From f852f826486df419058fd0a1257acafe31ce1540 Mon Sep 17 00:00:00 2001 From: Mark Hollow Date: Tue, 17 May 2016 21:48:12 +0700 Subject: [PATCH 001/112] raises RequestExceptions instead of returning them --- sift/client.py | 21 +++++++++------------ tests/client_test.py | 42 +++++++++++++++++++++++++++++++----------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/sift/client.py b/sift/client.py index 304397f..7a6e9b2 100644 --- a/sift/client.py +++ b/sift/client.py @@ -92,8 +92,8 @@ def track( Returns: A requests.Response object if the track call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. + raises a RuntimeError or subclass of + requests.exceptions.RequestException. """ if not isinstance( event, self.UNICODE_STRING) or len( @@ -133,7 +133,7 @@ def track( except requests.exceptions.RequestException as e: warnings.warn('Failed to track event: %s' % properties) warnings.warn(traceback.format_exc()) - return e + raise e def score(self, user_id, timeout=None): """Retrieves a user's fraud score from the Sift Science API. @@ -144,9 +144,8 @@ def score(self, user_id, timeout=None): user_id: A user's id. This id should be the same as the user_id used in event calls. Returns: - A requests.Response object if the score call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. + A requests.Response object if the score call succeeded, or raises + a RuntimeError or subclass of requests.exceptions.RequestException. """ if not isinstance( user_id, self.UNICODE_STRING) or len( @@ -169,7 +168,7 @@ def score(self, user_id, timeout=None): except requests.exceptions.RequestException as e: warnings.warn('Failed to get score for user %s' % user_id) warnings.warn(traceback.format_exc()) - return e + raise e def label(self, user_id, properties, timeout=None): """Labels a user as either good or bad through the Sift Science API. @@ -183,8 +182,7 @@ def label(self, user_id, properties, timeout=None): timeout(optional): specify a custom timeout for this call Returns: A requests.Response object if the label call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. + raises a RuntimeError or a subclass of requests.exceptions.RequestException. """ if not isinstance( user_id, self.UNICODE_STRING) or len( @@ -208,8 +206,7 @@ def unlabel(self, user_id, timeout=None): timeout(optional): specify a custom timeout for this call Returns: A requests.Response object if the unlabel call succeeded, otherwise - a subclass of requests.exceptions.RequestException indicating the - exception that occurred. + raises a RuntimeError or subclass of requests.exceptions.RequestException. """ if not isinstance( user_id, self.UNICODE_STRING) or len( @@ -234,7 +231,7 @@ def unlabel(self, user_id, timeout=None): except requests.exceptions.RequestException as e: warnings.warn('Failed to unlabel user %s' % user_id) warnings.warn(traceback.format_exc()) - return e + raise e class Response(object): diff --git a/tests/client_test.py b/tests/client_test.py index 42b3968..1713698 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -154,6 +154,7 @@ def test_event_ok(self): headers=mock.ANY, timeout=mock.ANY, params={}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -177,6 +178,7 @@ def test_event_with_timeout_param_ok(self): headers=mock.ANY, timeout=test_timeout, params={}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -196,6 +198,7 @@ def test_score_ok(self): 'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.55) @@ -216,6 +219,7 @@ def test_score_with_timeout_param_ok(self): 'api_key': self.test_key}, headers=mock.ANY, timeout=test_timeout) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.55) @@ -239,6 +243,7 @@ def test_sync_score_ok(self): timeout=mock.ANY, params={ 'return_score': True}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -264,6 +269,7 @@ def test_label_user_ok(self): mock_post.assert_called_with( 'https://api.siftscience.com/v203/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=mock.ANY, params={}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -289,6 +295,7 @@ def test_label_user_with_timeout_param_ok(self): mock_post.assert_called_with( 'https://api.siftscience.com/v203/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=test_timeout, params={}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -305,6 +312,7 @@ def test_unlabel_user_ok(self): 'https://api.siftscience.com/v203/users/%s/labels' % user_id, headers=mock.ANY, timeout=mock.ANY, params={ 'api_key': self.test_key}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) def test_unicode_string_parameter_support(self): @@ -345,6 +353,7 @@ def test_unlabel_user_with_special_chars_ok(self): 'https://api.siftscience.com/v203/users/%s/labels' % urllib.quote(user_id), headers=mock.ANY, timeout=mock.ANY, params={ 'api_key': self.test_key}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) def test_label_user__with_special_chars_ok(self): @@ -371,6 +380,7 @@ def test_label_user__with_special_chars_ok(self): headers=mock.ANY, timeout=mock.ANY, params={}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -392,6 +402,7 @@ def test_score__with_special_user_id_chars_ok(self): 'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.55) @@ -402,12 +413,15 @@ def test_exception_during_track_call(self): with mock.patch('requests.post') as mock_post: mock_post.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - response = self.sift_client.track( - '$transaction', valid_transaction_properties()) - assert(len(w) == 2) - assert('Failed to track event:' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) + try: + response = self.sift_client.track( + '$transaction', valid_transaction_properties()) + except Exception as e: + assert(isinstance(e, requests.exceptions.RequestException)) + assert(len(w) == 2) + assert('Failed to track event:' in str(w[0].message)) + assert('RequestException: Failed' in str(w[1].message)) + assert('Traceback' in str(w[1].message)) def test_exception_during_score_call(self): with warnings.catch_warnings(record=True) as w: @@ -415,11 +429,14 @@ def test_exception_during_score_call(self): with mock.patch('requests.get') as mock_get: mock_get.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - response = self.sift_client.score('Fred') - assert(len(w) == 2) - assert('Failed to get score for user Fred' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) + try: + response = self.sift_client.score('Fred') + except Exception as e: + assert(isinstance(e, requests.exceptions.RequestException)) + assert(len(w) == 2) + assert('Failed to get score for user Fred' in str(w[0].message)) + assert('RequestException: Failed' in str(w[1].message)) + assert('Traceback' in str(w[1].message)) def test_exception_during_unlabel_call(self): with warnings.catch_warnings(record=True) as w: @@ -428,6 +445,8 @@ def test_exception_during_unlabel_call(self): mock_delete.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) response = self.sift_client.unlabel('Fred') + assert(isinstance(response, + requests.exceptions.RequestException)) assert(len(w) == 2) assert('Failed to unlabel user Fred' in str(w[0].message)) @@ -456,6 +475,7 @@ def test_return_actions_on_track(self): params={ 'return_action': True}) + assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") From 23a723ecb54f4d739c7d813c39a4a4ed85c36761 Mon Sep 17 00:00:00 2001 From: Mark Hollow Date: Tue, 17 May 2016 22:33:20 +0700 Subject: [PATCH 002/112] fixed uncaught exception --- tests/client_test.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/client_test.py b/tests/client_test.py index 1713698..5686657 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -444,14 +444,14 @@ def test_exception_during_unlabel_call(self): with mock.patch('requests.delete') as mock_delete: mock_delete.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - response = self.sift_client.unlabel('Fred') - assert(isinstance(response, - requests.exceptions.RequestException)) - - assert(len(w) == 2) - assert('Failed to unlabel user Fred' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) + try: + response = self.sift_client.unlabel('Fred') + except Exception as e: + assert(isinstance(e, requests.exceptions.RequestException)) + assert(len(w) == 2) + assert('Failed to unlabel user Fred' in str(w[0].message)) + assert('RequestException: Failed' in str(w[1].message)) + assert('Traceback' in str(w[1].message)) def test_return_actions_on_track(self): event = '$transaction' From 825648312bc555614ad875a5d8589cd741c37a6b Mon Sep 17 00:00:00 2001 From: Mark Hollow Date: Tue, 21 Jun 2016 15:31:48 +0700 Subject: [PATCH 003/112] removed warnings & revised tests; updated some doc strings --- sift/client.py | 20 ++++---------- tests/client_test.py | 65 +++++++++++++++++--------------------------- 2 files changed, 31 insertions(+), 54 deletions(-) diff --git a/sift/client.py b/sift/client.py index 7a6e9b2..fd58979 100644 --- a/sift/client.py +++ b/sift/client.py @@ -4,8 +4,6 @@ import json import requests -import traceback -import warnings import sys if sys.version_info[0] < 3: import urllib @@ -91,7 +89,7 @@ def track( https://siftscience.com/resources/tutorials/formulas Returns: - A requests.Response object if the track call succeeded, otherwise + A sift.client.Response object if the track call succeeded, otherwise raises a RuntimeError or subclass of requests.exceptions.RequestException. """ @@ -131,8 +129,6 @@ def track( params=params) return Response(response) except requests.exceptions.RequestException as e: - warnings.warn('Failed to track event: %s' % properties) - warnings.warn(traceback.format_exc()) raise e def score(self, user_id, timeout=None): @@ -144,7 +140,7 @@ def score(self, user_id, timeout=None): user_id: A user's id. This id should be the same as the user_id used in event calls. Returns: - A requests.Response object if the score call succeeded, or raises + A sift.client.Response object if the score call succeeded, or raises a RuntimeError or subclass of requests.exceptions.RequestException. """ if not isinstance( @@ -166,8 +162,6 @@ def score(self, user_id, timeout=None): params=params) return Response(response) except requests.exceptions.RequestException as e: - warnings.warn('Failed to get score for user %s' % user_id) - warnings.warn(traceback.format_exc()) raise e def label(self, user_id, properties, timeout=None): @@ -181,7 +175,7 @@ def label(self, user_id, properties, timeout=None): properties: A dict of additional event-specific attributes to track timeout(optional): specify a custom timeout for this call Returns: - A requests.Response object if the label call succeeded, otherwise + A sift.client.Response object if the label call succeeded, otherwise raises a RuntimeError or a subclass of requests.exceptions.RequestException. """ if not isinstance( @@ -205,7 +199,7 @@ def unlabel(self, user_id, timeout=None): event calls. timeout(optional): specify a custom timeout for this call Returns: - A requests.Response object if the unlabel call succeeded, otherwise + A sift.client.Response object if the unlabel call succeeded, otherwise raises a RuntimeError or subclass of requests.exceptions.RequestException. """ if not isinstance( @@ -229,8 +223,6 @@ def unlabel(self, user_id, timeout=None): return Response(response) except requests.exceptions.RequestException as e: - warnings.warn('Failed to unlabel user %s' % user_id) - warnings.warn(traceback.format_exc()) raise e @@ -259,9 +251,9 @@ def __init__(self, http_response): self.request = json.loads(self.body['request']) else: self.request = None - except ValueError as e: + except ValueError: not_json_warning = "Failed to parse json response from {}. HTTP status code: {}.".format(self.url, self.http_status_code) - warnings.warn(not_json_warning) + raise ApiException(not_json_warning) finally: if (int(self.http_status_code) < 200 or int(self.http_status_code) >= 300): non_2xx_warning = "{} returned non-2XX http status code {}".format(self.url, self.http_status_code) diff --git a/tests/client_test.py b/tests/client_test.py index 5686657..af41fdf 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -408,50 +408,35 @@ def test_score__with_special_user_id_chars_ok(self): assert(response.body['score'] == 0.55) def test_exception_during_track_call(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - with mock.patch('requests.post') as mock_post: - mock_post.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.track( - '$transaction', valid_transaction_properties()) - except Exception as e: - assert(isinstance(e, requests.exceptions.RequestException)) - assert(len(w) == 2) - assert('Failed to track event:' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) + warnings.simplefilter("always") + with mock.patch('requests.post') as mock_post: + mock_post.side_effect = mock.Mock( + side_effect=requests.exceptions.RequestException("Failed")) + try: + response = self.sift_client.track( + '$transaction', valid_transaction_properties()) + except Exception as e: + assert(isinstance(e, requests.exceptions.RequestException)) def test_exception_during_score_call(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - with mock.patch('requests.get') as mock_get: - mock_get.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.score('Fred') - except Exception as e: - assert(isinstance(e, requests.exceptions.RequestException)) - assert(len(w) == 2) - assert('Failed to get score for user Fred' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) + warnings.simplefilter("always") + with mock.patch('requests.get') as mock_get: + mock_get.side_effect = mock.Mock( + side_effect=requests.exceptions.RequestException("Failed")) + try: + response = self.sift_client.score('Fred') + except Exception as e: + assert(isinstance(e, requests.exceptions.RequestException)) def test_exception_during_unlabel_call(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - with mock.patch('requests.delete') as mock_delete: - mock_delete.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.unlabel('Fred') - except Exception as e: - assert(isinstance(e, requests.exceptions.RequestException)) - assert(len(w) == 2) - assert('Failed to unlabel user Fred' in str(w[0].message)) - assert('RequestException: Failed' in str(w[1].message)) - assert('Traceback' in str(w[1].message)) + warnings.simplefilter("always") + with mock.patch('requests.delete') as mock_delete: + mock_delete.side_effect = mock.Mock( + side_effect=requests.exceptions.RequestException("Failed")) + try: + response = self.sift_client.unlabel('Fred') + except Exception as e: + assert(isinstance(e, requests.exceptions.RequestException)) def test_return_actions_on_track(self): event = '$transaction' From da951671da72eca69ffee75bca70a0a9229d0a4e Mon Sep 17 00:00:00 2001 From: Mark Hollow Date: Tue, 21 Jun 2016 15:45:51 +0700 Subject: [PATCH 004/112] now only raises ApiExceptions --- sift/client.py | 30 +++++++++++++++--------------- tests/client_test.py | 34 +++++++++++++++++----------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/sift/client.py b/sift/client.py index fd58979..a5bcf78 100644 --- a/sift/client.py +++ b/sift/client.py @@ -30,13 +30,13 @@ def __init__(self, api_key=None, api_url=API_URL, timeout=2.0): to 2 seconds. """ if not isinstance(api_url, str) or len(api_url.strip()) == 0: - raise RuntimeError("api_url must be a string") + raise ApiException("api_url must be a string") if api_key is None: api_key = sift.api_key if not isinstance(api_key, str) or len(api_key.strip()) == 0: - raise RuntimeError("valid api_key is required") + raise ApiException("valid api_key is required") self.api_key = api_key self.url = api_url + '/v%s' % version.API_VERSION @@ -90,16 +90,15 @@ def track( Returns: A sift.client.Response object if the track call succeeded, otherwise - raises a RuntimeError or subclass of - requests.exceptions.RequestException. + raises an ApiException. """ if not isinstance( event, self.UNICODE_STRING) or len( event.strip()) == 0: - raise RuntimeError("event must be a string") + raise ApiException("event must be a string") if not isinstance(properties, dict) or len(properties) == 0: - raise RuntimeError("properties dictionary may not be empty") + raise ApiException("properties dictionary may not be empty") headers = {'Content-type': 'application/json', 'Accept': '*/*', @@ -129,7 +128,7 @@ def track( params=params) return Response(response) except requests.exceptions.RequestException as e: - raise e + raise ApiException(str(e)) def score(self, user_id, timeout=None): """Retrieves a user's fraud score from the Sift Science API. @@ -141,12 +140,12 @@ def score(self, user_id, timeout=None): event calls. Returns: A sift.client.Response object if the score call succeeded, or raises - a RuntimeError or subclass of requests.exceptions.RequestException. + an ApiException. """ if not isinstance( user_id, self.UNICODE_STRING) or len( user_id.strip()) == 0: - raise RuntimeError("user_id must be a string") + raise ApiException("user_id must be a string") if timeout is None: timeout = self.timeout @@ -162,7 +161,7 @@ def score(self, user_id, timeout=None): params=params) return Response(response) except requests.exceptions.RequestException as e: - raise e + raise ApiException(str(e)) def label(self, user_id, properties, timeout=None): """Labels a user as either good or bad through the Sift Science API. @@ -176,12 +175,12 @@ def label(self, user_id, properties, timeout=None): timeout(optional): specify a custom timeout for this call Returns: A sift.client.Response object if the label call succeeded, otherwise - raises a RuntimeError or a subclass of requests.exceptions.RequestException. + raises an ApiException. """ if not isinstance( user_id, self.UNICODE_STRING) or len( user_id.strip()) == 0: - raise RuntimeError("user_id must be a string") + raise ApiException("user_id must be a string") return self.track( '$label', @@ -200,12 +199,12 @@ def unlabel(self, user_id, timeout=None): timeout(optional): specify a custom timeout for this call Returns: A sift.client.Response object if the unlabel call succeeded, otherwise - raises a RuntimeError or subclass of requests.exceptions.RequestException. + raises an ApiException. """ if not isinstance( user_id, self.UNICODE_STRING) or len( user_id.strip()) == 0: - raise RuntimeError("user_id must be a string") + raise ApiException("user_id must be a string") if timeout is None: timeout = self.timeout @@ -223,7 +222,7 @@ def unlabel(self, user_id, timeout=None): return Response(response) except requests.exceptions.RequestException as e: - raise e + raise ApiException(str(e)) class Response(object): @@ -273,6 +272,7 @@ def is_ok(self): return self.api_status == 0 + class ApiException(Exception): def __init__(self, *args, **kwargs): Exception.__init__(self, *args, **kwargs) diff --git a/tests/client_test.py b/tests/client_test.py index af41fdf..512921e 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -93,7 +93,7 @@ def setUp(self): def test_global_api_key(self): # test for error if global key is undefined - self.assertRaises(RuntimeError, sift.Client) + self.assertRaises(sift.client.ApiException, sift.Client) sift.api_key = "a_test_global_api_key" local_api_key = "a_test_local_api_key" @@ -110,32 +110,32 @@ def test_global_api_key(self): assert(client2.api_key == sift.api_key) def test_constructor_requires_valid_api_key(self): - self.assertRaises(RuntimeError, sift.Client, None) - self.assertRaises(RuntimeError, sift.Client, '') + self.assertRaises(sift.client.ApiException, sift.Client, None) + self.assertRaises(sift.client.ApiException, sift.Client, '') def test_constructor_invalid_api_url(self): - self.assertRaises(RuntimeError, sift.Client, self.test_key, None) - self.assertRaises(RuntimeError, sift.Client, self.test_key, '') + self.assertRaises(sift.client.ApiException, sift.Client, self.test_key, None) + self.assertRaises(sift.client.ApiException, sift.Client, self.test_key, '') def test_constructor_api_key(self): client = sift.Client(self.test_key) self.assertEqual(client.api_key, self.test_key) def test_track_requires_valid_event(self): - self.assertRaises(RuntimeError, self.sift_client.track, None, {}) - self.assertRaises(RuntimeError, self.sift_client.track, '', {}) - self.assertRaises(RuntimeError, self.sift_client.track, 42, {}) + self.assertRaises(sift.client.ApiException, self.sift_client.track, None, {}) + self.assertRaises(sift.client.ApiException, self.sift_client.track, '', {}) + self.assertRaises(sift.client.ApiException, self.sift_client.track, 42, {}) def test_track_requires_properties(self): event = 'custom_event' - self.assertRaises(RuntimeError, self.sift_client.track, event, None) - self.assertRaises(RuntimeError, self.sift_client.track, event, 42) - self.assertRaises(RuntimeError, self.sift_client.track, event, {}) + self.assertRaises(sift.client.ApiException, self.sift_client.track, event, None) + self.assertRaises(sift.client.ApiException, self.sift_client.track, event, 42) + self.assertRaises(sift.client.ApiException, self.sift_client.track, event, {}) def test_score_requires_user_id(self): - self.assertRaises(RuntimeError, self.sift_client.score, None) - self.assertRaises(RuntimeError, self.sift_client.score, '') - self.assertRaises(RuntimeError, self.sift_client.score, 42) + self.assertRaises(sift.client.ApiException, self.sift_client.score, None) + self.assertRaises(sift.client.ApiException, self.sift_client.score, '') + self.assertRaises(sift.client.ApiException, self.sift_client.score, 42) def test_event_ok(self): event = '$transaction' @@ -416,7 +416,7 @@ def test_exception_during_track_call(self): response = self.sift_client.track( '$transaction', valid_transaction_properties()) except Exception as e: - assert(isinstance(e, requests.exceptions.RequestException)) + assert(isinstance(e, sift.client.ApiException)) def test_exception_during_score_call(self): warnings.simplefilter("always") @@ -426,7 +426,7 @@ def test_exception_during_score_call(self): try: response = self.sift_client.score('Fred') except Exception as e: - assert(isinstance(e, requests.exceptions.RequestException)) + assert(isinstance(e, sift.client.ApiException)) def test_exception_during_unlabel_call(self): warnings.simplefilter("always") @@ -436,7 +436,7 @@ def test_exception_during_unlabel_call(self): try: response = self.sift_client.unlabel('Fred') except Exception as e: - assert(isinstance(e, requests.exceptions.RequestException)) + assert(isinstance(e, sift.client.ApiException)) def test_return_actions_on_track(self): event = '$transaction' From 53187f4eb8be97e82ae8e68a0da47ea8ab9802cf Mon Sep 17 00:00:00 2001 From: Mark Hollow Date: Tue, 21 Jun 2016 15:47:32 +0700 Subject: [PATCH 005/112] added docstring --- sift/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sift/client.py b/sift/client.py index a5bcf78..8f7ff77 100644 --- a/sift/client.py +++ b/sift/client.py @@ -230,7 +230,10 @@ class Response(object): HTTP_CODES_WITHOUT_BODY = [204, 304] def __init__(self, http_response): - + """ + Raises ApiException on invalid JSON in Response body or non-2XX HTTP + status code. + """ # Set defaults. self.body = None self.request = None From affdc8552eb651736eb73c49e4a27fa93e326cd6 Mon Sep 17 00:00:00 2001 From: Fred Sadaghiani Date: Tue, 21 Jun 2016 10:55:29 -0700 Subject: [PATCH 006/112] Upgrade to v2.0.0.0 --- CHANGES.md | 45 +++++++++++++++++++ CHANGES.rst | 32 -------------- README.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ README.rst | 104 ------------------------------------------- sift/version.py | 2 +- 5 files changed, 160 insertions(+), 137 deletions(-) create mode 100644 CHANGES.md delete mode 100644 CHANGES.rst create mode 100644 README.md delete mode 100644 README.rst diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..e6e2e00 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,45 @@ +2.0.0.0 (2016-06-21) +==================== +- Major version bump; client APIs have changed to raise exceptions + in the case of API errors to be more Pythonic + +1.1.2.1 (2015-05-18) +==================== + +- Added Python 2.6 compatibility +- Added Travis CI +- Minor bug fixes + +1.1.2.0 (2015-02-04) +==================== + +- Added Unlabel functionaly +- Minor bug fixes. + +1.1.1.0 (2014-09-3) +=================== + +- Added timeout parameter to track, score, and label functions. + +1.1.0.0 (2014-08-25) +==================== + +- Added Module-scoped API key. +- Minor documentation updates. + +0.2.0 (2014-08-20) +================== + +- Added Label and Score functions. +- Added Python 3 compatibility. + +0.1.1 (2014-02-21) +================== + +- Bump default API version to v203. + +0.1.0 (2013-01-08) +================== + +- Just the Python REST client itself. + diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index 7557231..0000000 --- a/CHANGES.rst +++ /dev/null @@ -1,32 +0,0 @@ -1.1.2.1 (2015-05-18) -==================== -* Added Python 2.6 compatibility -* Added Travis CI -* Minor bug fixes - -1.1.2.0 (2015-02-04) -==================== -* Added Unlabel functionaly -* Minor bug fixes. - -1.1.1.0 (2014-09-3) -=================== -* Added timeout parameter to track, score, and label functions. - -1.1.0.0 (2014-08-25) -==================== -* Added Module-scoped API key. -* Minor documentation updates. - -0.2.0 (2014-08-20) -================== -* Added Label and Score functions. -* Added Python 3 compatibility. - -0.1.1 (2014-02-21) -================== -* Bump default API version to v203. - -0.1.0 (2013-01-08) -================== -* Just the Python REST client itself. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5035a1 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Sift Science Python Bindings ![TravisCI](https://travis-ci.org/SiftScience/sift-python.png?branch=master) + +Bindings for Sift Science's [Events](https://siftscience.com/resources/references/events-api.html), [Labels](https://siftscience.com/resources/references/labels-api.html), and [Score](https://siftscience.com/resources/references/score-api.html) APIs. + +## Installation + +Set up a virtual environment with virtualenv (otherwise you will need to +make the pip calls as sudo): + + virtualenv venv + source venv/bin/activate + +Get the latest released package from pip: + +Python 2: + + pip install sift + +Python 3: + + pip3 install sift + +or install newest source directly from GitHub: + +Python 2: + + pip install git+https://github.com/SiftScience/sift-python + +Python 3: + + pip3 install git+https://github.com/SiftScience/sift-python + + +## Documentation + +Please see [here](https://siftscience.com/docs/api/python) for the most up-to-date documentation. + +## Changelog + +Please see [the CHANGELOG](https://github.com/SiftScience/sift-python/blob/master/CHANGES.md) for a history of all changes. Note, that in v2.0.0.0, the API semantics were changed to raise an exception in the case of error to be more pythonic. Client code will need to be updated to catch `sift.client.ApiException` exceptions. + +## Usage + +Here's an example: + +```python + +import sift.client + +sift.api_key = '' +client = sift.Client() + +user_id = "23056" # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ + +# Track a transaction event -- note this is a blocking call +properties = { + "$user_id" : user_id, + "$user_email" : "buyer@gmail.com", + "$seller_user_id" : "2371", + "seller_user_email" : "seller@gmail.com", + "$transaction_id" : "573050", + "$payment_method" : { + "$payment_type" : "$credit_card", + "$payment_gateway" : "$braintree", + "$card_bin" : "542486", + "$card_last4" : "4444" + }, + "$currency_code" : "USD", + "$amount" : 15230000, +} + +try: + response = client.track("$transaction", properties) + if response.is_ok(): + print "Successfully tracked event" +except sift.client.ApiException: + # request failed + + +# Request a score for the user with user_id 23056 +try: + response = client.score(user_id) + s = json.dumps(response.body) + print s + +except sift.client.ApiException: + # request failed + + +try: + # Label the user with user_id 23056 as Bad with all optional fields + response = client.label(user_id,{ "$is_bad" : True, "$reasons" : ["$chargeback", ], + "$description" : "Chargeback issued", + "$source" : "Manual Review", + "$analyst" : "analyst.name@your_domain.com"}) +except sift.client.ApiException: + # request failed + + +# Remove a label from a user with user_id 23056 +try: + response = client.unlabel(user_id) +except sift.client.ApiException: + # request failed + +``` + +## Testing + +Before submitting a change, make sure the following commands run without +errors from the root dir of the repository: + + PYTHONPATH=. python tests/client_test.py + PYTHONPATH=. python3 tests/client_test.py diff --git a/README.rst b/README.rst deleted file mode 100644 index f96ba91..0000000 --- a/README.rst +++ /dev/null @@ -1,104 +0,0 @@ -============================ -Sift Science Python Bindings |TravisCI|_ -============================ - -.. |TravisCI| image:: https://travis-ci.org/SiftScience/sift-python.png?branch=master -.. _TravisCI: https://travis-ci.org/SiftScience/sift-python - -Bindings for Sift Science's `Events `_, `Labels `_, and `Score `_ APIs. - -Installation -============ - -Set up a virtual environment with virtualenv (otherwise you will need to make the pip calls as sudo): -:: - - virtualenv venv - source venv/bin/activate - -Get the latest released package from pip: - -Python 2: -:: - - pip install sift - -Python 3: -:: - - pip3 install sift - -or install newest source directly from GitHub: - -Python 2: -:: - - pip install git+https://github.com/SiftScience/sift-python - -Python 3: -:: - - pip3 install git+https://github.com/SiftScience/sift-python - -Usage -===== - -Here's an example: - -:: - - import sift.client - - sift.api_key = '' - client = sift.Client() - - user_id= "23056" # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ - - # Track a transaction event -- note this is blocking - event = "$transaction" - - properties = { - "$user_id" : user_id, - "$user_email" : "buyer@gmail.com", - "$seller_user_id" : "2371", - "seller_user_email" : "seller@gmail.com", - "$transaction_id" : "573050", - "$payment_method" : { - "$payment_type" : "$credit_card", - "$payment_gateway" : "$braintree", - "$card_bin" : "542486", - "$card_last4" : "4444" - }, - "$currency_code" : "USD", - "$amount" : 15230000, - } - - response = client.track(event, properties) - - - response.is_ok() # returns True of False - - print response # prints entire response body and http status code - - - # Request a score for the user with user_id 23056 - response = client.score(user_id) - - # Label the user with user_id 23056 as Bad with all optional fields - response = client.label(user_id,{ "$is_bad" : True, "$reasons" : ["$chargeback", ], - "$description" : "Chargeback issued", - "$source" : "Manual Review", - "$analyst" : "analyst.name@your_domain.com"}) - - # Remove a label from a user with user_id 23056 - response = client.unlabel(user_id) - -Testing -======= - -Before submitting a change, make sure the following commands run without errors from the root dir of the repository: - -:: - - PYTHONPATH=. python tests/client_test.py - PYTHONPATH=. python3 tests/client_test.py diff --git a/sift/version.py b/sift/version.py index dd17efd..cdd6176 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '1.1.2.6' +VERSION = '2.0.0.0' API_VERSION = '203' From 977f48a5d5e8add32a5d4268244c841216d73482 Mon Sep 17 00:00:00 2001 From: Stephen Brennan Date: Wed, 6 Jul 2016 15:52:20 -0700 Subject: [PATCH 007/112] Don't require Content-Length header. Fixes #45 --- sift/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sift/client.py b/sift/client.py index 8f7ff77..5f9d8d7 100644 --- a/sift/client.py +++ b/sift/client.py @@ -243,7 +243,7 @@ def __init__(self, http_response): self.url = http_response.url if (self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY) \ - and 'content-length' in http_response.headers: + and http_response.text: try: self.body = http_response.json() self.api_status = self.body['status'] From 7d0b81829854e36830720d5c2c704ec7ef75608c Mon Sep 17 00:00:00 2001 From: John McSpedon Date: Thu, 7 Jul 2016 13:43:50 -0700 Subject: [PATCH 008/112] version 2.0.0.0 -> 2.0.1.0 --- CHANGES.md | 4 ++++ sift/version.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index e6e2e00..61676c3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +2.0.1.0 (2016-07-07) +==================== +- Fixes bug parsing chunked HTTP responses + 2.0.0.0 (2016-06-21) ==================== - Major version bump; client APIs have changed to raise exceptions diff --git a/sift/version.py b/sift/version.py index cdd6176..d638224 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '2.0.0.0' +VERSION = '2.0.1.0' API_VERSION = '203' From 25c33dc073d7810654a059e8ad8bdf8257dfbb15 Mon Sep 17 00:00:00 2001 From: Jacob Burnim Date: Fri, 8 Jul 2016 08:39:18 -0700 Subject: [PATCH 009/112] Adds support for calling multiple versions of Sift's APIs. --- sift/client.py | 67 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/sift/client.py b/sift/client.py index 5f9d8d7..4eadb76 100644 --- a/sift/client.py +++ b/sift/client.py @@ -11,14 +11,19 @@ import urllib.parse as urllib import sift -from . import version +import sift.version API_URL = 'https://api.siftscience.com' class Client(object): - def __init__(self, api_key=None, api_url=API_URL, timeout=2.0): + def __init__( + self, + api_key=None, + api_url=API_URL, + timeout=2.0, + version=sift.version.API_VERSION): """Initialize the client. Args: @@ -39,25 +44,25 @@ def __init__(self, api_key=None, api_url=API_URL, timeout=2.0): raise ApiException("valid api_key is required") self.api_key = api_key - self.url = api_url + '/v%s' % version.API_VERSION + self.url = api_url self.timeout = timeout + self.version = version if sys.version_info[0] < 3: self.UNICODE_STRING = basestring else: self.UNICODE_STRING = str - def user_agent(self): - return 'SiftScience/v%s sift-python/%s' % ( - version.API_VERSION, version.VERSION) + def _user_agent(self): + return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) - def event_url(self): - return self.url + '/events' + def _event_url(self, version): + return self.url + '/v%s/events' % version - def score_url(self, user_id): - return self.url + '/score/%s' % urllib.quote(user_id) + def _score_url(self, user_id, version): + return self.url + '/v%s/score/%s' % (version, urllib.quote(user_id)) - def label_url(self, user_id): - return self.url + '/users/%s/labels' % urllib.quote(user_id) + def _label_url(self, user_id, version): + return self.url + '/v%s/users/%s/labels' % (version, urllib.quote(user_id)) def track( self, @@ -66,7 +71,8 @@ def track( path=None, return_score=False, return_action=False, - timeout=None): + timeout=None, + version=None): """Track an event and associated properties to the Sift Science client. This call is blocking. Check out https://siftscience.com/resources/references/events-api for more information on what types of events you can send and fields you can add to the @@ -102,10 +108,13 @@ def track( headers = {'Content-type': 'application/json', 'Accept': '*/*', - 'User-Agent': self.user_agent()} + 'User-Agent': self._user_agent()} + + if version is None: + version = self.version if path is None: - path = self.event_url() + path = self._event_url(version) if timeout is None: timeout = self.timeout @@ -130,7 +139,7 @@ def track( except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def score(self, user_id, timeout=None): + def score(self, user_id, timeout=None, version=None): """Retrieves a user's fraud score from the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/score_api.html for more information on our Score response structure @@ -150,12 +159,15 @@ def score(self, user_id, timeout=None): if timeout is None: timeout = self.timeout - headers = {'User-Agent': self.user_agent()} + if version is None: + version = self.version + + headers = {'User-Agent': self._user_agent()} params = {'api_key': self.api_key} try: response = requests.get( - self.score_url(user_id), + self._score_url(user_id, version), headers=headers, timeout=timeout, params=params) @@ -163,7 +175,7 @@ def score(self, user_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def label(self, user_id, properties, timeout=None): + def label(self, user_id, properties, timeout=None, version=None): """Labels a user as either good or bad through the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html for more information on what fields to send in properties. @@ -182,13 +194,17 @@ def label(self, user_id, properties, timeout=None): user_id.strip()) == 0: raise ApiException("user_id must be a string") + if version is None: + version = self.version + return self.track( '$label', properties, - self.label_url(user_id), - timeout=timeout) + path=self._label_url(user_id, version), + timeout=timeout, + version=version) - def unlabel(self, user_id, timeout=None): + def unlabel(self, user_id, timeout=None, version=None): """unlabels a user through the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html for more information. @@ -209,13 +225,16 @@ def unlabel(self, user_id, timeout=None): if timeout is None: timeout = self.timeout - headers = {'User-Agent': self.user_agent()} + if version is None: + version = self.version + + headers = {'User-Agent': self._user_agent()} params = {'api_key': self.api_key} try: response = requests.delete( - self.label_url(user_id), + self._label_url(user_id, version), headers=headers, timeout=timeout, params=params) From 8f5d21c4b3e28c107f10eaac5612767b8a99b94e Mon Sep 17 00:00:00 2001 From: Jacob Burnim Date: Fri, 8 Jul 2016 10:11:38 -0700 Subject: [PATCH 010/112] Makes tests discoverable by unittest. --- .travis.yml | 2 +- README.md | 4 ++-- setup.py | 1 + tests/__init__.py | 0 tests/{client_test.py => test_client.py} | 0 5 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 tests/__init__.py rename tests/{client_test.py => test_client.py} (100%) diff --git a/.travis.yml b/.travis.yml index 0c4efd2..f9cf832 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,4 +8,4 @@ install: - pip install -e .[test] # command to run tests script: - - python tests/client_test.py + - unit2 diff --git a/README.md b/README.md index f5035a1..f2788b2 100644 --- a/README.md +++ b/README.md @@ -110,5 +110,5 @@ except sift.client.ApiException: Before submitting a change, make sure the following commands run without errors from the root dir of the repository: - PYTHONPATH=. python tests/client_test.py - PYTHONPATH=. python3 tests/client_test.py + python -m unittest discover + python3 -m unittest discover diff --git a/setup.py b/setup.py index 991554a..7701bf5 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ extras_require={ 'test': [ 'mock >= 1.0.1', + 'unittest2 >= 1, < 2', ], }, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/client_test.py b/tests/test_client.py similarity index 100% rename from tests/client_test.py rename to tests/test_client.py From 3bf8bd7adb41134a3c8079e18bcd3d6a36f2a00f Mon Sep 17 00:00:00 2001 From: Jacob Burnim Date: Fri, 8 Jul 2016 14:42:27 -0700 Subject: [PATCH 011/112] Adds support for Sift Science's v204 API. Adds support for the Workflow Status API, User Decisions API, and Order Decisions API. --- README.md | 63 +++++- sift/__init__.py | 1 + sift/client.py | 244 +++++++++++++++++---- sift/version.py | 2 +- tests/test_client.py | 251 +++++++++++++++------ tests/test_client_v203.py | 451 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 882 insertions(+), 130 deletions(-) create mode 100644 tests/test_client_v203.py diff --git a/README.md b/README.md index f2788b2..b2ba753 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ # Sift Science Python Bindings ![TravisCI](https://travis-ci.org/SiftScience/sift-python.png?branch=master) -Bindings for Sift Science's [Events](https://siftscience.com/resources/references/events-api.html), [Labels](https://siftscience.com/resources/references/labels-api.html), and [Score](https://siftscience.com/resources/references/score-api.html) APIs. +Bindings for Sift Science's APIs -- including the +[Events](https://siftscience.com/resources/references/events-api.html), +[Labels](https://siftscience.com/resources/references/labels-api.html), +and +[Score](https://siftscience.com/resources/references/score-api.html) +APIs. + ## Installation -Set up a virtual environment with virtualenv (otherwise you will need to -make the pip calls as sudo): +Set up a virtual environment with virtualenv (otherwise you will need +to make the pip calls as sudo): virtualenv venv source venv/bin/activate @@ -33,11 +39,19 @@ Python 3: ## Documentation -Please see [here](https://siftscience.com/docs/api/python) for the most up-to-date documentation. +Please see [here](https://siftscience.com/docs/api/python) for the +most up-to-date documentation. ## Changelog -Please see [the CHANGELOG](https://github.com/SiftScience/sift-python/blob/master/CHANGES.md) for a history of all changes. Note, that in v2.0.0.0, the API semantics were changed to raise an exception in the case of error to be more pythonic. Client code will need to be updated to catch `sift.client.ApiException` exceptions. +Please see +[the CHANGELOG](https://github.com/SiftScience/sift-python/blob/master/CHANGES.md) +for a history of all changes. + +Note, that in v2.0.0.0, the API semantics were changed to raise an +exception in the case of error to be more pythonic. Client code will +need to be updated to catch `sift.client.ApiException` exceptions. + ## Usage @@ -47,11 +61,13 @@ Here's an example: import sift.client -sift.api_key = '' +sift.api_key = '' +sift.account_id = '' client = sift.Client() user_id = "23056" # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ + # Track a transaction event -- note this is a blocking call properties = { "$user_id" : user_id, @@ -89,22 +105,47 @@ except sift.client.ApiException: try: # Label the user with user_id 23056 as Bad with all optional fields - response = client.label(user_id,{ "$is_bad" : True, "$reasons" : ["$chargeback", ], - "$description" : "Chargeback issued", - "$source" : "Manual Review", - "$analyst" : "analyst.name@your_domain.com"}) + response = client.label(user_id, { + "$is_bad" : True, + "$abuse_type" : "payment_abuse", + "$description" : "Chargeback issued", + "$source" : "Manual Review", + "$analyst" : "analyst.name@your_domain.com" + }) except sift.client.ApiException: # request failed # Remove a label from a user with user_id 23056 try: - response = client.unlabel(user_id) + response = client.unlabel(user_id, abuse_type='content_abuse') +except sift.client.ApiException: + # request failed + + +# Get the status of a workflow run +try: + response = client.get_workflow_status('my_run_id'); +except sift.client.ApiException: + # request failed + + +# Get the latest decisions for a user +try: + response = client.get_user_decisions('example_user'); +except sift.client.ApiException: + # request failed + + +# Get the latest decisions for an order +try: + response = client.get_order_decisions('example_order'); except sift.client.ApiException: # request failed ``` + ## Testing Before submitting a change, make sure the following commands run without diff --git a/sift/__init__.py b/sift/__init__.py index b14df89..c1b28a2 100644 --- a/sift/__init__.py +++ b/sift/__init__.py @@ -1,2 +1,3 @@ api_key = None +account_id = None from .client import Client diff --git a/sift/client.py b/sift/client.py index 4eadb76..1737912 100644 --- a/sift/client.py +++ b/sift/client.py @@ -4,6 +4,7 @@ import json import requests +import requests.auth import sys if sys.version_info[0] < 3: import urllib @@ -14,6 +15,7 @@ import sift.version API_URL = 'https://api.siftscience.com' +API3_URL = 'https://api3.siftscience.com' class Client(object): @@ -23,16 +25,27 @@ def __init__( api_key=None, api_url=API_URL, timeout=2.0, + account_id=None, version=sift.version.API_VERSION): """Initialize the client. Args: api_key: Your Sift Science API key associated with your customer account. You can obtain this from - https://siftscience.com/quickstart - api_url: The URL to send events to. + https://siftscience.com/console/developer/api-keys . + + api_url: Base URL, including scheme and host, for sending events. + Defaults to 'https://api.siftscience.com'. + timeout: Number of seconds to wait before failing request. Defaults to 2 seconds. + + account_id: The ID of your Sift Science account. You can obtain + this from https://siftscience.com/console/account/profile . + + version: The version of the Sift Science API to call. Defaults to + the latest version ('204'). + """ if not isinstance(api_url, str) or len(api_url.strip()) == 0: raise ApiException("api_url must be a string") @@ -46,23 +59,13 @@ def __init__( self.api_key = api_key self.url = api_url self.timeout = timeout + self.account_id = account_id or sift.account_id self.version = version if sys.version_info[0] < 3: self.UNICODE_STRING = basestring else: self.UNICODE_STRING = str - def _user_agent(self): - return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) - - def _event_url(self, version): - return self.url + '/v%s/events' % version - - def _score_url(self, user_id, version): - return self.url + '/v%s/score/%s' % (version, urllib.quote(user_id)) - - def _label_url(self, user_id, version): - return self.url + '/v%s/users/%s/labels' % (version, urllib.quote(user_id)) def track( self, @@ -71,6 +74,8 @@ def track( path=None, return_score=False, return_action=False, + return_workflow_status=False, + abuse_types=None, timeout=None, version=None): """Track an event and associated properties to the Sift Science client. @@ -83,24 +88,33 @@ def track( event name such as "$transaction" or "$create_order" or a custom event name (that does not start with a $). - properties: A dict of additional event-specific attributes to track + properties: A dict of additional event-specific attributes to track. return_score: Whether the API response should include a score for this - user (the score will be calculated using this event). This feature must be - enabled for your account in order to use it. Please contact - support@siftscience.com if you are interested in using this feature. + user (the score will be calculated using this event). return_action: Whether the API response should include actions in the response. For more information on how this works, please visit the tutorial at: - https://siftscience.com/resources/tutorials/formulas + https://siftscience.com/resources/tutorials/formulas . + + return_workflow_status: Whether the API response should + include the status of any workflow run as a result of + the tracked event. + + abuse_types(optional): List of abuse types, specifying for which abuse types a score + should be returned (if scores were requested). If not specified, a score will + be returned for every abuse_type to which you are subscribed. + + timeout(optional): Use a custom timeout (in seconds) for this call. + + version(optional): Use a different version of the Sift Science API for this call. Returns: A sift.client.Response object if the track call succeeded, otherwise raises an ApiException. + """ - if not isinstance( - event, self.UNICODE_STRING) or len( - event.strip()) == 0: + if not isinstance(event, self.UNICODE_STRING) or len(event.strip()) == 0: raise ApiException("event must be a string") if not isinstance(properties, dict) or len(properties) == 0: @@ -123,10 +137,16 @@ def track( params = {} if return_score: - params.update({'return_score': return_score}) + params['return_score'] = 'true' if return_action: - params.update({'return_action': return_action}) + params['return_action'] = 'true' + + if abuse_types: + params['abuse_types'] = ','.join(abuse_types) + + if return_workflow_status: + params['return_workflow_status'] = 'true' try: response = requests.post( @@ -139,21 +159,29 @@ def track( except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def score(self, user_id, timeout=None, version=None): + + def score(self, user_id, timeout=None, abuse_types=None, version=None): """Retrieves a user's fraud score from the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/score_api.html - for more information on our Score response structure + for more information on our Score response structure. Args: user_id: A user's id. This id should be the same as the user_id used in event calls. + + timeout(optional): Use a custom timeout (in seconds) for this call. + + abuse_types(optional): List of abuse types, specifying for which abuse types a score + should be returned (if scores were requested). If not specified, a score will + be returned for every abuse_type to which you are subscribed. + + version(optional): Use a different version of the Sift Science API for this call. + Returns: A sift.client.Response object if the score call succeeded, or raises an ApiException. """ - if not isinstance( - user_id, self.UNICODE_STRING) or len( - user_id.strip()) == 0: + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: raise ApiException("user_id must be a string") if timeout is None: @@ -164,6 +192,8 @@ def score(self, user_id, timeout=None, version=None): headers = {'User-Agent': self._user_agent()} params = {'api_key': self.api_key} + if abuse_types: + params['abuse_types'] = ','.join(abuse_types) try: response = requests.get( @@ -175,6 +205,7 @@ def score(self, user_id, timeout=None, version=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) + def label(self, user_id, properties, timeout=None, version=None): """Labels a user as either good or bad through the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html @@ -183,15 +214,18 @@ def label(self, user_id, properties, timeout=None, version=None): Args: user_id: A user's id. This id should be the same as the user_id used in event calls. - properties: A dict of additional event-specific attributes to track - timeout(optional): specify a custom timeout for this call + + properties: A dict of additional event-specific attributes to track. + + timeout(optional): Use a custom timeout (in seconds) for this call. + + version(optional): Use a different version of the Sift Science API for this call. + Returns: A sift.client.Response object if the label call succeeded, otherwise raises an ApiException. """ - if not isinstance( - user_id, self.UNICODE_STRING) or len( - user_id.strip()) == 0: + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: raise ApiException("user_id must be a string") if version is None: @@ -204,7 +238,8 @@ def label(self, user_id, properties, timeout=None, version=None): timeout=timeout, version=version) - def unlabel(self, user_id, timeout=None, version=None): + + def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): """unlabels a user through the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html for more information. @@ -212,14 +247,19 @@ def unlabel(self, user_id, timeout=None, version=None): Args: user_id: A user's id. This id should be the same as the user_id used in event calls. - timeout(optional): specify a custom timeout for this call + + timeout(optional): Use a custom timeout (in seconds) for this call. + + abuse_type(optional): The abuse type for which the user should be unlabeled. + If omitted, the user is unlabeled for all abuse types. + + version(optional): Use a different version of the Sift Science API for this call. + Returns: A sift.client.Response object if the unlabel call succeeded, otherwise raises an ApiException. """ - if not isinstance( - user_id, self.UNICODE_STRING) or len( - user_id.strip()) == 0: + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: raise ApiException("user_id must be a string") if timeout is None: @@ -230,6 +270,8 @@ def unlabel(self, user_id, timeout=None, version=None): headers = {'User-Agent': self._user_agent()} params = {'api_key': self.api_key} + if abuse_type: + params['abuse_type'] = abuse_type try: @@ -244,6 +286,112 @@ def unlabel(self, user_id, timeout=None, version=None): raise ApiException(str(e)) + def get_workflow_status(self, run_id, timeout=None): + """Gets the status of a workflow run. + + Args: + run_id: The ID of a workflow run. + + Returns: + A sift.client.Response object if the call succeeded. + Otherwise, raises an ApiException. + + """ + if not isinstance(run_id, self.UNICODE_STRING) or len(run_id.strip()) == 0: + raise ApiException("run_id must be a string") + + if timeout is None: + timeout = self.timeout + + try: + return Response(requests.get( + self._workflow_status_url(self.account_id, run_id), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + + def get_user_decisions(self, user_id, timeout=None): + """Gets the decisions for a user. + + Args: + user_id: The ID of a user. + + Returns: + A sift.client.Response object if the call succeeded. + Otherwise, raises an ApiException. + + """ + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + + if timeout is None: + timeout = self.timeout + + try: + return Response(requests.get( + self._user_decisions_url(self.account_id, user_id), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + + def get_order_decisions(self, order_id, timeout=None): + """Gets the decisions for an order. + + Args: + order_id: The ID of an order. + + Returns: + A sift.client.Response object if the call succeeded. + Otherwise, raises an ApiException. + + """ + if not isinstance(order_id, self.UNICODE_STRING) or len(order_id.strip()) == 0: + raise ApiException("order_id must be a string") + + if timeout is None: + timeout = self.timeout + + try: + return Response(requests.get( + self._order_decisions_url(self.account_id, order_id), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + + def _user_agent(self): + return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) + + def _event_url(self, version): + return self.url + '/v%s/events' % version + + def _score_url(self, user_id, version): + return self.url + '/v%s/score/%s' % (version, urllib.quote(user_id)) + + def _label_url(self, user_id, version): + return self.url + '/v%s/users/%s/labels' % (version, urllib.quote(user_id)) + + def _workflow_status_url(self, account_id, run_id): + return API3_URL + '/v3/accounts/%s/workflows/runs/%s' % (account_id, run_id) + + def _user_decisions_url(self, account_id, user_id): + return API3_URL + '/v3/accounts/%s/users/%s/decisions' % (account_id, user_id) + + def _order_decisions_url(self, account_id, order_id): + return API3_URL + '/v3/accounts/%s/orders/%s/decisions' % (account_id, order_id) + + class Response(object): HTTP_CODES_WITHOUT_BODY = [204, 304] @@ -261,17 +409,15 @@ def __init__(self, http_response): self.http_status_code = http_response.status_code self.url = http_response.url - if (self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY) \ - and http_response.text: + if (self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY) and http_response.text: try: self.body = http_response.json() - self.api_status = self.body['status'] - self.api_error_message = self.body['error_message'] - if 'request' in self.body.keys() \ - and isinstance(self.body['request'], str): + if 'status' in self.body: + self.api_status = self.body['status'] + if 'error_message' in self.body: + self.api_error_message = self.body['error_message'] + if 'request' in self.body.keys() and isinstance(self.body['request'], str): self.request = json.loads(self.body['request']) - else: - self.request = None except ValueError: not_json_warning = "Failed to parse json response from {}. HTTP status code: {}.".format(self.url, self.http_status_code) raise ApiException(not_json_warning) @@ -292,7 +438,11 @@ def is_ok(self): if self.http_status_code in self.HTTP_CODES_WITHOUT_BODY: return 204 == self.http_status_code - return self.api_status == 0 + # NOTE: Responses from /v3/... endpoints do not contain an API status. + if self.api_status: + return self.api_status == 0 + + return self.http_status_code == 200 class ApiException(Exception): diff --git a/sift/version.py b/sift/version.py index d638224..9f89317 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ VERSION = '2.0.1.0' -API_VERSION = '203' +API_VERSION = '204' diff --git a/tests/test_client.py b/tests/test_client.py index 512921e..80e378c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -34,9 +34,11 @@ def valid_transaction_properties(): def valid_label_properties(): return { - '$description': 'Listed a fake item', + '$abuse_type': 'content_abuse', '$is_bad': True, - '$reasons': ["$fake"], + '$description': 'Listed a fake item', + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' } @@ -45,7 +47,29 @@ def score_response_json(): "status": 0, "error_message": "OK", "user_id": "12345", - "score": 0.55 + "score": 0.85, + "latest_label": { + "is_bad": true, + "time": 1450201660000 + }, + "scores": { + "content_abuse": { + "score": 0.14 + }, + "payment_abuse": { + "score": 0.97 + } + }, + "latest_labels": { + "promotion_abuse": { + "is_bad": false, + "time": 1457201099000 + }, + "payment_abuse": { + "is_bad": true, + "time": 1457212345000 + } + } }""" @@ -71,16 +95,33 @@ def action_response_json(): ] } ], - "score": 0.55, + "score": 0.85, "status": 0, "error_message": "OK", - "user_id": "Fred" - }""" + "user_id": "Fred", + "scores": { + "content_abuse": { + "score": 0.14 + }, + "payment_abuse": { + "score": 0.97 + } + }, + "latest_labels": { + "promotion_abuse": { + "is_bad": false, + "time": 1457201099000 + }, + "payment_abuse": { + "is_bad": true, + "time": 1457212345000 + } + } + }""" def response_with_data_header(): return { - 'content-length': 1, # Simply has to be > 0 'content-type': 'application/json; charset=UTF-8' } @@ -89,7 +130,8 @@ class TestSiftPythonClient(unittest.TestCase): def setUp(self): self.test_key = 'a_fake_test_api_key' - self.sift_client = sift.Client(self.test_key) + self.account_id = 'ACCT' + self.sift_client = sift.Client(api_key=self.test_key, account_id=self.account_id) def test_global_api_key(self): # test for error if global key is undefined @@ -146,10 +188,9 @@ def test_event_ok(self): mock_response.headers = response_with_data_header() with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track( - event, valid_transaction_properties()) + response = self.sift_client.track(event, valid_transaction_properties()) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + 'https://api.siftscience.com/v204/events', data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, @@ -167,13 +208,12 @@ def test_event_with_timeout_param_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( event, valid_transaction_properties(), timeout=test_timeout) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + 'https://api.siftscience.com/v204/events', data=mock.ANY, headers=mock.ANY, timeout=test_timeout, @@ -193,15 +233,16 @@ def test_score_ok(self): mock_post.return_value = mock_response response = self.sift_client.score('12345') mock_post.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', - params={ - 'api_key': self.test_key}, + 'https://api.siftscience.com/v204/score/12345', + params={'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert(response.body['score'] == 0.85) + assert(response.body['scores']['content_abuse']['score'] == 0.14) + assert(response.body['scores']['payment_abuse']['score'] == 0.97) def test_score_with_timeout_param_ok(self): test_timeout = 5 @@ -214,40 +255,45 @@ def test_score_with_timeout_param_ok(self): mock_post.return_value = mock_response response = self.sift_client.score('12345', test_timeout) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', - params={ - 'api_key': self.test_key}, + 'https://api.siftscience.com/v204/score/12345', + params={'api_key': self.test_key}, headers=mock.ANY, timeout=test_timeout) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert(response.body['score'] == 0.85) + assert(response.body['scores']['content_abuse']['score'] == 0.14) + assert(response.body['scores']['payment_abuse']['score'] == 0.97) def test_sync_score_ok(self): event = '$transaction' mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK", "score_response": %s}' % score_response_json( - ) + mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' + % score_response_json()) mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( - event, valid_transaction_properties(), return_score=True) + event, + valid_transaction_properties(), + return_score=True, + abuse_types=['payment_abuse', 'content_abuse', 'legacy']) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + 'https://api.siftscience.com/v204/events', data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={ - 'return_score': True}) + params={'return_score': 'true', 'abuse_types': 'payment_abuse,content_abuse,legacy'}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") - assert(response.body["score_response"]['score'] == 0.55) + assert(response.body['score_response']['score'] == 0.85) + assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) + assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) def test_label_user_ok(self): user_id = '54321' @@ -258,17 +304,19 @@ def test_label_user_ok(self): mock_response.headers = response_with_data_header() with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.label( - user_id, valid_label_properties()) + response = self.sift_client.label(user_id, valid_label_properties()) properties = { - '$description': 'Listed a fake item', + '$abuse_type': 'content_abuse', '$is_bad': True, - '$reasons': ["$fake"]} + '$description': 'Listed a fake item', + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' + } properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % - user_id, data=data, headers=mock.ANY, timeout=mock.ANY, params={}) + 'https://api.siftscience.com/v204/users/%s/labels' % user_id, + data=data, headers=mock.ANY, timeout=mock.ANY, params={}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) @@ -287,31 +335,34 @@ def test_label_user_with_timeout_param_ok(self): response = self.sift_client.label( user_id, valid_label_properties(), test_timeout) properties = { - '$description': 'Listed a fake item', + '$abuse_type': 'content_abuse', '$is_bad': True, - '$reasons': ["$fake"]} + '$description': 'Listed a fake item', + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' + } properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % - user_id, data=data, headers=mock.ANY, timeout=test_timeout, params={}) + 'https://api.siftscience.com/v204/users/%s/labels' % user_id, + data=data, headers=mock.ANY, timeout=test_timeout, params={}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") def test_unlabel_user_ok(self): - user_id = '54321' mock_response = mock.Mock() mock_response.status_code = 204 with mock.patch('requests.delete') as mock_delete: mock_delete.return_value = mock_response - response = self.sift_client.unlabel(user_id) + response = self.sift_client.unlabel(user_id, abuse_type='account_abuse') mock_delete.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % - user_id, headers=mock.ANY, timeout=mock.ANY, params={ - 'api_key': self.test_key}) + 'https://api.siftscience.com/v204/users/%s/labels' % user_id, + headers=mock.ANY, + timeout=mock.ANY, + params={'api_key': self.test_key, 'abuse_type': 'account_abuse'}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) @@ -329,20 +380,18 @@ def test_unicode_string_parameter_support(self): with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response - assert( - self.sift_client.track( - u'$transaction', - valid_transaction_properties())) - assert( - self.sift_client.label( - user_id, - valid_label_properties())) + assert(self.sift_client.track( + u'$transaction', + valid_transaction_properties())) + assert(self.sift_client.label( + user_id, + valid_label_properties())) with mock.patch('requests.get') as mock_post: mock_post.return_value = mock_response - assert(self.sift_client.score(user_id)) + assert(self.sift_client.score( + user_id, abuse_types=[u'payment_abuse', 'content_abuse'])) def test_unlabel_user_with_special_chars_ok(self): - user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.status_code = 204 @@ -350,9 +399,10 @@ def test_unlabel_user_with_special_chars_ok(self): mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id) mock_delete.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % - urllib.quote(user_id), headers=mock.ANY, timeout=mock.ANY, params={ - 'api_key': self.test_key}) + 'https://api.siftscience.com/v204/users/%s/labels' % urllib.quote(user_id), + headers=mock.ANY, + timeout=mock.ANY, + params={'api_key': self.test_key}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) @@ -368,14 +418,16 @@ def test_label_user__with_special_chars_ok(self): response = self.sift_client.label( user_id, valid_label_properties()) properties = { - '$description': 'Listed a fake item', + '$abuse_type': 'content_abuse', '$is_bad': True, - '$reasons': ["$fake"]} + '$description': 'Listed a fake item', + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' + } properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % - urllib.quote(user_id), + 'https://api.siftscience.com/v204/users/%s/labels' % urllib.quote(user_id), data=data, headers=mock.ANY, timeout=mock.ANY, @@ -394,18 +446,18 @@ def test_score__with_special_user_id_chars_ok(self): mock_response.headers = response_with_data_header() with mock.patch('requests.get') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.score(user_id) + response = self.sift_client.score(user_id, abuse_types=['legacy']) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/score/%s' % - urllib.quote(user_id), - params={ - 'api_key': self.test_key}, + 'https://api.siftscience.com/v204/score/%s' % urllib.quote(user_id), + params={'api_key': self.test_key, 'abuse_types': 'legacy'}, headers=mock.ANY, timeout=mock.ANY) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert(response.body['score'] == 0.85) + assert(response.body['scores']['content_abuse']['score'] == 0.14) + assert(response.body['scores']['payment_abuse']['score'] == 0.97) def test_exception_during_track_call(self): warnings.simplefilter("always") @@ -441,8 +493,8 @@ def test_exception_during_unlabel_call(self): def test_return_actions_on_track(self): event = '$transaction' mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK", "score_response": %s}' % action_response_json( - ) + mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' + % action_response_json()) mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -453,12 +505,11 @@ def test_return_actions_on_track(self): response = self.sift_client.track( event, valid_transaction_properties(), return_action=True) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + 'https://api.siftscience.com/v204/events', data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={ - 'return_action': True}) + params={'return_action': 'true'}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) @@ -471,6 +522,64 @@ def test_return_actions_on_track(self): assert(actions[0]['action']['id'] == 'freds_action') assert(actions[0]['triggers']) + def test_get_workflow_status(self): + mock_response = mock.Mock() + mock_response.content = '{"id":"4zxwibludiaaa","config":{"id":"5rrbr4iaaa","version":"1468367620871"},"config_display_name":"workflow config","abuse_types":["payment_abuse"],"state":"running","entity":{"id":"example_user","type":"user"},"history":[{"app":"decision","name":"decision","state":"running","config":{"decision_id":"user_decision"}},{"app":"event","name":"Event","state":"finished","config":{}},{"app":"user","name":"Entity","state":"finished","config":{}}]}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_workflow_status('4zxwibludiaaa', timeout=3) + mock_get.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa', + headers=mock.ANY, auth=mock.ANY, timeout=3) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.body['state'] == 'running') + + def test_get_user_decisions(self): + mock_response = mock.Mock() + mock_response.content = '{"decisions":{"payment_abuse":{"decision":{"id":"user_decision"},"time":1468707128659,"webhook_succeeded":false}}}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_user_decisions('example_user') + mock_get.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/decisions', + headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') + + def test_get_order_decisions(self): + mock_response = mock.Mock() + mock_response.content = '{"decisions":{"payment_abuse":{"decision":{"id":"decision7"},"time":1468599638005,"webhook_succeeded":false},"promotion_abuse":{"decision":{"id":"good_order"},"time":1468517407135,"webhook_succeeded":true}}}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_order_decisions('example_order') + mock_get.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/orders/example_order/decisions', + headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') + assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') + def main(): unittest.main() diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py new file mode 100644 index 0000000..be7205b --- /dev/null +++ b/tests/test_client_v203.py @@ -0,0 +1,451 @@ +import datetime +import warnings +import json +import mock +import sift +import unittest +import sys +import requests.exceptions +if sys.version_info[0] < 3: + import urllib +else: + import urllib.parse as urllib + + +def valid_transaction_properties(): + return { + '$buyer_user_id': '123456', + '$seller_user_id': '654321', + '$amount': 1253200, + '$currency_code': 'USD', + '$time': int(datetime.datetime.now().strftime('%s')), + '$transaction_id': 'my_transaction_id', + '$billing_name': 'Mike Snow', + '$billing_bin': '411111', + '$billing_last4': '1111', + '$billing_address1': '123 Main St.', + '$billing_city': 'San Francisco', + '$billing_region': 'CA', + '$billing_country': 'US', + '$billing_zip': '94131', + '$user_email': 'mike@example.com' + } + + +def valid_label_properties(): + return { + '$description': 'Listed a fake item', + '$is_bad': True, + '$reasons': ["$fake"], + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' + } + + +def score_response_json(): + return """{ + "status": 0, + "error_message": "OK", + "user_id": "12345", + "score": 0.55 + }""" + + +def action_response_json(): + return """{ + "actions": [ + { + "action": { + "id": "freds_action" + }, + "entity": { + "id": "Fred" + }, + "id": "ACTION1234567890:freds_action", + "triggers": [ + { + "source": "synchronous_action", + "trigger": { + "id": "TRIGGER1234567890" + }, + "type": "formula" + } + ] + } + ], + "score": 0.55, + "status": 0, + "error_message": "OK", + "user_id": "Fred" + }""" + + +def response_with_data_header(): + return { + 'content-length': 1, # Simply has to be > 0 + 'content-type': 'application/json; charset=UTF-8' + } + + +class TestSiftPythonClient(unittest.TestCase): + + def setUp(self): + self.test_key = 'a_fake_test_api_key' + self.sift_client = sift.Client(self.test_key, version='203') + self.sift_client_v204 = sift.Client(self.test_key) + + def test_track_requires_valid_event(self): + self.assertRaises(sift.client.ApiException, self.sift_client.track, None, {}) + self.assertRaises(sift.client.ApiException, self.sift_client.track, '', {}) + self.assertRaises(sift.client.ApiException, self.sift_client_v204.track, 42, {'version':'203'}) + + def test_track_requires_properties(self): + event = 'custom_event' + self.assertRaises(sift.client.ApiException, self.sift_client.track, event, None, {}) + self.assertRaises(sift.client.ApiException, self.sift_client_v204.track, event, 42, {'version':'203'}) + self.assertRaises(sift.client.ApiException, self.sift_client.track, event, {}) + + def test_score_requires_user_id(self): + self.assertRaises(sift.client.ApiException, self.sift_client_v204.score, None, {'version':'203'}) + self.assertRaises(sift.client.ApiException, self.sift_client.score, '', {}) + self.assertRaises(sift.client.ApiException, self.sift_client.score, 42, {}) + + def test_event_ok(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track(event, valid_transaction_properties()) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_event_with_timeout_param_ok(self): + event = '$transaction' + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client_v204.track( + event, valid_transaction_properties(), timeout=test_timeout, version='203') + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/events', + data=mock.ANY, + headers=mock.ANY, + timeout=test_timeout, + params={}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_score_ok(self): + mock_response = mock.Mock() + mock_response.content = score_response_json() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.get') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client_v204.score('12345', version='203') + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/score/12345', + params={'api_key': self.test_key}, + headers=mock.ANY, + timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_error_message == "OK") + assert(response.body['score'] == 0.55) + + def test_score_with_timeout_param_ok(self): + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = score_response_json() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.get') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.score('12345', test_timeout) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/score/12345', + params={'api_key': self.test_key}, + headers=mock.ANY, + timeout=test_timeout) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_error_message == "OK") + assert(response.body['score'] == 0.55) + + def test_sync_score_ok(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' + % score_response_json()) + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track( + event, valid_transaction_properties(), return_score=True) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={'return_score': 'true'}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + assert(response.body["score_response"]['score'] == 0.55) + + def test_label_user_ok(self): + user_id = '54321' + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.label(user_id, valid_label_properties()) + properties = { + '$description': 'Listed a fake item', + '$is_bad': True, + '$reasons': ["$fake"], + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' + } + properties.update({'$api_key': self.test_key, '$type': '$label'}) + data = json.dumps(properties) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/users/%s/labels' % user_id, + data=data, headers=mock.ANY, timeout=mock.ANY, params={}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_label_user_with_timeout_param_ok(self): + user_id = '54321' + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client_v204.label( + user_id, valid_label_properties(), test_timeout, version='203') + properties = { + '$description': 'Listed a fake item', + '$is_bad': True, + '$reasons': ["$fake"], + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' + } + properties.update({'$api_key': self.test_key, '$type': '$label'}) + data = json.dumps(properties) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/users/%s/labels' % user_id, + data=data, headers=mock.ANY, timeout=test_timeout, params={}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_unlabel_user_ok(self): + user_id = '54321' + mock_response = mock.Mock() + mock_response.status_code = 204 + with mock.patch('requests.delete') as mock_delete: + mock_delete.return_value = mock_response + response = self.sift_client.unlabel(user_id) + mock_delete.assert_called_with( + 'https://api.siftscience.com/v203/users/%s/labels' % user_id, + headers=mock.ANY, + timeout=mock.ANY, + params={'api_key': self.test_key}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + + def test_unicode_string_parameter_support(self): + # str is unicode in python 3, so no need to check as this was covered + # by other unit tests. + if sys.version_info[0] < 3: + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + user_id = u'23056' + + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + assert( + self.sift_client.track( + u'$transaction', + valid_transaction_properties())) + assert( + self.sift_client.label( + user_id, + valid_label_properties())) + with mock.patch('requests.get') as mock_post: + mock_post.return_value = mock_response + assert(self.sift_client.score(user_id)) + + def test_unlabel_user_with_special_chars_ok(self): + user_id = "54321=.-_+@:&^%!$" + mock_response = mock.Mock() + mock_response.status_code = 204 + with mock.patch('requests.delete') as mock_delete: + mock_delete.return_value = mock_response + response = self.sift_client_v204.unlabel(user_id, version='203') + mock_delete.assert_called_with( + 'https://api.siftscience.com/v203/users/%s/labels' % urllib.quote(user_id), + headers=mock.ANY, + timeout=mock.ANY, + params={'api_key': self.test_key}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + + def test_label_user__with_special_chars_ok(self): + user_id = '54321=.-_+@:&^%!$' + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.label( + user_id, valid_label_properties()) + properties = { + '$description': 'Listed a fake item', + '$is_bad': True, + '$reasons': ["$fake"], + '$source': 'Internal Review Queue', + '$analyst': 'super.sleuth@example.com' + } + properties.update({'$api_key': self.test_key, '$type': '$label'}) + data = json.dumps(properties) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/users/%s/labels' % urllib.quote(user_id), + data=data, + headers=mock.ANY, + timeout=mock.ANY, + params={}) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_score__with_special_user_id_chars_ok(self): + user_id = '54321=.-_+@:&^%!$' + mock_response = mock.Mock() + mock_response.content = score_response_json() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.get') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.score(user_id) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/score/%s' % urllib.quote(user_id), + params={'api_key': self.test_key}, + headers=mock.ANY, + timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_error_message == "OK") + assert(response.body['score'] == 0.55) + + def test_exception_during_track_call(self): + warnings.simplefilter("always") + with mock.patch('requests.post') as mock_post: + mock_post.side_effect = mock.Mock( + side_effect=requests.exceptions.RequestException("Failed")) + try: + response = self.sift_client.track( + '$transaction', valid_transaction_properties()) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_exception_during_score_call(self): + warnings.simplefilter("always") + with mock.patch('requests.get') as mock_get: + mock_get.side_effect = mock.Mock( + side_effect=requests.exceptions.RequestException("Failed")) + try: + response = self.sift_client.score('Fred') + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_exception_during_unlabel_call(self): + warnings.simplefilter("always") + with mock.patch('requests.delete') as mock_delete: + mock_delete.side_effect = mock.Mock( + side_effect=requests.exceptions.RequestException("Failed")) + try: + response = self.sift_client.unlabel('Fred') + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_return_actions_on_track(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' + % action_response_json()) + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.track( + event, valid_transaction_properties(), return_action=True) + mock_post.assert_called_with( + 'https://api.siftscience.com/v203/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={'return_action': 'true'}) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + actions = response.body["score_response"]['actions'] + assert(actions) + assert(actions[0]['action']) + assert(actions[0]['action']['id'] == 'freds_action') + assert(actions[0]['triggers']) + + +def main(): + unittest.main() + +if __name__ == '__main__': + main() From 15b044b0810dfcc5eeabce6efd64a586b2104d3c Mon Sep 17 00:00:00 2001 From: Jacob Burnim Date: Fri, 8 Jul 2016 16:00:26 -0700 Subject: [PATCH 012/112] Bumps version to 3.0.0.0. This is necessary, as sift.Client methods now call version 204 API by default. Users can continue to call version 203 by explicitly specifying a version to sift.Client. --- LICENSE | 2 +- sift/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index f389fa0..dea6a27 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2013-2014 Sift Science (https://siftscience.com) +Copyright (c) 2013-2016 Sift Science (https://siftscience.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/sift/version.py b/sift/version.py index 9f89317..d858aef 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '2.0.1.0' +VERSION = '3.0.0.0' API_VERSION = '204' From 74367fa802a6ba372e53daf5f683c7cb45f57e16 Mon Sep 17 00:00:00 2001 From: Jacob Burnim Date: Mon, 18 Jul 2016 14:35:40 -0700 Subject: [PATCH 013/112] Updates CHANGES.md for 3.0.0.0. --- CHANGES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 61676c3..7bbf71d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,9 +1,19 @@ +3.0.0.0 2016-07-19 +================== + +- Adds support for v204 of Sift Science's APIs +- Adds Workflow Status API, User Decisions API, Order Decisions API +- V204 APIs are now called by default -- this is an incompatible change + (use version='203' to call the previous API version) + 2.0.1.0 (2016-07-07) ==================== + - Fixes bug parsing chunked HTTP responses 2.0.0.0 (2016-06-21) ==================== + - Major version bump; client APIs have changed to raise exceptions in the case of API errors to be more Pythonic From 6590f08ca005a0d9d0870b17d335cb69c6479325 Mon Sep 17 00:00:00 2001 From: Megan Mann Date: Wed, 10 Aug 2016 09:47:01 -0700 Subject: [PATCH 014/112] Update docs link in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b2ba753..abd09cc 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Python 3: ## Documentation -Please see [here](https://siftscience.com/docs/api/python) for the +Please see [here](https://siftscience.com/developers/docs/python/events-api/overview) for the most up-to-date documentation. ## Changelog From 641307e0f5dea031705f1441279d395a3c637916 Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Mon, 12 Dec 2016 13:16:17 -0800 Subject: [PATCH 015/112] add support for public 'apply decisions' api --- sift/client.py | 96 ++++++++++++++++++++++++++++++++ tests/test_client.py | 128 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 1 deletion(-) diff --git a/sift/client.py b/sift/client.py index 1737912..a6a8517 100644 --- a/sift/client.py +++ b/sift/client.py @@ -16,6 +16,7 @@ API_URL = 'https://api.siftscience.com' API3_URL = 'https://api3.siftscience.com' +DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] class Client(object): @@ -314,6 +315,99 @@ def get_workflow_status(self, run_id, timeout=None): raise ApiException(str(e)) + def apply_user_decision(self, user_id, apply_decision_request, timeout=None): + """Apply decision to user + + Args: + user_id: id of user + applyDecisionJson: + decision_id: decision to apply to user + source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} + analyst: id or email, required if `source: MANUAL_REVIEW` + time: in millis when decision was applied + Returns + A sift.client.Response object if the call succeeded, else raises an ApiException + """ + + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + + if timeout is None: + timeout = self.timeout + + if 'source' in apply_decision_request: + apply_decision_request.update({'source': apply_decision_request.get('source').upper()}) + if apply_decision_request.get('source') not in DECISION_SOURCES: + raise ApiException("decision 'source' must be one of [%s]" % ", ".join(DECISION_SOURCES)) + else: + raise ApiException("must provide decision 'source'") + + if apply_decision_request.get('source') == 'MANUAL_REVIEW' and \ + ('analyst' not in apply_decision_request or len(apply_decision_request.get('analyst')) == 0): + raise ApiException("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") + + try: + return Response(requests.post( + self._user_decisions_url(self.account_id, user_id), + data=json.dumps(apply_decision_request), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + def apply_order_decision(self, user_id, order_id, apply_decision_request, timeout=None): + """Apply decision to order + + Args: + user_id: id of user + order_id: id of order + applyDecisionJson: + decision_id: decision to apply to user + source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} + analyst: id or email, required if `source: MANUAL_REVIEW` + time: in millis when decision was applied + Returns + A sift.client.Response object if the call succeeded, else raises an ApiException + """ + + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + + if not isinstance(order_id, self.UNICODE_STRING) or len(order_id.strip()) == 0: + raise ApiException("order_id must be a string") + + if 'source' in apply_decision_request: + apply_decision_request.update({'source': apply_decision_request.get('source').upper()}) + if apply_decision_request.get('source') not in DECISION_SOURCES: + raise ApiException("decision 'source' must be one of [%s]" % ", ".join(DECISION_SOURCES)) + else: + raise ApiException("must provide decision 'source'") + + if apply_decision_request.get('source') == 'MANUAL_REVIEW' and \ + ('analyst' not in apply_decision_request or len(apply_decision_request.get('analyst')) == 0): + raise ApiException("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") + + if timeout is None: + timeout = self.timeout + + try: + return Response(requests.post( + self._order_apply_decisions_url(self.account_id, user_id, order_id), + data=json.dumps(apply_decision_request), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + def get_user_decisions(self, user_id, timeout=None): """Gets the decisions for a user. @@ -391,6 +485,8 @@ def _user_decisions_url(self, account_id, user_id): def _order_decisions_url(self, account_id, order_id): return API3_URL + '/v3/accounts/%s/orders/%s/decisions' % (account_id, order_id) + def _order_apply_decisions_url(self, account_id, user_id, order_id): + return API3_URL + '/v3/accounts/%s/users/%s/orders/%s/decisions' % (account_id, user_id, order_id) class Response(object): diff --git a/tests/test_client.py b/tests/test_client.py index 80e378c..3ca62cb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,7 +41,6 @@ def valid_label_properties(): '$analyst': 'super.sleuth@example.com' } - def score_response_json(): return """{ "status": 0, @@ -295,6 +294,133 @@ def test_sync_score_ok(self): assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) + def test_apply_decision_to_user_ok(self): + user_id = '54321' + mock_response = mock.Mock() + applyDecisionRequest = { + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'time': 1481569575 + } + applyDecisionResponseJson = '{' \ + '"time":"1481569575",' \ + '"status":0,' \ + '"request": {' \ + '"decision_id":"user_looks_ok_legacy",' \ + '"source":"MANUAL_REVIEW",' \ + '"analyst":"analyst@biz.com",' \ + '"time":"1481569575"' \ + '},' \ + '"error_message":"OK"}' + mock_response.content = applyDecisionResponseJson + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) + data = json.dumps(applyDecisionRequest) + mock_post.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_apply_decision_manual_review_no_analyst_fails(self): + user_id = '54321' + mock_response = mock.Mock() + applyDecisionRequest = { + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'time': 1481569575 + } + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + try: + response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) + data = json.dumps(applyDecisionRequest) + mock_post.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_apply_decision_no_source_fails(self): + user_id = '54321' + mock_response = mock.Mock() + applyDecisionRequest = { + 'decision_id': 'user_looks_ok_legacy', + 'time': 1481569575 + } + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + try: + response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) + data = json.dumps(applyDecisionRequest) + mock_post.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_apply_decision_invalid_source_fails(self): + user_id = '54321' + mock_response = mock.Mock() + applyDecisionRequest = { + 'decision_id': 'user_looks_ok_legacy', + 'source': 'INVALID_SOURCE', + 'time': 1481569575 + } + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + try: + response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) + data = json.dumps(applyDecisionRequest) + mock_post.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_apply_decision_to_order_ok(self): + user_id = '54321' + order_id = '43210' + mock_response = mock.Mock() + applyDecisionRequest = { + 'decision_id': 'order_looks_bad_payment_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } + + applyDecisionResponseJson = '{'\ + '"time": "1481569575",' \ + '"status": 0,' \ + '"request": {' \ + '"decision_id":"order_looks_bad_payment_abuse",' \ + '"source":"AUTOMATED_RULE",' \ + '"time":"1481569575"' \ + '},' \ + '"error_message": "OK"}' + + mock_response.content = applyDecisionResponseJson + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.apply_order_decision(user_id, order_id, applyDecisionRequest) + data = json.dumps(applyDecisionRequest) + mock_post.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id,order_id), + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + def test_label_user_ok(self): user_id = '54321' mock_response = mock.Mock() From ffbe229627945e1d49aa5e6891a6b234841c7c39 Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 3 Jan 2017 09:33:20 -0800 Subject: [PATCH 016/112] add support for decision description field, update tests --- sift/client.py | 1 + tests/test_client.py | 43 ++++++++++++++++++++++--------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/sift/client.py b/sift/client.py index a6a8517..4cd283f 100644 --- a/sift/client.py +++ b/sift/client.py @@ -369,6 +369,7 @@ def apply_order_decision(self, user_id, order_id, apply_decision_request, timeou decision_id: decision to apply to user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if `source: MANUAL_REVIEW` + description: note or description of decision applied or reasons (optional) time: in millis when decision was applied Returns A sift.client.Response object if the call succeeded, else raises an ApiException diff --git a/tests/test_client.py b/tests/test_client.py index 3ca62cb..0959036 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -301,18 +301,18 @@ def test_apply_decision_to_user_ok(self): 'decision_id': 'user_looks_ok_legacy', 'source': 'MANUAL_REVIEW', 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', 'time': 1481569575 } applyDecisionResponseJson = '{' \ - '"time":"1481569575",' \ - '"status":0,' \ - '"request": {' \ - '"decision_id":"user_looks_ok_legacy",' \ - '"source":"MANUAL_REVIEW",' \ - '"analyst":"analyst@biz.com",' \ - '"time":"1481569575"' \ + '"entity": {' \ + '"id": "54321",' \ + '"type": "user"' \ '},' \ - '"error_message":"OK"}' + '"decision": {' \ + '"id":"user_looks_ok_legacy"' \ + '},' \ + '"time":"1481569575"}' mock_response.content = applyDecisionResponseJson mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 @@ -324,10 +324,11 @@ def test_apply_decision_to_user_ok(self): mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) + assert(response.body['entity']['type'] == 'user') + assert(response.http_status_code == 200) assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") def test_apply_decision_manual_review_no_analyst_fails(self): user_id = '54321' @@ -395,15 +396,15 @@ def test_apply_decision_to_order_ok(self): 'time': 1481569575 } - applyDecisionResponseJson = '{'\ - '"time": "1481569575",' \ - '"status": 0,' \ - '"request": {' \ - '"decision_id":"order_looks_bad_payment_abuse",' \ - '"source":"AUTOMATED_RULE",' \ - '"time":"1481569575"' \ - '},' \ - '"error_message": "OK"}' + applyDecisionResponseJson = '{' \ + '"entity": {' \ + '"id": "54321",' \ + '"type": "order"' \ + '},' \ + '"decision": {' \ + '"id":"order_looks_bad_payment_abuse"' \ + '},' \ + '"time":"1481569575"}' mock_response.content = applyDecisionResponseJson mock_response.json.return_value = json.loads(mock_response.content) @@ -418,8 +419,8 @@ def test_apply_decision_to_order_ok(self): auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert(response.http_status_code == 200) + assert(response.body['entity']['type'] == 'order') def test_label_user_ok(self): user_id = '54321' From b5de6519507b086ada2241d1143de06b43a477c9 Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 3 Jan 2017 10:33:33 -0800 Subject: [PATCH 017/112] add support for get decisions api --- sift/client.py | 39 +++++++++++++++++++++++++++++++++++++ tests/test_client.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/sift/client.py b/sift/client.py index 4cd283f..c0018e5 100644 --- a/sift/client.py +++ b/sift/client.py @@ -314,6 +314,42 @@ def get_workflow_status(self, run_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) + def get_decisions(self, entity_type, limit, start_from, abuse_types, timeout=None): + """Get decisions available to customer + + Args: + entity_type: only return decisions applicable to entity type {USER|ORDER} + limit: number of query results (decisions) to return [default: 100] + start_from: result set offset for use in pagination [default: 0] + abuse_types: csv of abuse_types to filter returned decisions by (optional) + + Returns: + A sift.client.Response object containing array of decisions if call succeeded + Otherwise raises an exception + """ + + if timeout is None: + timeout = self.timeout + + params = {} + + if entity_type: + params['entity_type'] = entity_type + if limit: + params['limit'] = limit + if start_from: + params['from'] = start_from + if abuse_types: + params['abuse_types'] = abuse_types + + try: + get = requests.get(self._get_decisions_url(self.account_id), params=params, + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, timeout=timeout) + return Response(get) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) def apply_user_decision(self, user_id, apply_decision_request, timeout=None): """Apply decision to user @@ -480,6 +516,9 @@ def _label_url(self, user_id, version): def _workflow_status_url(self, account_id, run_id): return API3_URL + '/v3/accounts/%s/workflows/runs/%s' % (account_id, run_id) + def _get_decisions_url(self, account_id): + return API3_URL + '/v3/accounts/%s/decisions' % (account_id) + def _user_decisions_url(self, account_id, user_id): return API3_URL + '/v3/accounts/%s/users/%s/decisions' % (account_id, user_id) diff --git a/tests/test_client.py b/tests/test_client.py index 0959036..31f624d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -294,6 +294,52 @@ def test_sync_score_ok(self): assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) + def test_get_decisions(self): + mock_response = mock.Mock() + getDecisionsResponseJson = \ + '{' \ + '"data": [' \ + '{' \ + '"id": "block_user",' \ + '"name" : "Block user",' \ + '"description": "user has a different billing and shipping addresses",' \ + '"entity_type": "user",' \ + '"abuse_type": "legacy",' \ + '"category": "block",' \ + '"webhook_url": "http://web.hook",' \ + '"created_at": "1468005577348",' \ + '"created_by": "admin@biz.com",' \ + '"updated_at": "1469229177756",' \ + '"updated_by": "analyst@biz.com"' \ + '}' \ + '],' \ + '"has_more": "true",' \ + '"next_ref": "v3/accounts/accountId/decisions"' \ + '}' + + mock_response.content = getDecisionsResponseJson + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_decisions(entity_type="user", + limit=10, + start_from=None, + abuse_types="legacy,payment_abuse", + timeout=3) + mock_get.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', + headers=mock.ANY, + auth=mock.ANY, + params={'entity_type':'user','limit':10,'abuse_types':'legacy,payment_abuse'}, + timeout=3) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.body['data'][0]['id'] == 'block_user') + def test_apply_decision_to_user_ok(self): user_id = '54321' mock_response = mock.Mock() From f192a784827c7fdda16f9f04b538270376bd6d19 Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 3 Jan 2017 15:03:02 -0800 Subject: [PATCH 018/112] refactor get decisions --- sift/client.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sift/client.py b/sift/client.py index c0018e5..6d3f7f5 100644 --- a/sift/client.py +++ b/sift/client.py @@ -343,10 +343,9 @@ def get_decisions(self, entity_type, limit, start_from, abuse_types, timeout=Non params['abuse_types'] = abuse_types try: - get = requests.get(self._get_decisions_url(self.account_id), params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, timeout=timeout) - return Response(get) + return Response(requests.get(self._get_decisions_url(self.account_id), params=params, + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: raise ApiException(str(e)) From e1dde2de117ca809489fbf2d0082e246ac6513bc Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 17 Jan 2017 08:32:41 -0800 Subject: [PATCH 019/112] address pr comments --- sift/client.py | 80 ++++++++++++++++++++++---------------------- tests/test_client.py | 6 ++-- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/sift/client.py b/sift/client.py index 6d3f7f5..5183155 100644 --- a/sift/client.py +++ b/sift/client.py @@ -314,7 +314,7 @@ def get_workflow_status(self, run_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def get_decisions(self, entity_type, limit, start_from, abuse_types, timeout=None): + def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=None, timeout=None): """Get decisions available to customer Args: @@ -325,7 +325,7 @@ def get_decisions(self, entity_type, limit, start_from, abuse_types, timeout=Non Returns: A sift.client.Response object containing array of decisions if call succeeded - Otherwise raises an exception + Otherwise raises an ApiException """ if timeout is None: @@ -333,12 +333,18 @@ def get_decisions(self, entity_type, limit, start_from, abuse_types, timeout=Non params = {} - if entity_type: - params['entity_type'] = entity_type + if not isinstance(entity_type, self.UNICODE_STRING) or len(entity_type.strip()) == 0\ + or entity_type not in {'user', 'order'}: + raise ApiException("entity_type must be one of {user, order}") + + params['entity_type'] = entity_type + if limit: params['limit'] = limit + if start_from: params['from'] = start_from + if abuse_types: params['abuse_types'] = abuse_types @@ -350,12 +356,12 @@ def get_decisions(self, entity_type, limit, start_from, abuse_types, timeout=Non except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def apply_user_decision(self, user_id, apply_decision_request, timeout=None): + def apply_user_decision(self, user_id, properties, timeout=None): """Apply decision to user Args: user_id: id of user - applyDecisionJson: + apply_decision_request: decision_id: decision to apply to user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if `source: MANUAL_REVIEW` @@ -364,27 +370,15 @@ def apply_user_decision(self, user_id, apply_decision_request, timeout=None): A sift.client.Response object if the call succeeded, else raises an ApiException """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") - if timeout is None: timeout = self.timeout - if 'source' in apply_decision_request: - apply_decision_request.update({'source': apply_decision_request.get('source').upper()}) - if apply_decision_request.get('source') not in DECISION_SOURCES: - raise ApiException("decision 'source' must be one of [%s]" % ", ".join(DECISION_SOURCES)) - else: - raise ApiException("must provide decision 'source'") - - if apply_decision_request.get('source') == 'MANUAL_REVIEW' and \ - ('analyst' not in apply_decision_request or len(apply_decision_request.get('analyst')) == 0): - raise ApiException("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") + self.validate_apply_decision_request(properties, user_id, None) try: return Response(requests.post( self._user_decisions_url(self.account_id, user_id), - data=json.dumps(apply_decision_request), + data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'Content-type': 'application/json', 'Accept': '*/*', @@ -394,7 +388,7 @@ def apply_user_decision(self, user_id, apply_decision_request, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def apply_order_decision(self, user_id, order_id, apply_decision_request, timeout=None): + def apply_order_decision(self, user_id, order_id, properties, timeout=None): """Apply decision to order Args: @@ -403,37 +397,22 @@ def apply_order_decision(self, user_id, order_id, apply_decision_request, timeou applyDecisionJson: decision_id: decision to apply to user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} - analyst: id or email, required if `source: MANUAL_REVIEW` + analyst: id or email, required if 'source: MANUAL_REVIEW' description: note or description of decision applied or reasons (optional) time: in millis when decision was applied Returns A sift.client.Response object if the call succeeded, else raises an ApiException """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") - - if not isinstance(order_id, self.UNICODE_STRING) or len(order_id.strip()) == 0: - raise ApiException("order_id must be a string") - - if 'source' in apply_decision_request: - apply_decision_request.update({'source': apply_decision_request.get('source').upper()}) - if apply_decision_request.get('source') not in DECISION_SOURCES: - raise ApiException("decision 'source' must be one of [%s]" % ", ".join(DECISION_SOURCES)) - else: - raise ApiException("must provide decision 'source'") - - if apply_decision_request.get('source') == 'MANUAL_REVIEW' and \ - ('analyst' not in apply_decision_request or len(apply_decision_request.get('analyst')) == 0): - raise ApiException("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") - if timeout is None: timeout = self.timeout + self.validate_apply_decision_request(properties, user_id, order_id) + try: return Response(requests.post( self._order_apply_decisions_url(self.account_id, user_id, order_id), - data=json.dumps(apply_decision_request), + data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'Content-type': 'application/json', 'Accept': '*/*', @@ -443,6 +422,27 @@ def apply_order_decision(self, user_id, order_id, apply_decision_request, timeou except requests.exceptions.RequestException as e: raise ApiException(str(e)) + def validate_apply_decision_request(self, properties, user_id, order_id): + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + + if not order_id is None and (not isinstance(order_id, self.UNICODE_STRING) or len(order_id.strip()) == 0): + raise ApiException("order_id must be a string") + + if not isinstance(properties, dict) or len(properties) == 0: + raise ApiException("properties dictionary may not be empty") + + source = properties.get('source') + + if not isinstance(source, self.UNICODE_STRING) or len(source.strip()) == 0 or source not in DECISION_SOURCES: + raise ApiException("decision 'source' must be one of [%s]" % ", ".join(DECISION_SOURCES)) + + properties.update({'source': source.upper()}) + + if source == 'MANUAL_REVIEW' and \ + ('analyst' not in properties or len(properties.get('analyst')) == 0): + raise ApiException("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") + def get_user_decisions(self, user_id, timeout=None): """Gets the decisions for a user. diff --git a/tests/test_client.py b/tests/test_client.py index 31f624d..5cf848c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -343,7 +343,7 @@ def test_get_decisions(self): def test_apply_decision_to_user_ok(self): user_id = '54321' mock_response = mock.Mock() - applyDecisionRequest = { + apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', 'source': 'MANUAL_REVIEW', 'analyst': 'analyst@biz.com', @@ -365,8 +365,8 @@ def test_apply_decision_to_user_ok(self): mock_response.headers = response_with_data_header() with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) - data = json.dumps(applyDecisionRequest) + response = self.sift_client.apply_user_decision(user_id, apply_decision_request) + data = json.dumps(apply_decision_request) mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) From 782ad6dd8c13aec81b0b6eebcbf535d89edaa146 Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 17 Jan 2017 13:12:32 -0800 Subject: [PATCH 020/112] address comments; add tests --- sift/client.py | 26 +++++++------- tests/test_client.py | 84 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 30 deletions(-) diff --git a/sift/client.py b/sift/client.py index 5183155..4a91c4f 100644 --- a/sift/client.py +++ b/sift/client.py @@ -321,7 +321,7 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No entity_type: only return decisions applicable to entity type {USER|ORDER} limit: number of query results (decisions) to return [default: 100] start_from: result set offset for use in pagination [default: 0] - abuse_types: csv of abuse_types to filter returned decisions by (optional) + abuse_types: csv of abuse_types by which to filter returned decisions (optional) Returns: A sift.client.Response object containing array of decisions if call succeeded @@ -333,7 +333,7 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No params = {} - if not isinstance(entity_type, self.UNICODE_STRING) or len(entity_type.strip()) == 0\ + if not isinstance(entity_type, self.UNICODE_STRING) or len(entity_type.strip()) == 0 \ or entity_type not in {'user', 'order'}: raise ApiException("entity_type must be one of {user, order}") @@ -361,7 +361,7 @@ def apply_user_decision(self, user_id, properties, timeout=None): Args: user_id: id of user - apply_decision_request: + properties: decision_id: decision to apply to user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if `source: MANUAL_REVIEW` @@ -373,7 +373,7 @@ def apply_user_decision(self, user_id, properties, timeout=None): if timeout is None: timeout = self.timeout - self.validate_apply_decision_request(properties, user_id, None) + self._validate_apply_decision_request(properties, user_id) try: return Response(requests.post( @@ -394,12 +394,12 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): Args: user_id: id of user order_id: id of order - applyDecisionJson: + properties: decision_id: decision to apply to user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' - description: note or description of decision applied or reasons (optional) - time: in millis when decision was applied + description: free form text (optional) + time: in millis when decision was applied (optional) Returns A sift.client.Response object if the call succeeded, else raises an ApiException """ @@ -407,7 +407,12 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): if timeout is None: timeout = self.timeout - self.validate_apply_decision_request(properties, user_id, order_id) + + if order_id is None or not isinstance(order_id, self.UNICODE_STRING) or \ + len(order_id.strip()) == 0: + raise ApiException("order_id must be a string") + + self._validate_apply_decision_request(properties, user_id) try: return Response(requests.post( @@ -422,13 +427,10 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def validate_apply_decision_request(self, properties, user_id, order_id): + def _validate_apply_decision_request(self, properties, user_id): if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: raise ApiException("user_id must be a string") - if not order_id is None and (not isinstance(order_id, self.UNICODE_STRING) or len(order_id.strip()) == 0): - raise ApiException("order_id must be a string") - if not isinstance(properties, dict) or len(properties) == 0: raise ApiException("properties dictionary may not be empty") diff --git a/tests/test_client.py b/tests/test_client.py index 5cf848c..525c88b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -296,7 +296,7 @@ def test_sync_score_ok(self): def test_get_decisions(self): mock_response = mock.Mock() - getDecisionsResponseJson = \ + get_decisions_response_json = \ '{' \ '"data": [' \ '{' \ @@ -317,7 +317,7 @@ def test_get_decisions(self): '"next_ref": "v3/accounts/accountId/decisions"' \ '}' - mock_response.content = getDecisionsResponseJson + mock_response.content = get_decisions_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -350,7 +350,7 @@ def test_apply_decision_to_user_ok(self): 'description': 'called user and verified account', 'time': 1481569575 } - applyDecisionResponseJson = '{' \ + apply_decision_response_json = '{' \ '"entity": {' \ '"id": "54321",' \ '"type": "user"' \ @@ -359,7 +359,7 @@ def test_apply_decision_to_user_ok(self): '"id":"user_looks_ok_legacy"' \ '},' \ '"time":"1481569575"}' - mock_response.content = applyDecisionResponseJson + mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -376,10 +376,58 @@ def test_apply_decision_to_user_ok(self): assert(response.http_status_code == 200) assert(response.is_ok()) + def test_validate_no_user_id_string_fails(self): + apply_decision_request = { + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', + } + try: + self.sift_client._validate_apply_decision_request(apply_decision_request, 123) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_apply_decision_to_order(self): + try: + self.sift_client.apply_order_decision("user_id", None, {}) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_validate_apply_decision_request_no_analyst_fails(self): + apply_decision_request = { + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'time': 1481569575 + } + + try: + self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_validate_apply_decision_request_no_source_fails(self): + apply_decision_request = { + 'decision_id': 'user_looks_ok_legacy', + 'time': 1481569575 + } + + try: + self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + + def test_validate_empty_apply_decision_request_fails(self): + apply_decision_request = {} + try: + self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + def test_apply_decision_manual_review_no_analyst_fails(self): user_id = '54321' mock_response = mock.Mock() - applyDecisionRequest = { + apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', 'source': 'MANUAL_REVIEW', 'time': 1481569575 @@ -387,8 +435,8 @@ def test_apply_decision_manual_review_no_analyst_fails(self): with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response try: - response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) - data = json.dumps(applyDecisionRequest) + response = self.sift_client.apply_user_decision(user_id, apply_decision_request) + data = json.dumps(apply_decision_request) mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) @@ -398,15 +446,15 @@ def test_apply_decision_manual_review_no_analyst_fails(self): def test_apply_decision_no_source_fails(self): user_id = '54321' mock_response = mock.Mock() - applyDecisionRequest = { + apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', 'time': 1481569575 } with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response try: - response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) - data = json.dumps(applyDecisionRequest) + response = self.sift_client.apply_user_decision(user_id, apply_decision_request) + data = json.dumps(apply_decision_request) mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) @@ -416,7 +464,7 @@ def test_apply_decision_no_source_fails(self): def test_apply_decision_invalid_source_fails(self): user_id = '54321' mock_response = mock.Mock() - applyDecisionRequest = { + apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', 'source': 'INVALID_SOURCE', 'time': 1481569575 @@ -424,8 +472,8 @@ def test_apply_decision_invalid_source_fails(self): with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response try: - response = self.sift_client.apply_user_decision(user_id, applyDecisionRequest) - data = json.dumps(applyDecisionRequest) + response = self.sift_client.apply_user_decision(user_id, apply_decision_request) + data = json.dumps(apply_decision_request) mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) @@ -436,13 +484,13 @@ def test_apply_decision_to_order_ok(self): user_id = '54321' order_id = '43210' mock_response = mock.Mock() - applyDecisionRequest = { + apply_decision_request = { 'decision_id': 'order_looks_bad_payment_abuse', 'source': 'AUTOMATED_RULE', 'time': 1481569575 } - applyDecisionResponseJson = '{' \ + apply_decision_response_json = '{' \ '"entity": {' \ '"id": "54321",' \ '"type": "order"' \ @@ -452,14 +500,14 @@ def test_apply_decision_to_order_ok(self): '},' \ '"time":"1481569575"}' - mock_response.content = applyDecisionResponseJson + mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() with mock.patch('requests.post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_order_decision(user_id, order_id, applyDecisionRequest) - data = json.dumps(applyDecisionRequest) + response = self.sift_client.apply_order_decision(user_id, order_id, apply_decision_request) + data = json.dumps(apply_decision_request) mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id,order_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) From 32c5f5cb8184a25f157318ab2a77504ada2d953f Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 17 Jan 2017 13:19:10 -0800 Subject: [PATCH 021/112] add test; fix braces --- sift/client.py | 2 +- tests/test_client.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/sift/client.py b/sift/client.py index 4a91c4f..312fbbe 100644 --- a/sift/client.py +++ b/sift/client.py @@ -334,7 +334,7 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No params = {} if not isinstance(entity_type, self.UNICODE_STRING) or len(entity_type.strip()) == 0 \ - or entity_type not in {'user', 'order'}: + or entity_type.lower() not in ['user', 'order']: raise ApiException("entity_type must be one of {user, order}") params['entity_type'] = entity_type diff --git a/tests/test_client.py b/tests/test_client.py index 525c88b..e255565 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -294,6 +294,12 @@ def test_sync_score_ok(self): assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) + def test_get_decisions_fails(self): + try: + self.sift_client.get_decisions('usr') + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + def test_get_decisions(self): mock_response = mock.Mock() get_decisions_response_json = \ From 957b44b805d59ac872d24f146ae04fe61b8d943b Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 17 Jan 2017 15:48:16 -0800 Subject: [PATCH 022/112] clearer language in comments --- sift/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sift/client.py b/sift/client.py index 312fbbe..9d6b790 100644 --- a/sift/client.py +++ b/sift/client.py @@ -319,9 +319,9 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No Args: entity_type: only return decisions applicable to entity type {USER|ORDER} - limit: number of query results (decisions) to return [default: 100] - start_from: result set offset for use in pagination [default: 0] - abuse_types: csv of abuse_types by which to filter returned decisions (optional) + limit: number of query results (decisions) to return [optional, default: 100] + start_from: result set offset for use in pagination [optional, default: 0] + abuse_types: comma-separated list of abuse_types used to filter returned decisions (optional) Returns: A sift.client.Response object containing array of decisions if call succeeded @@ -364,7 +364,7 @@ def apply_user_decision(self, user_id, properties, timeout=None): properties: decision_id: decision to apply to user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} - analyst: id or email, required if `source: MANUAL_REVIEW` + analyst: id or email, required if 'source: MANUAL_REVIEW' time: in millis when decision was applied Returns A sift.client.Response object if the call succeeded, else raises an ApiException From 5e803b92b8dc900429aa1794cc21260b0dbc4921 Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 17 Jan 2017 16:49:37 -0800 Subject: [PATCH 023/112] increment version to 3.1.0.0 --- sift/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sift/version.py b/sift/version.py index d858aef..7ea7834 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '3.0.0.0' +VERSION = '3.1.0.0' API_VERSION = '204' From 2e15117c0a00c89f12104779316a7d0f8c796935 Mon Sep 17 00:00:00 2001 From: Arjun Krishnaiah Date: Tue, 17 Jan 2017 17:09:59 -0800 Subject: [PATCH 024/112] update CHANGES.md --- CHANGES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 7bbf71d..8945b3b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +3.1.0.0 2017-01-17 +================== + +- Adds support for Get, Apply Decisions APIs + 3.0.0.0 2016-07-19 ================== From 884b8b0cebc9f9b9246a0b089730aa31d4560dba Mon Sep 17 00:00:00 2001 From: mjouahri <35049558+mjouahri@users.noreply.github.com> Date: Tue, 13 Feb 2018 11:21:17 -0800 Subject: [PATCH 025/112] (For Jintae or Gary) Add session level decision. (#55) * Add session level decision. --- CHANGES.md | 6 +++ sift/client.py | 51 +++++++++++++++++++++++-- sift/version.py | 2 +- tests/test_client.py | 90 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8945b3b..1c7b51e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,9 @@ +3.2.0.0 2018-02-12 +================== + +- Add session level decisions in Apply Decisions APIs. +- Add support for filtering get decisions by entity type session. + 3.1.0.0 2017-01-17 ================== diff --git a/sift/client.py b/sift/client.py index 9d6b790..e87a4ac 100644 --- a/sift/client.py +++ b/sift/client.py @@ -318,7 +318,7 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No """Get decisions available to customer Args: - entity_type: only return decisions applicable to entity type {USER|ORDER} + entity_type: only return decisions applicable to entity type {USER|ORDER|SESSION} limit: number of query results (decisions) to return [optional, default: 100] start_from: result set offset for use in pagination [optional, default: 0] abuse_types: comma-separated list of abuse_types used to filter returned decisions (optional) @@ -334,8 +334,8 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No params = {} if not isinstance(entity_type, self.UNICODE_STRING) or len(entity_type.strip()) == 0 \ - or entity_type.lower() not in ['user', 'order']: - raise ApiException("entity_type must be one of {user, order}") + or entity_type.lower() not in ['user', 'order', 'session']: + raise ApiException("entity_type must be one of {user, order, session}") params['entity_type'] = entity_type @@ -395,7 +395,7 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): user_id: id of user order_id: id of order properties: - decision_id: decision to apply to user + decision_id: decision to apply to order source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' description: free form text (optional) @@ -502,6 +502,46 @@ def get_order_decisions(self, order_id, timeout=None): raise ApiException(str(e)) + def apply_session_decision(self, user_id, session_id, properties, timeout=None): + """Apply decision to session + + Args: + user_id: id of user + session_id: id of session + properties: + decision_id: decision to apply to session + source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} + analyst: id or email, required if 'source: MANUAL_REVIEW' + description: free form text (optional) + time: in millis when decision was applied (optional) + Returns + A sift.client.Response object if the call succeeded, else raises an ApiException + """ + + if timeout is None: + timeout = self.timeout + + + if session_id is None or not isinstance(session_id, self.UNICODE_STRING) or \ + len(session_id.strip()) == 0: + raise ApiException("session_id must be a string") + + self._validate_apply_decision_request(properties, user_id) + + try: + return Response(requests.post( + self._session_apply_decisions_url(self.account_id, user_id, session_id), + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -529,6 +569,9 @@ def _order_decisions_url(self, account_id, order_id): def _order_apply_decisions_url(self, account_id, user_id, order_id): return API3_URL + '/v3/accounts/%s/users/%s/orders/%s/decisions' % (account_id, user_id, order_id) + def _session_apply_decisions_url(self, account_id, user_id, session_id): + return API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % (account_id, user_id, session_id) + class Response(object): HTTP_CODES_WITHOUT_BODY = [204, 304] diff --git a/sift/version.py b/sift/version.py index 7ea7834..a51312d 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '3.1.0.0' +VERSION = '3.2.0.0' API_VERSION = '204' diff --git a/tests/test_client.py b/tests/test_client.py index e255565..c10638e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -346,6 +346,52 @@ def test_get_decisions(self): assert(response.is_ok()) assert(response.body['data'][0]['id'] == 'block_user') + def test_get_decisions_entity_session(self): + mock_response = mock.Mock() + get_decisions_response_json = \ + '{' \ + '"data": [' \ + '{' \ + '"id": "block_session",' \ + '"name" : "Block session",' \ + '"description": "session has problems",' \ + '"entity_type": "session",' \ + '"abuse_type": "legacy",' \ + '"category": "block",' \ + '"webhook_url": "http://web.hook",' \ + '"created_at": "1468005577348",' \ + '"created_by": "admin@biz.com",' \ + '"updated_at": "1469229177756",' \ + '"updated_by": "analyst@biz.com"' \ + '}' \ + '],' \ + '"has_more": "true",' \ + '"next_ref": "v3/accounts/accountId/decisions"' \ + '}' + + mock_response.content = get_decisions_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_decisions(entity_type="session", + limit=10, + start_from=None, + abuse_types="account_takeover", + timeout=3) + mock_get.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', + headers=mock.ANY, + auth=mock.ANY, + params={'entity_type':'session','limit':10,'abuse_types':'account_takeover'}, + timeout=3) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.body['data'][0]['id'] == 'block_session') + def test_apply_decision_to_user_ok(self): user_id = '54321' mock_response = mock.Mock() @@ -394,12 +440,18 @@ def test_validate_no_user_id_string_fails(self): except Exception as e: assert(isinstance(e, sift.client.ApiException)) - def test_apply_decision_to_order(self): + def test_apply_decision_to_order_fails_with_no_order_id(self): try: self.sift_client.apply_order_decision("user_id", None, {}) except Exception as e: assert(isinstance(e, sift.client.ApiException)) + def test_apply_decision_to_session_fails_with_no_session_id(self): + try: + self.sift_client.apply_session_decision("user_id", None, {}) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + def test_validate_apply_decision_request_no_analyst_fails(self): apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', @@ -522,6 +574,42 @@ def test_apply_decision_to_order_ok(self): assert(response.http_status_code == 200) assert(response.body['entity']['type'] == 'order') + def test_apply_decision_to_session_ok(self): + user_id = '54321' + session_id = 'gigtleqddo84l8cm15qe4il' + mock_response = mock.Mock() + apply_decision_request = { + 'decision_id': 'session_looks_bad_ato', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } + + apply_decision_response_json = '{' \ + '"entity": {' \ + '"id": "54321",' \ + '"type": "login"' \ + '},' \ + '"decision": {' \ + '"id":"session_looks_bad_ato"' \ + '},' \ + '"time":"1481569575"}' + + mock_response.content = apply_decision_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.apply_session_decision(user_id, session_id, apply_decision_request) + data = json.dumps(apply_decision_request) + mock_post.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id,session_id), + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.http_status_code == 200) + assert(response.body['entity']['type'] == 'login') + def test_label_user_ok(self): user_id = '54321' mock_response = mock.Mock() From ef7edf764dad35aba75e2202cc938bc6552857f0 Mon Sep 17 00:00:00 2001 From: Kuba Karpierz Date: Wed, 28 Mar 2018 15:04:05 -0700 Subject: [PATCH 026/112] (Gary L.) Adding Content-level decisions, incrementing version number (#56) * Squashed commits, added get content decision status, and incremented python version * Added updated content decision status request * Updated README * Removed a new line --- CHANGES.md | 6 ++++ README.md | 8 +++++ sift/client.py | 84 ++++++++++++++++++++++++++++++++++++++++++-- sift/version.py | 4 +-- tests/test_client.py | 84 +++++++++++++++++++++++++++++++++++++------- 5 files changed, 169 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 1c7b51e..6b13971 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,9 @@ +4.0.0.0 2018-02-23 +================== + +- V205 APIs are now called by default -- this is an incompatible change + (use version='204' to call the previous API version) + 3.2.0.0 2018-02-12 ================== diff --git a/README.md b/README.md index abd09cc..5b8f0f3 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,14 @@ try: except sift.client.ApiException: # request failed + + +# Get the latest decisions for a piece of content +try: + response = client.get_content_decisions('example_user', 'example_content'); +except sift.client.ApiException: + # request failed + ``` diff --git a/sift/client.py b/sift/client.py index e87a4ac..3e43a74 100644 --- a/sift/client.py +++ b/sift/client.py @@ -318,7 +318,7 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No """Get decisions available to customer Args: - entity_type: only return decisions applicable to entity type {USER|ORDER|SESSION} + entity_type: only return decisions applicable to entity type {USER|ORDER|SESSION|CONTENT} limit: number of query results (decisions) to return [optional, default: 100] start_from: result set offset for use in pagination [optional, default: 0] abuse_types: comma-separated list of abuse_types used to filter returned decisions (optional) @@ -334,8 +334,8 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No params = {} if not isinstance(entity_type, self.UNICODE_STRING) or len(entity_type.strip()) == 0 \ - or entity_type.lower() not in ['user', 'order', 'session']: - raise ApiException("entity_type must be one of {user, order, session}") + or entity_type.lower() not in ['user', 'order', 'session', 'content']: + raise ApiException("entity_type must be one of {user, order, session, content}") params['entity_type'] = entity_type @@ -502,6 +502,38 @@ def get_order_decisions(self, order_id, timeout=None): raise ApiException(str(e)) + def get_content_decisions(self, user_id, content_id, timeout=None): + """Gets the decisions for a piece of content. + + Args: + user_id: The ID of the owner of the content. + content_id: The ID of a piece of content. + + Returns: + A sift.client.Response object if the call succeeded. + Otherwise, raises an ApiException. + + """ + if not isinstance(content_id, self.UNICODE_STRING) or len(content_id.strip()) == 0: + raise ApiException("content_id must be a string") + + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + + if timeout is None: + timeout = self.timeout + + try: + return Response(requests.get( + self._content_decisions_url(self.account_id, user_id, content_id), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + def apply_session_decision(self, user_id, session_id, properties, timeout=None): """Apply decision to session @@ -542,6 +574,46 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): raise ApiException(str(e)) + def apply_content_decision(self, user_id, content_id, properties, timeout=None): + """Apply decision to content + + Args: + user_id: id of user + content_id: id of content + properties: + decision_id: decision to apply to session + source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} + analyst: id or email, required if 'source: MANUAL_REVIEW' + description: free form text (optional) + time: in millis when decision was applied (optional) + Returns + A sift.client.Response object if the call succeeded, else raises an ApiException + """ + + if timeout is None: + timeout = self.timeout + + + if content_id is None or not isinstance(content_id, self.UNICODE_STRING) or \ + len(content_id.strip()) == 0: + raise ApiException("content_id must be a string") + + self._validate_apply_decision_request(properties, user_id) + + try: + return Response(requests.post( + self._content_apply_decisions_url(self.account_id, user_id, content_id), + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -566,12 +638,18 @@ def _user_decisions_url(self, account_id, user_id): def _order_decisions_url(self, account_id, order_id): return API3_URL + '/v3/accounts/%s/orders/%s/decisions' % (account_id, order_id) + def _content_decisions_url(self, account_id, user_id, content_id): + return API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (account_id, user_id, content_id) + def _order_apply_decisions_url(self, account_id, user_id, order_id): return API3_URL + '/v3/accounts/%s/users/%s/orders/%s/decisions' % (account_id, user_id, order_id) def _session_apply_decisions_url(self, account_id, user_id, session_id): return API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % (account_id, user_id, session_id) + def _content_apply_decisions_url(self, account_id, user_id, content_id): + return API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (account_id, user_id, content_id) + class Response(object): HTTP_CODES_WITHOUT_BODY = [204, 304] diff --git a/sift/version.py b/sift/version.py index a51312d..9f0579b 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '3.2.0.0' -API_VERSION = '204' +VERSION = '4.0.0.0' +API_VERSION = '205' diff --git a/tests/test_client.py b/tests/test_client.py index c10638e..84ef0d7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -189,7 +189,7 @@ def test_event_ok(self): mock_post.return_value = mock_response response = self.sift_client.track(event, valid_transaction_properties()) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/events', + 'https://api.siftscience.com/v205/events', data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, @@ -212,7 +212,7 @@ def test_event_with_timeout_param_ok(self): response = self.sift_client.track( event, valid_transaction_properties(), timeout=test_timeout) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/events', + 'https://api.siftscience.com/v205/events', data=mock.ANY, headers=mock.ANY, timeout=test_timeout, @@ -232,7 +232,7 @@ def test_score_ok(self): mock_post.return_value = mock_response response = self.sift_client.score('12345') mock_post.assert_called_with( - 'https://api.siftscience.com/v204/score/12345', + 'https://api.siftscience.com/v205/score/12345', params={'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) @@ -254,7 +254,7 @@ def test_score_with_timeout_param_ok(self): mock_post.return_value = mock_response response = self.sift_client.score('12345', test_timeout) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/score/12345', + 'https://api.siftscience.com/v205/score/12345', params={'api_key': self.test_key}, headers=mock.ANY, timeout=test_timeout) @@ -281,7 +281,7 @@ def test_sync_score_ok(self): return_score=True, abuse_types=['payment_abuse', 'content_abuse', 'legacy']) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/events', + 'https://api.siftscience.com/v205/events', data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, @@ -452,6 +452,12 @@ def test_apply_decision_to_session_fails_with_no_session_id(self): except Exception as e: assert(isinstance(e, sift.client.ApiException)) + def test_apply_decision_to_content_fails_with_no_content_id(self): + try: + self.sift_client.apply_content_decision("user_id", None, {}) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + def test_validate_apply_decision_request_no_analyst_fails(self): apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', @@ -610,6 +616,42 @@ def test_apply_decision_to_session_ok(self): assert(response.http_status_code == 200) assert(response.body['entity']['type'] == 'login') + def test_apply_decision_to_content_ok(self): + user_id = '54321' + content_id = 'listing-1231' + mock_response = mock.Mock() + apply_decision_request = { + 'decision_id': 'content_looks_bad_content_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } + + apply_decision_response_json = '{' \ + '"entity": {' \ + '"id": "54321",' \ + '"type": "create_content"' \ + '},' \ + '"decision": {' \ + '"id":"content_looks_bad_content_abuse"' \ + '},' \ + '"time":"1481569575"}' + + mock_response.content = apply_decision_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.apply_content_decision(user_id, content_id, apply_decision_request) + data = json.dumps(apply_decision_request) + mock_post.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id,content_id), + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.http_status_code == 200) + assert(response.body['entity']['type'] == 'create_content') + def test_label_user_ok(self): user_id = '54321' mock_response = mock.Mock() @@ -630,7 +672,7 @@ def test_label_user_ok(self): properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/users/%s/labels' % user_id, + 'https://api.siftscience.com/v205/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=mock.ANY, params={}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) @@ -659,7 +701,7 @@ def test_label_user_with_timeout_param_ok(self): properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/users/%s/labels' % user_id, + 'https://api.siftscience.com/v205/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=test_timeout, params={}) assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) @@ -674,7 +716,7 @@ def test_unlabel_user_ok(self): mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id, abuse_type='account_abuse') mock_delete.assert_called_with( - 'https://api.siftscience.com/v204/users/%s/labels' % user_id, + 'https://api.siftscience.com/v205/users/%s/labels' % user_id, headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key, 'abuse_type': 'account_abuse'}) @@ -714,7 +756,7 @@ def test_unlabel_user_with_special_chars_ok(self): mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id) mock_delete.assert_called_with( - 'https://api.siftscience.com/v204/users/%s/labels' % urllib.quote(user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.quote(user_id), headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) @@ -742,7 +784,7 @@ def test_label_user__with_special_chars_ok(self): properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/users/%s/labels' % urllib.quote(user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.quote(user_id), data=data, headers=mock.ANY, timeout=mock.ANY, @@ -763,7 +805,7 @@ def test_score__with_special_user_id_chars_ok(self): mock_post.return_value = mock_response response = self.sift_client.score(user_id, abuse_types=['legacy']) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/score/%s' % urllib.quote(user_id), + 'https://api.siftscience.com/v205/score/%s' % urllib.quote(user_id), params={'api_key': self.test_key, 'abuse_types': 'legacy'}, headers=mock.ANY, timeout=mock.ANY) @@ -820,7 +862,7 @@ def test_return_actions_on_track(self): response = self.sift_client.track( event, valid_transaction_properties(), return_action=True) mock_post.assert_called_with( - 'https://api.siftscience.com/v204/events', + 'https://api.siftscience.com/v205/events', data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, @@ -895,6 +937,24 @@ def test_get_order_decisions(self): assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') + def test_get_content_decisions(self): + mock_response = mock.Mock() + mock_response.content = '{"decisions":{"content_abuse":{"decision":{"id":"content_looks_bad_content_abuse"},"time":1468517407135,"webhook_succeeded":true}}}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_content_decisions('example_user', 'example_content') + mock_get.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions', + headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') def main(): unittest.main() From b3f0a952d4ca308f0120f1665cfc0497d0d7d597 Mon Sep 17 00:00:00 2001 From: Kuba Karpierz Date: Fri, 6 Apr 2018 11:15:37 -0700 Subject: [PATCH 027/112] (Data Success) Update python client lib documentation (#57) * Updated changes/readme * removed redundant info * Updated docs * Updated date on CHANGES file * added Nick's changes --- CHANGES.md | 19 ++++++++++++++++--- README.md | 3 +-- sift/version.py | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6b13971..e05c95a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,21 @@ -4.0.0.0 2018-02-23 +4.0.1 2018-04-06 ================== -- V205 APIs are now called by default -- this is an incompatible change - (use version='204' to call the previous API version) +- Updated documentation in CHANGES.md and README.md + +4.0.0.0 2018-03-30 +================== + +- Adds support for Sift Science API Version 205, including new [`$create_content`](https://siftscience.com/developers/docs/curl/events-api/reserved-events/create-content) and [`$update_content`](https://siftscience.com/developers/docs/curl/events-api/reserved-events/update-content) formats +- V205 APIs are now called -- **this is an incompatible change** + - Use `version = '204'` when constructing the Client to call the previous API version +- Adds support for content decisions to [Decisions API](https://siftscience.com/developers/docs/curl/decisions-api) + + +INCOMPATIBLE CHANGES INTRODUCED IN API V205: +- `$create_content` and `$update_content` have significantly changed, and the old format will be rejected +- `$send_message` and `$submit_review` events are no longer valid +- V205 improves server-side event data validation. In V204 and earlier, server-side validation accepted some events that did not conform to the published APIs in our [developer documentation](https://siftscience.com/developers/docs/curl/events-api). V205 does not modify existing event APIs other than those mentioned above, but may reject invalid event data that were previously accepted. **Please test your integration on V205 in sandbox before using in production.** 3.2.0.0 2018-02-12 ================== diff --git a/README.md b/README.md index 5b8f0f3..49ff4ee 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Please see [the CHANGELOG](https://github.com/SiftScience/sift-python/blob/master/CHANGES.md) for a history of all changes. -Note, that in v2.0.0.0, the API semantics were changed to raise an +Note, that in v2.0.0, the API semantics were changed to raise an exception in the case of error to be more pythonic. Client code will need to be updated to catch `sift.client.ApiException` exceptions. @@ -144,7 +144,6 @@ except sift.client.ApiException: # request failed - # Get the latest decisions for a piece of content try: response = client.get_content_decisions('example_user', 'example_content'); diff --git a/sift/version.py b/sift/version.py index 9f0579b..b5a3c06 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '4.0.0.0' +VERSION = '4.0.1' API_VERSION = '205' From 2b5f442c7c052693cdd348c3140a86a365c65c4e Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Tue, 10 Apr 2018 14:09:53 -0700 Subject: [PATCH 028/112] Update the pip install example to the correct package name --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 49ff4ee..0b781ed 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ Get the latest released package from pip: Python 2: - pip install sift + pip install Sift Python 3: - pip3 install sift + pip3 install Sift or install newest source directly from GitHub: From 49d0db5c788b91000c8f6fe466eba3ea41e78d13 Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Thu, 10 May 2018 16:32:41 -0700 Subject: [PATCH 029/112] Make the readme use PEP 8 (#58) --- README.md | 100 +++++++++++++++++++++++++++--------------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 0b781ed..7987134 100644 --- a/README.md +++ b/README.md @@ -65,91 +65,91 @@ sift.api_key = '' sift.account_id = '' client = sift.Client() -user_id = "23056" # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ - +# User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ +user_id = "23056" # Track a transaction event -- note this is a blocking call properties = { - "$user_id" : user_id, - "$user_email" : "buyer@gmail.com", - "$seller_user_id" : "2371", - "seller_user_email" : "seller@gmail.com", - "$transaction_id" : "573050", - "$payment_method" : { - "$payment_type" : "$credit_card", - "$payment_gateway" : "$braintree", - "$card_bin" : "542486", - "$card_last4" : "4444" - }, - "$currency_code" : "USD", - "$amount" : 15230000, + "$user_id": user_id, + "$user_email": "buyer@gmail.com", + "$seller_user_id": "2371", + "seller_user_email": "seller@gmail.com", + "$transaction_id": "573050", + "$payment_method": { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444" + }, + "$currency_code": "USD", + "$amount": 15230000, } try: - response = client.track("$transaction", properties) - if response.is_ok(): - print "Successfully tracked event" + response = client.track("$transaction", properties) + if response.is_ok(): + print "Successfully tracked event" except sift.client.ApiException: - # request failed - + # request failed + pass # Request a score for the user with user_id 23056 try: - response = client.score(user_id) - s = json.dumps(response.body) - print s + response = client.score(user_id) + s = json.dumps(response.body) + print s except sift.client.ApiException: - # request failed - + # request failed + pass try: - # Label the user with user_id 23056 as Bad with all optional fields - response = client.label(user_id, { - "$is_bad" : True, - "$abuse_type" : "payment_abuse", - "$description" : "Chargeback issued", - "$source" : "Manual Review", - "$analyst" : "analyst.name@your_domain.com" - }) + # Label the user with user_id 23056 as Bad with all optional fields + response = client.label(user_id, { + "$is_bad": True, + "$abuse_type": "payment_abuse", + "$description": "Chargeback issued", + "$source": "Manual Review", + "$analyst": "analyst.name@your_domain.com" + }) except sift.client.ApiException: - # request failed - + # request failed + pass # Remove a label from a user with user_id 23056 try: - response = client.unlabel(user_id, abuse_type='content_abuse') + response = client.unlabel(user_id, abuse_type='content_abuse') except sift.client.ApiException: - # request failed - + # request failed + pass # Get the status of a workflow run try: - response = client.get_workflow_status('my_run_id'); + response = client.get_workflow_status('my_run_id') except sift.client.ApiException: - # request failed - + # request failed + pass # Get the latest decisions for a user try: - response = client.get_user_decisions('example_user'); + response = client.get_user_decisions('example_user') except sift.client.ApiException: - # request failed - + # request failed + pass # Get the latest decisions for an order try: - response = client.get_order_decisions('example_order'); + response = client.get_order_decisions('example_order') except sift.client.ApiException: - # request failed - + # request failed + pass # Get the latest decisions for a piece of content try: - response = client.get_content_decisions('example_user', 'example_content'); + response = client.get_content_decisions('example_user', 'example_content') except sift.client.ApiException: - # request failed - + # request failed + pass ``` From e20dbdc8106ac514c966b307eb7a026c8738337c Mon Sep 17 00:00:00 2001 From: lauramilanez Date: Fri, 25 May 2018 11:39:51 -0700 Subject: [PATCH 030/112] Update client.py --- sift/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sift/client.py b/sift/client.py index 3e43a74..b3719db 100644 --- a/sift/client.py +++ b/sift/client.py @@ -45,7 +45,7 @@ def __init__( this from https://siftscience.com/console/account/profile . version: The version of the Sift Science API to call. Defaults to - the latest version ('204'). + the latest version ('205'). """ if not isinstance(api_url, str) or len(api_url.strip()) == 0: From 31ac26d3182f905e2f95b2b6708bfff07682b839 Mon Sep 17 00:00:00 2001 From: Michael LeGore Date: Wed, 30 May 2018 16:01:26 -0700 Subject: [PATCH 031/112] Add get session decisions Added API client method for calling user session get decisions API described here: https://siftscience.com/developers/docs/curl/decisions-api/decision-status for https://github.com/SiftScience/code/issues/25317 --- CHANGES.md | 6 +++++- sift/client.py | 32 ++++++++++++++++++++++++++++++++ sift/version.py | 2 +- tests/test_client.py | 25 +++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e05c95a..d9edf26 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +4.1.0.0 2018-06-01 +================== + +- Add get session level decisions in Get Decisions APIs. + 4.0.1 2018-04-06 ================== @@ -86,4 +91,3 @@ INCOMPATIBLE CHANGES INTRODUCED IN API V205: ================== - Just the Python REST client itself. - diff --git a/sift/client.py b/sift/client.py index b3719db..831d69a 100644 --- a/sift/client.py +++ b/sift/client.py @@ -533,6 +533,35 @@ def get_content_decisions(self, user_id, content_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) + def get_session_decisions(self, user_id, session_id, timeout=None): + """Gets the decisions for a user's session. + + Args: + user_id: The ID of a user. + session_id: The ID of a session + + Returns: + A sift.client.Response object if the call succeeded. + Otherwise, raises an ApiException. + + """ + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + if not isinstance(session_id, self.UNICODE_STRING) or len(session_id.strip()) == 0: + raise ApiException("session_id must be a string") + + if timeout is None: + timeout = self.timeout + + try: + return Response(requests.get( + self._session_decisions_url(self.account_id, user_id, session_id), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) def apply_session_decision(self, user_id, session_id, properties, timeout=None): """Apply decision to session @@ -638,6 +667,9 @@ def _user_decisions_url(self, account_id, user_id): def _order_decisions_url(self, account_id, order_id): return API3_URL + '/v3/accounts/%s/orders/%s/decisions' % (account_id, order_id) + def _session_decisions_url(self, account_id, user_id, session_id): + return API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % (account_id, user_id, session_id) + def _content_decisions_url(self, account_id, user_id, content_id): return API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (account_id, user_id, content_id) diff --git a/sift/version.py b/sift/version.py index b5a3c06..634a545 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '4.0.1' +VERSION = '4.1.0' API_VERSION = '205' diff --git a/tests/test_client.py b/tests/test_client.py index 84ef0d7..3334d0d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -452,6 +452,12 @@ def test_apply_decision_to_session_fails_with_no_session_id(self): except Exception as e: assert(isinstance(e, sift.client.ApiException)) + def test_get_session_decisions_fails_with_no_session_id(self): + try: + self.sift_client.get_session_decisions("user_id", None) + except Exception as e: + assert(isinstance(e, sift.client.ApiException)) + def test_apply_decision_to_content_fails_with_no_content_id(self): try: self.sift_client.apply_content_decision("user_id", None, {}) @@ -937,6 +943,25 @@ def test_get_order_decisions(self): assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') + def test_get_session_decisions(self): + mock_response = mock.Mock() + mock_response.content = '{"decisions":{"payment_abuse":{"decision":{"id":"user_decision"},"time":1468707128659,"webhook_succeeded":false}}}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + + response = self.sift_client.get_session_decisions('example_user','example_session') + mock_get.assert_called_with( + 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions', + headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') + def test_get_content_decisions(self): mock_response = mock.Mock() mock_response.content = '{"decisions":{"content_abuse":{"decision":{"id":"content_looks_bad_content_abuse"},"time":1468517407135,"webhook_succeeded":true}}}' From ef2589a833e38572cfabda08f2bda325123e2a6f Mon Sep 17 00:00:00 2001 From: Michael LeGore Date: Thu, 31 May 2018 14:19:32 -0700 Subject: [PATCH 032/112] Add example to readme --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 7987134..b98fb1e 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,13 @@ except sift.client.ApiException: # request failed pass +# Get the latest decisions for a session +try: + response = client.get_session_decisions('example_user', 'example_session') +except sift.client.ApiException: + # request failed + pass + # Get the latest decisions for a piece of content try: response = client.get_content_decisions('example_user', 'example_content') From 4a20f6f76acda704e9da4052f36482e1b6f2aec4 Mon Sep 17 00:00:00 2001 From: Michael LeGore Date: Thu, 31 May 2018 14:19:50 -0700 Subject: [PATCH 033/112] Add Jintae's comments --- sift/client.py | 2 +- tests/test_client.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sift/client.py b/sift/client.py index 831d69a..9b50a35 100644 --- a/sift/client.py +++ b/sift/client.py @@ -538,7 +538,7 @@ def get_session_decisions(self, user_id, session_id, timeout=None): Args: user_id: The ID of a user. - session_id: The ID of a session + session_id: The ID of a session. Returns: A sift.client.Response object if the call succeeded. diff --git a/tests/test_client.py b/tests/test_client.py index 3334d0d..fa1e982 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -945,7 +945,7 @@ def test_get_order_decisions(self): def test_get_session_decisions(self): mock_response = mock.Mock() - mock_response.content = '{"decisions":{"payment_abuse":{"decision":{"id":"user_decision"},"time":1468707128659,"webhook_succeeded":false}}}' + mock_response.content = '{"decisions":{"account_abuse": {"decision": {"id": "session_decision"},"time": 1461963839151,"webhook_succeeded": true}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -960,7 +960,7 @@ def test_get_session_decisions(self): assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') + assert(response.body['decisions']['account_abuse']['decision']['id'] == 'session_decision') def test_get_content_decisions(self): mock_response = mock.Mock() From 3445b7b52a729227a20b5fb5dd4ce54e6f9a9393 Mon Sep 17 00:00:00 2001 From: Michael LeGore Date: Fri, 1 Jun 2018 09:29:22 -0700 Subject: [PATCH 034/112] getsessiondecisions test use relevant test data --- tests/test_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index fa1e982..5d11280 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -945,7 +945,7 @@ def test_get_order_decisions(self): def test_get_session_decisions(self): mock_response = mock.Mock() - mock_response.content = '{"decisions":{"account_abuse": {"decision": {"id": "session_decision"},"time": 1461963839151,"webhook_succeeded": true}}}' + mock_response.content = '{"decisions":{"account_takeover": {"decision": {"id": "session_decision"},"time": 1461963839151,"webhook_succeeded": true}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -960,7 +960,7 @@ def test_get_session_decisions(self): assert(isinstance(response, sift.client.Response)) assert(response.is_ok()) - assert(response.body['decisions']['account_abuse']['decision']['id'] == 'session_decision') + assert(response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') def test_get_content_decisions(self): mock_response = mock.Mock() From 02450bded882428f1cef6291a03ee512c50438c8 Mon Sep 17 00:00:00 2001 From: Reginald Long Date: Thu, 5 Jul 2018 12:43:58 -0700 Subject: [PATCH 035/112] add force_workflow_run query param --- CHANGES.md | 4 ++++ sift/client.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d9edf26..15c9f49 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +4.1.1.0 2018-07-05 +================== +- Adds new query parameter force_workflow_run + 4.1.0.0 2018-06-01 ================== diff --git a/sift/client.py b/sift/client.py index 9b50a35..514526f 100644 --- a/sift/client.py +++ b/sift/client.py @@ -76,6 +76,7 @@ def track( return_score=False, return_action=False, return_workflow_status=False, + force_workflow_run=False, abuse_types=None, timeout=None, version=None): @@ -101,6 +102,8 @@ def track( return_workflow_status: Whether the API response should include the status of any workflow run as a result of the tracked event. + + force_workflow_run: TODO:(rlong) Add after Rishabh adds documentation. abuse_types(optional): List of abuse types, specifying for which abuse types a score should be returned (if scores were requested). If not specified, a score will @@ -149,6 +152,9 @@ def track( if return_workflow_status: params['return_workflow_status'] = 'true' + if force_workflow_run: + params['force_workflow_run'] = 'true' + try: response = requests.post( path, From 7f3749d77aa19e8a073342130960aeedaec5b967 Mon Sep 17 00:00:00 2001 From: Reginald Long <5060415+reginaldlong@users.noreply.github.com> Date: Thu, 5 Jul 2018 12:45:03 -0700 Subject: [PATCH 036/112] Update CHANGES.md --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 15c9f49..f551770 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ 4.1.1.0 2018-07-05 ================== -- Adds new query parameter force_workflow_run +- Add new query parameter force_workflow_run 4.1.0.0 2018-06-01 ================== From 6e997ddf9471f4c45db3229180d0de8338b688fb Mon Sep 17 00:00:00 2001 From: Reginald Long <5060415+reginaldlong@users.noreply.github.com> Date: Thu, 5 Jul 2018 12:57:14 -0700 Subject: [PATCH 037/112] Update CHANGES.md --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index f551770..2ce3336 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,4 @@ -4.1.1.0 2018-07-05 +4.2.0.0 2018-07-05 ================== - Add new query parameter force_workflow_run From e55afff4fd787d7d56effef013e59e61f79f8444 Mon Sep 17 00:00:00 2001 From: Alex Paino Date: Fri, 10 Aug 2018 11:13:05 -0700 Subject: [PATCH 038/112] (For David) Add support for rescore_user and get_user_score (#61) --- CHANGES.md | 4 ++ sift/client.py | 86 ++++++++++++++++++++++++++ sift/version.py | 2 +- tests/test_client.py | 144 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 2ce3336..59b19ad 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +4.3.0.0 2018-07-31 +================== +- Add support for rescore_user and get_user_score APIs + 4.2.0.0 2018-07-05 ================== - Add new query parameter force_workflow_run diff --git a/sift/client.py b/sift/client.py index 514526f..e2bfe50 100644 --- a/sift/client.py +++ b/sift/client.py @@ -213,6 +213,89 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): raise ApiException(str(e)) + def get_user_score(self, user_id, timeout=None, abuse_types=None): + """Fetches the latest score(s) computed for the specified user and abuse types from the Sift Science API. + As opposed to client.score() and client.rescore_user(), this *does not* compute a new score for the user; it + simply fetches the latest score(s) which have computed. These scores may be arbitrarily old. + + This call is blocking. See https://siftscience.com/developers/docs/python/score-api/get-score for more details. + + Args: + user_id: A user's id. This id should be the same as the user_id used in + event calls. + + timeout(optional): Use a custom timeout (in seconds) for this call. + + abuse_types(optional): List of abuse types, specifying for which abuse types a score + should be returned (if scores were requested). If not specified, a score will + be returned for every abuse_type to which you are subscribed. + + Returns: + A sift.client.Response object if the score call succeeded, or raises + an ApiException. + """ + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + + if timeout is None: + timeout = self.timeout + + headers = {'User-Agent': self._user_agent()} + params = {'api_key': self.api_key} + if abuse_types: + params['abuse_types'] = ','.join(abuse_types) + + try: + response = requests.get( + self._user_score_url(user_id, self.version), + headers=headers, + timeout=timeout, + params=params) + return Response(response) + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + + def rescore_user(self, user_id, timeout=None, abuse_types=None): + """Rescores the specified user for the specified abuse types and returns the resulting score(s). + This call is blocking. See https://siftscience.com/developers/docs/python/score-api/rescore for more details. + + Args: + user_id: A user's id. This id should be the same as the user_id used in + event calls. + + timeout(optional): Use a custom timeout (in seconds) for this call. + + abuse_types(optional): List of abuse types, specifying for which abuse types a score + should be returned (if scores were requested). If not specified, a score will + be returned for every abuse_type to which you are subscribed. + + Returns: + A sift.client.Response object if the score call succeeded, or raises + an ApiException. + """ + if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: + raise ApiException("user_id must be a string") + + if timeout is None: + timeout = self.timeout + + headers = {'User-Agent': self._user_agent()} + params = {'api_key': self.api_key} + if abuse_types: + params['abuse_types'] = ','.join(abuse_types) + + try: + response = requests.post( + self._user_score_url(user_id, self.version), + headers=headers, + timeout=timeout, + params=params) + return Response(response) + except requests.exceptions.RequestException as e: + raise ApiException(str(e)) + + def label(self, user_id, properties, timeout=None, version=None): """Labels a user as either good or bad through the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html @@ -658,6 +741,9 @@ def _event_url(self, version): def _score_url(self, user_id, version): return self.url + '/v%s/score/%s' % (version, urllib.quote(user_id)) + def _user_score_url(self, user_id, version): + return self.url + '/v%s/users/%s/score' % (version, urllib.quote(user_id)) + def _label_url(self, user_id, version): return self.url + '/v%s/users/%s/labels' % (version, urllib.quote(user_id)) diff --git a/sift/version.py b/sift/version.py index 634a545..3e0f491 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '4.1.0' +VERSION = '4.3.0' API_VERSION = '205' diff --git a/tests/test_client.py b/tests/test_client.py index 5d11280..9f3b81e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -72,6 +72,42 @@ def score_response_json(): }""" +# A sample response from the /{version}/users/{userId}/score API. +USER_SCORE_RESPONSE_JSON = """{ + "status": 0, + "error_message": "OK", + "entity_type": "user", + "entity_id": "12345", + "scores": { + "content_abuse": { + "score": 0.14 + }, + "payment_abuse": { + "score": 0.97 + } + }, + "latest_decisions": { + "payment_abuse": { + "id": "user_looks_bad_payment_abuse", + "category": "block", + "source": "AUTOMATED_RULE", + "time": 1352201880, + "description": "Bad Fraudster" + } + }, + "latest_labels": { + "promotion_abuse": { + "is_bad": false, + "time": 1457201099000 + }, + "payment_abuse": { + "is_bad": true, + "time": 1457212345000 + } + } +}""" + + def action_response_json(): return """{ "actions": [ @@ -265,6 +301,114 @@ def test_score_with_timeout_param_ok(self): assert(response.body['scores']['content_abuse']['score'] == 0.14) assert(response.body['scores']['payment_abuse']['score'] == 0.97) + def test_get_user_score_ok(self): + """Test the GET /{version}/users/{userId}/score API, i.e. client.get_user_score() + """ + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = USER_SCORE_RESPONSE_JSON + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + response = self.sift_client.get_user_score('12345', test_timeout) + mock_get.assert_called_with( + 'https://api.siftscience.com/v205/users/12345/score', + params={'api_key': self.test_key}, + headers=mock.ANY, + timeout=test_timeout) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_error_message == "OK") + assert(response.body['entity_id'] == '12345') + assert(response.body['scores']['content_abuse']['score'] == 0.14) + assert(response.body['scores']['payment_abuse']['score'] == 0.97) + assert('latest_decisions' in response.body) + + + def test_get_user_score_with_abuse_types_ok(self): + """Test the GET /{version}/users/{userId}/score?abuse_types=... API, i.e. client.get_user_score() + """ + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = USER_SCORE_RESPONSE_JSON + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.get') as mock_get: + mock_get.return_value = mock_response + response = self.sift_client.get_user_score('12345', + abuse_types=['payment_abuse', 'content_abuse'], + timeout=test_timeout) + mock_get.assert_called_with( + 'https://api.siftscience.com/v205/users/12345/score', + params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + headers=mock.ANY, + timeout=test_timeout) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_error_message == "OK") + assert(response.body['entity_id'] == '12345') + assert(response.body['scores']['content_abuse']['score'] == 0.14) + assert(response.body['scores']['payment_abuse']['score'] == 0.97) + assert('latest_decisions' in response.body) + + + def test_rescore_user_ok(self): + """Test the POST /{version}/users/{userId}/score API, i.e. client.rescore_user() + """ + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = USER_SCORE_RESPONSE_JSON + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.rescore_user('12345', test_timeout) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/users/12345/score', + params={'api_key': self.test_key}, + headers=mock.ANY, + timeout=test_timeout) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_error_message == "OK") + assert(response.body['entity_id'] == '12345') + assert(response.body['scores']['content_abuse']['score'] == 0.14) + assert(response.body['scores']['payment_abuse']['score'] == 0.97) + assert('latest_decisions' in response.body) + + + def test_rescore_user_with_abuse_types_ok(self): + """Test the POST /{version}/users/{userId}/score?abuse_types=... API, i.e. client.rescore_user() + """ + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = USER_SCORE_RESPONSE_JSON + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch('requests.post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.rescore_user('12345', + abuse_types=['payment_abuse', 'content_abuse'], + timeout=test_timeout) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/users/12345/score', + params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + headers=mock.ANY, + timeout=test_timeout) + assert(isinstance(response, sift.client.Response)) + assert(response.is_ok()) + assert(response.api_error_message == "OK") + assert(response.body['entity_id'] == '12345') + assert(response.body['scores']['content_abuse']['score'] == 0.14) + assert(response.body['scores']['payment_abuse']['score'] == 0.97) + assert('latest_decisions' in response.body) + + def test_sync_score_ok(self): event = '$transaction' mock_response = mock.Mock() From 09b6a55cbadebcb5289c548a346f0ade3fd22685 Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Thu, 20 Dec 2018 18:25:04 -0800 Subject: [PATCH 039/112] Update Travis CI badge in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b98fb1e..786092c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Sift Science Python Bindings ![TravisCI](https://travis-ci.org/SiftScience/sift-python.png?branch=master) +# Sift Science Python Bindings [![Build Status](https://travis-ci.org/SiftScience/sift-python.svg?branch=master)](https://travis-ci.org/SiftScience/sift-python) Bindings for Sift Science's APIs -- including the [Events](https://siftscience.com/resources/references/events-api.html), From f89de0af5fde21ed507e02f4c523a362237eea7c Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Fri, 4 Jan 2019 14:49:37 -0800 Subject: [PATCH 040/112] Enable connection pooling --- sift/client.py | 40 +++++++-------- tests/test_client.py | 100 ++++++++++++++++++++++---------------- tests/test_client_v203.py | 48 +++++++++--------- 3 files changed, 103 insertions(+), 85 deletions(-) diff --git a/sift/client.py b/sift/client.py index e2bfe50..2337e53 100644 --- a/sift/client.py +++ b/sift/client.py @@ -27,7 +27,8 @@ def __init__( api_url=API_URL, timeout=2.0, account_id=None, - version=sift.version.API_VERSION): + version=sift.version.API_VERSION, + session=None): """Initialize the client. Args: @@ -57,6 +58,7 @@ def __init__( if not isinstance(api_key, str) or len(api_key.strip()) == 0: raise ApiException("valid api_key is required") + self.session = session or requests.Session() self.api_key = api_key self.url = api_url self.timeout = timeout @@ -102,7 +104,7 @@ def track( return_workflow_status: Whether the API response should include the status of any workflow run as a result of the tracked event. - + force_workflow_run: TODO:(rlong) Add after Rishabh adds documentation. abuse_types(optional): List of abuse types, specifying for which abuse types a score @@ -156,7 +158,7 @@ def track( params['force_workflow_run'] = 'true' try: - response = requests.post( + response = self.session.post( path, data=json.dumps(properties), headers=headers, @@ -203,7 +205,7 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): params['abuse_types'] = ','.join(abuse_types) try: - response = requests.get( + response = self.session.get( self._score_url(user_id, version), headers=headers, timeout=timeout, @@ -246,7 +248,7 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None): params['abuse_types'] = ','.join(abuse_types) try: - response = requests.get( + response = self.session.get( self._user_score_url(user_id, self.version), headers=headers, timeout=timeout, @@ -286,7 +288,7 @@ def rescore_user(self, user_id, timeout=None, abuse_types=None): params['abuse_types'] = ','.join(abuse_types) try: - response = requests.post( + response = self.session.post( self._user_score_url(user_id, self.version), headers=headers, timeout=timeout, @@ -365,7 +367,7 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): try: - response = requests.delete( + response = self.session.delete( self._label_url(user_id, version), headers=headers, timeout=timeout, @@ -394,7 +396,7 @@ def get_workflow_status(self, run_id, timeout=None): timeout = self.timeout try: - return Response(requests.get( + return Response(self.session.get( self._workflow_status_url(self.account_id, run_id), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, @@ -438,9 +440,9 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No params['abuse_types'] = abuse_types try: - return Response(requests.get(self._get_decisions_url(self.account_id), params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, timeout=timeout)) + return Response(self.session.get(self._get_decisions_url(self.account_id), params=params, + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: raise ApiException(str(e)) @@ -465,7 +467,7 @@ def apply_user_decision(self, user_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) try: - return Response(requests.post( + return Response(self.session.post( self._user_decisions_url(self.account_id, user_id), data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), @@ -504,7 +506,7 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) try: - return Response(requests.post( + return Response(self.session.post( self._order_apply_decisions_url(self.account_id, user_id, order_id), data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), @@ -553,7 +555,7 @@ def get_user_decisions(self, user_id, timeout=None): timeout = self.timeout try: - return Response(requests.get( + return Response(self.session.get( self._user_decisions_url(self.account_id, user_id), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, @@ -581,7 +583,7 @@ def get_order_decisions(self, order_id, timeout=None): timeout = self.timeout try: - return Response(requests.get( + return Response(self.session.get( self._order_decisions_url(self.account_id, order_id), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, @@ -613,7 +615,7 @@ def get_content_decisions(self, user_id, content_id, timeout=None): timeout = self.timeout try: - return Response(requests.get( + return Response(self.session.get( self._content_decisions_url(self.account_id, user_id, content_id), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, @@ -643,7 +645,7 @@ def get_session_decisions(self, user_id, session_id, timeout=None): timeout = self.timeout try: - return Response(requests.get( + return Response(self.session.get( self._session_decisions_url(self.account_id, user_id, session_id), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, @@ -679,7 +681,7 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) try: - return Response(requests.post( + return Response(self.session.post( self._session_apply_decisions_url(self.account_id, user_id, session_id), data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), @@ -719,7 +721,7 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) try: - return Response(requests.post( + return Response(self.session.post( self._content_apply_decisions_url(self.account_id, user_id, content_id), data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), diff --git a/tests/test_client.py b/tests/test_client.py index 9f3b81e..412281d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -221,7 +221,7 @@ def test_event_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track(event, valid_transaction_properties()) mock_post.assert_called_with( @@ -243,7 +243,7 @@ def test_event_with_timeout_param_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( event, valid_transaction_properties(), timeout=test_timeout) @@ -264,10 +264,10 @@ def test_score_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response response = self.sift_client.score('12345') - mock_post.assert_called_with( + mock_get.assert_called_with( 'https://api.siftscience.com/v205/score/12345', params={'api_key': self.test_key}, headers=mock.ANY, @@ -286,10 +286,10 @@ def test_score_with_timeout_param_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response response = self.sift_client.score('12345', test_timeout) - mock_post.assert_called_with( + mock_get.assert_called_with( 'https://api.siftscience.com/v205/score/12345', params={'api_key': self.test_key}, headers=mock.ANY, @@ -310,7 +310,7 @@ def test_get_user_score_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_user_score('12345', test_timeout) mock_get.assert_called_with( @@ -336,7 +336,7 @@ def test_get_user_score_with_abuse_types_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_user_score('12345', abuse_types=['payment_abuse', 'content_abuse'], @@ -364,7 +364,7 @@ def test_rescore_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.rescore_user('12345', test_timeout) mock_post.assert_called_with( @@ -390,7 +390,7 @@ def test_rescore_user_with_abuse_types_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.rescore_user('12345', abuse_types=['payment_abuse', 'content_abuse'], @@ -417,7 +417,7 @@ def test_sync_score_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( event, @@ -471,7 +471,7 @@ def test_get_decisions(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_decisions(entity_type="user", @@ -517,7 +517,7 @@ def test_get_decisions_entity_session(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_decisions(entity_type="session", @@ -559,7 +559,7 @@ def test_apply_decision_to_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.apply_user_decision(user_id, apply_decision_request) data = json.dumps(apply_decision_request) @@ -646,7 +646,7 @@ def test_apply_decision_manual_review_no_analyst_fails(self): 'source': 'MANUAL_REVIEW', 'time': 1481569575 } - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response try: response = self.sift_client.apply_user_decision(user_id, apply_decision_request) @@ -664,7 +664,7 @@ def test_apply_decision_no_source_fails(self): 'decision_id': 'user_looks_ok_legacy', 'time': 1481569575 } - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response try: response = self.sift_client.apply_user_decision(user_id, apply_decision_request) @@ -683,7 +683,7 @@ def test_apply_decision_invalid_source_fails(self): 'source': 'INVALID_SOURCE', 'time': 1481569575 } - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response try: response = self.sift_client.apply_user_decision(user_id, apply_decision_request) @@ -718,7 +718,7 @@ def test_apply_decision_to_order_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.apply_order_decision(user_id, order_id, apply_decision_request) data = json.dumps(apply_decision_request) @@ -754,7 +754,7 @@ def test_apply_decision_to_session_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.apply_session_decision(user_id, session_id, apply_decision_request) data = json.dumps(apply_decision_request) @@ -790,7 +790,7 @@ def test_apply_decision_to_content_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.apply_content_decision(user_id, content_id, apply_decision_request) data = json.dumps(apply_decision_request) @@ -809,7 +809,7 @@ def test_label_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.label(user_id, valid_label_properties()) properties = { @@ -837,7 +837,7 @@ def test_label_user_with_timeout_param_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.label( user_id, valid_label_properties(), test_timeout) @@ -862,7 +862,7 @@ def test_unlabel_user_ok(self): user_id = '54321' mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch('requests.delete') as mock_delete: + with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id, abuse_type='account_abuse') mock_delete.assert_called_with( @@ -885,7 +885,7 @@ def test_unicode_string_parameter_support(self): user_id = u'23056' - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response assert(self.sift_client.track( u'$transaction', @@ -893,8 +893,8 @@ def test_unicode_string_parameter_support(self): assert(self.sift_client.label( user_id, valid_label_properties())) - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response assert(self.sift_client.score( user_id, abuse_types=[u'payment_abuse', 'content_abuse'])) @@ -902,7 +902,7 @@ def test_unlabel_user_with_special_chars_ok(self): user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch('requests.delete') as mock_delete: + with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id) mock_delete.assert_called_with( @@ -920,7 +920,7 @@ def test_label_user__with_special_chars_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.label( user_id, valid_label_properties()) @@ -951,10 +951,10 @@ def test_score__with_special_user_id_chars_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response response = self.sift_client.score(user_id, abuse_types=['legacy']) - mock_post.assert_called_with( + mock_get.assert_called_with( 'https://api.siftscience.com/v205/score/%s' % urllib.quote(user_id), params={'api_key': self.test_key, 'abuse_types': 'legacy'}, headers=mock.ANY, @@ -968,7 +968,7 @@ def test_score__with_special_user_id_chars_ok(self): def test_exception_during_track_call(self): warnings.simplefilter("always") - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) try: @@ -979,7 +979,7 @@ def test_exception_during_track_call(self): def test_exception_during_score_call(self): warnings.simplefilter("always") - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) try: @@ -989,7 +989,7 @@ def test_exception_during_score_call(self): def test_exception_during_unlabel_call(self): warnings.simplefilter("always") - with mock.patch('requests.delete') as mock_delete: + with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) try: @@ -1006,7 +1006,7 @@ def test_return_actions_on_track(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( @@ -1036,7 +1036,7 @@ def test_get_workflow_status(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_workflow_status('4zxwibludiaaa', timeout=3) @@ -1055,7 +1055,7 @@ def test_get_user_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_user_decisions('example_user') @@ -1074,7 +1074,7 @@ def test_get_order_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_order_decisions('example_order') @@ -1094,7 +1094,7 @@ def test_get_session_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_session_decisions('example_user','example_session') @@ -1113,7 +1113,7 @@ def test_get_content_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_content_decisions('example_user', 'example_content') @@ -1125,6 +1125,22 @@ def test_get_content_decisions(self): assert(response.is_ok()) assert(response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') + def test_provided_session(self): + session = mock.Mock() + client = sift.Client(api_key=self.test_key, account_id=self.account_id, session=session) + + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + session.post.return_value = mock_response + + event = '$transaction' + client.track(event, valid_transaction_properties()) + session.post.assert_called_once() + + def main(): unittest.main() diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index be7205b..073710c 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -117,7 +117,7 @@ def test_event_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track(event, valid_transaction_properties()) mock_post.assert_called_with( @@ -139,7 +139,7 @@ def test_event_with_timeout_param_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client_v204.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client_v204.track( event, valid_transaction_properties(), timeout=test_timeout, version='203') @@ -160,10 +160,10 @@ def test_score_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client_v204.session, 'get') as mock_get: + mock_get.return_value = mock_response response = self.sift_client_v204.score('12345', version='203') - mock_post.assert_called_with( + mock_get.assert_called_with( 'https://api.siftscience.com/v203/score/12345', params={'api_key': self.test_key}, headers=mock.ANY, @@ -180,10 +180,10 @@ def test_score_with_timeout_param_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response response = self.sift_client.score('12345', test_timeout) - mock_post.assert_called_with( + mock_get.assert_called_with( 'https://api.siftscience.com/v203/score/12345', params={'api_key': self.test_key}, headers=mock.ANY, @@ -201,7 +201,7 @@ def test_sync_score_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( event, valid_transaction_properties(), return_score=True) @@ -224,7 +224,7 @@ def test_label_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.label(user_id, valid_label_properties()) properties = { @@ -252,7 +252,7 @@ def test_label_user_with_timeout_param_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client_v204.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client_v204.label( user_id, valid_label_properties(), test_timeout, version='203') @@ -277,7 +277,7 @@ def test_unlabel_user_ok(self): user_id = '54321' mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch('requests.delete') as mock_delete: + with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id) mock_delete.assert_called_with( @@ -300,7 +300,7 @@ def test_unicode_string_parameter_support(self): user_id = u'23056' - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response assert( self.sift_client.track( @@ -310,15 +310,15 @@ def test_unicode_string_parameter_support(self): self.sift_client.label( user_id, valid_label_properties())) - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response assert(self.sift_client.score(user_id)) def test_unlabel_user_with_special_chars_ok(self): user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch('requests.delete') as mock_delete: + with mock.patch.object(self.sift_client_v204.session, 'delete') as mock_delete: mock_delete.return_value = mock_response response = self.sift_client_v204.unlabel(user_id, version='203') mock_delete.assert_called_with( @@ -336,7 +336,7 @@ def test_label_user__with_special_chars_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.label( user_id, valid_label_properties()) @@ -367,10 +367,10 @@ def test_score__with_special_user_id_chars_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.get') as mock_post: - mock_post.return_value = mock_response + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response response = self.sift_client.score(user_id) - mock_post.assert_called_with( + mock_get.assert_called_with( 'https://api.siftscience.com/v203/score/%s' % urllib.quote(user_id), params={'api_key': self.test_key}, headers=mock.ANY, @@ -382,7 +382,7 @@ def test_score__with_special_user_id_chars_ok(self): def test_exception_during_track_call(self): warnings.simplefilter("always") - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) try: @@ -393,7 +393,7 @@ def test_exception_during_track_call(self): def test_exception_during_score_call(self): warnings.simplefilter("always") - with mock.patch('requests.get') as mock_get: + with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) try: @@ -403,7 +403,7 @@ def test_exception_during_score_call(self): def test_exception_during_unlabel_call(self): warnings.simplefilter("always") - with mock.patch('requests.delete') as mock_delete: + with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) try: @@ -420,7 +420,7 @@ def test_return_actions_on_track(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch('requests.post') as mock_post: + with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( From da3b4603b46b12735b388edc98eb4ed37c1f196e Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Fri, 4 Jan 2019 14:50:10 -0800 Subject: [PATCH 041/112] Fix url encoding for all endpoints --- sift/client.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/sift/client.py b/sift/client.py index 2337e53..47e87d9 100644 --- a/sift/client.py +++ b/sift/client.py @@ -18,6 +18,10 @@ API3_URL = 'https://api3.siftscience.com' DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] +def _quote_path(s): + # by default, urllib.quote doesn't escape forward slash; pass the + # optional arg to override this + return urllib.quote(s, '') class Client(object): @@ -741,40 +745,48 @@ def _event_url(self, version): return self.url + '/v%s/events' % version def _score_url(self, user_id, version): - return self.url + '/v%s/score/%s' % (version, urllib.quote(user_id)) + return self.url + '/v%s/score/%s' % (version, _quote_path(user_id)) def _user_score_url(self, user_id, version): return self.url + '/v%s/users/%s/score' % (version, urllib.quote(user_id)) def _label_url(self, user_id, version): - return self.url + '/v%s/users/%s/labels' % (version, urllib.quote(user_id)) + return self.url + '/v%s/users/%s/labels' % (version, _quote_path(user_id)) def _workflow_status_url(self, account_id, run_id): - return API3_URL + '/v3/accounts/%s/workflows/runs/%s' % (account_id, run_id) + return (API3_URL + '/v3/accounts/%s/workflows/runs/%s' % + (_quote_path(account_id), _quote_path(run_id))) def _get_decisions_url(self, account_id): - return API3_URL + '/v3/accounts/%s/decisions' % (account_id) + return API3_URL + '/v3/accounts/%s/decisions' % (_quote_path(account_id),) def _user_decisions_url(self, account_id, user_id): - return API3_URL + '/v3/accounts/%s/users/%s/decisions' % (account_id, user_id) + return (API3_URL + '/v3/accounts/%s/users/%s/decisions' % + (_quote_path(account_id), _quote_path(user_id))) def _order_decisions_url(self, account_id, order_id): - return API3_URL + '/v3/accounts/%s/orders/%s/decisions' % (account_id, order_id) + return (API3_URL + '/v3/accounts/%s/orders/%s/decisions' % + (_quote_path(account_id), _quote_path(order_id))) def _session_decisions_url(self, account_id, user_id, session_id): - return API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % (account_id, user_id, session_id) + return (API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % + (_quote_path(account_id), _quote_path(user_id), _quote_path(session_id))) def _content_decisions_url(self, account_id, user_id, content_id): - return API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (account_id, user_id, content_id) + return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % + (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) def _order_apply_decisions_url(self, account_id, user_id, order_id): - return API3_URL + '/v3/accounts/%s/users/%s/orders/%s/decisions' % (account_id, user_id, order_id) + return (API3_URL + '/v3/accounts/%s/users/%s/orders/%s/decisions' % + (_quote_path(account_id), _quote_path(user_id), _quote_path(order_id))) def _session_apply_decisions_url(self, account_id, user_id, session_id): - return API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % (account_id, user_id, session_id) + return (API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % + (_quote_path(account_id), _quote_path(user_id), _quote_path(session_id))) def _content_apply_decisions_url(self, account_id, user_id, content_id): - return API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (account_id, user_id, content_id) + return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % + (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) class Response(object): From 3c1673f91736f0c4b73d081e7c2dfeeb449f461d Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Mon, 29 Oct 2018 11:37:01 -0700 Subject: [PATCH 042/112] Remove support for Python 2.6 --- .travis.yml | 1 - setup.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f9cf832..98c2bd6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - "2.6" - "2.7" - "3.4" # command to install dependencies diff --git a/setup.py b/setup.py index 7701bf5..5ac353b 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ description='Python bindings for Sift Science\'s API', version=version_mod.VERSION, url='https://siftscience.com', + python_requires=">=2.7", author='Sift Science', author_email='support@siftscience.com', From 8f871e2c32fc5cb9b09a09c28a1cb77cbe24e77e Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Fri, 26 Oct 2018 17:29:00 -0700 Subject: [PATCH 043/112] PEP-8 and exception changes * Replace ApiException with TypeError and ValueError for method arguments with the wrong type/value. * Assorted PEP-8 changes --- sift/client.py | 135 +++++------ tests/test_client.py | 467 +++++++++++++++++++++----------------- tests/test_client_v203.py | 62 +++-- 3 files changed, 350 insertions(+), 314 deletions(-) diff --git a/sift/client.py b/sift/client.py index 47e87d9..55b0bb8 100644 --- a/sift/client.py +++ b/sift/client.py @@ -8,8 +8,10 @@ import sys if sys.version_info[0] < 3: import urllib + _UNICODE_STRING = basestring else: import urllib.parse as urllib + _UNICODE_STRING = str import sift import sift.version @@ -18,11 +20,13 @@ API3_URL = 'https://api3.siftscience.com' DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] + def _quote_path(s): # by default, urllib.quote doesn't escape forward slash; pass the # optional arg to override this return urllib.quote(s, '') + class Client(object): def __init__( @@ -53,14 +57,12 @@ def __init__( the latest version ('205'). """ - if not isinstance(api_url, str) or len(api_url.strip()) == 0: - raise ApiException("api_url must be a string") + _assert_non_empty_unicode(api_url, 'api_url') if api_key is None: api_key = sift.api_key - if not isinstance(api_key, str) or len(api_key.strip()) == 0: - raise ApiException("valid api_key is required") + _assert_non_empty_unicode(api_key, 'api_key') self.session = session or requests.Session() self.api_key = api_key @@ -68,11 +70,6 @@ def __init__( self.timeout = timeout self.account_id = account_id or sift.account_id self.version = version - if sys.version_info[0] < 3: - self.UNICODE_STRING = basestring - else: - self.UNICODE_STRING = str - def track( self, @@ -124,11 +121,8 @@ def track( raises an ApiException. """ - if not isinstance(event, self.UNICODE_STRING) or len(event.strip()) == 0: - raise ApiException("event must be a string") - - if not isinstance(properties, dict) or len(properties) == 0: - raise ApiException("properties dictionary may not be empty") + _assert_non_empty_unicode(event, 'event') + _assert_non_empty_dict(properties, 'properties') headers = {'Content-type': 'application/json', 'Accept': '*/*', @@ -172,7 +166,6 @@ def track( except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def score(self, user_id, timeout=None, abuse_types=None, version=None): """Retrieves a user's fraud score from the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/score_api.html @@ -194,8 +187,7 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): A sift.client.Response object if the score call succeeded, or raises an ApiException. """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') if timeout is None: timeout = self.timeout @@ -218,7 +210,6 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def get_user_score(self, user_id, timeout=None, abuse_types=None): """Fetches the latest score(s) computed for the specified user and abuse types from the Sift Science API. As opposed to client.score() and client.rescore_user(), this *does not* compute a new score for the user; it @@ -240,8 +231,7 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None): A sift.client.Response object if the score call succeeded, or raises an ApiException. """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') if timeout is None: timeout = self.timeout @@ -261,7 +251,6 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def rescore_user(self, user_id, timeout=None, abuse_types=None): """Rescores the specified user for the specified abuse types and returns the resulting score(s). This call is blocking. See https://siftscience.com/developers/docs/python/score-api/rescore for more details. @@ -280,8 +269,7 @@ def rescore_user(self, user_id, timeout=None, abuse_types=None): A sift.client.Response object if the score call succeeded, or raises an ApiException. """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') if timeout is None: timeout = self.timeout @@ -301,7 +289,6 @@ def rescore_user(self, user_id, timeout=None, abuse_types=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def label(self, user_id, properties, timeout=None, version=None): """Labels a user as either good or bad through the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html @@ -321,8 +308,7 @@ def label(self, user_id, properties, timeout=None, version=None): A sift.client.Response object if the label call succeeded, otherwise raises an ApiException. """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') if version is None: version = self.version @@ -334,7 +320,6 @@ def label(self, user_id, properties, timeout=None, version=None): timeout=timeout, version=version) - def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): """unlabels a user through the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html @@ -355,8 +340,7 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): A sift.client.Response object if the unlabel call succeeded, otherwise raises an ApiException. """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') if timeout is None: timeout = self.timeout @@ -381,7 +365,6 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def get_workflow_status(self, run_id, timeout=None): """Gets the status of a workflow run. @@ -393,8 +376,7 @@ def get_workflow_status(self, run_id, timeout=None): Otherwise, raises an ApiException. """ - if not isinstance(run_id, self.UNICODE_STRING) or len(run_id.strip()) == 0: - raise ApiException("run_id must be a string") + _assert_non_empty_unicode(run_id, 'run_id') if timeout is None: timeout = self.timeout @@ -428,9 +410,9 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No params = {} - if not isinstance(entity_type, self.UNICODE_STRING) or len(entity_type.strip()) == 0 \ - or entity_type.lower() not in ['user', 'order', 'session', 'content']: - raise ApiException("entity_type must be one of {user, order, session, content}") + _assert_non_empty_unicode(entity_type, 'entity_type') + if entity_type.lower() not in ['user', 'order', 'session', 'content']: + raise ValueError("entity_type must be one of {user, order, session, content}") params['entity_type'] = entity_type @@ -502,10 +484,8 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): if timeout is None: timeout = self.timeout - - if order_id is None or not isinstance(order_id, self.UNICODE_STRING) or \ - len(order_id.strip()) == 0: - raise ApiException("order_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_unicode(order_id, 'order_id') self._validate_apply_decision_request(properties, user_id) @@ -523,23 +503,23 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): raise ApiException(str(e)) def _validate_apply_decision_request(self, properties, user_id): - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') - if not isinstance(properties, dict) or len(properties) == 0: - raise ApiException("properties dictionary may not be empty") + if not isinstance(properties, dict): + raise TypeError("properties must be a dict") + elif not properties: + raise ValueError("properties dictionary may not be empty") source = properties.get('source') - if not isinstance(source, self.UNICODE_STRING) or len(source.strip()) == 0 or source not in DECISION_SOURCES: - raise ApiException("decision 'source' must be one of [%s]" % ", ".join(DECISION_SOURCES)) + _assert_non_empty_unicode(source, 'source', error_cls=ValueError) + if source not in DECISION_SOURCES: + raise ValueError("decision 'source' must be one of [{0}]".format(", ".join(DECISION_SOURCES))) properties.update({'source': source.upper()}) - if source == 'MANUAL_REVIEW' and \ - ('analyst' not in properties or len(properties.get('analyst')) == 0): - raise ApiException("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") - + if source == 'MANUAL_REVIEW' and not properties.get('analyst', None): + raise ValueError("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") def get_user_decisions(self, user_id, timeout=None): """Gets the decisions for a user. @@ -552,8 +532,7 @@ def get_user_decisions(self, user_id, timeout=None): Otherwise, raises an ApiException. """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') if timeout is None: timeout = self.timeout @@ -568,7 +547,6 @@ def get_user_decisions(self, user_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def get_order_decisions(self, order_id, timeout=None): """Gets the decisions for an order. @@ -580,8 +558,7 @@ def get_order_decisions(self, order_id, timeout=None): Otherwise, raises an ApiException. """ - if not isinstance(order_id, self.UNICODE_STRING) or len(order_id.strip()) == 0: - raise ApiException("order_id must be a string") + _assert_non_empty_unicode(order_id, 'order_id') if timeout is None: timeout = self.timeout @@ -596,7 +573,6 @@ def get_order_decisions(self, order_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def get_content_decisions(self, user_id, content_id, timeout=None): """Gets the decisions for a piece of content. @@ -609,11 +585,8 @@ def get_content_decisions(self, user_id, content_id, timeout=None): Otherwise, raises an ApiException. """ - if not isinstance(content_id, self.UNICODE_STRING) or len(content_id.strip()) == 0: - raise ApiException("content_id must be a string") - - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") + _assert_non_empty_unicode(content_id, 'content_id') + _assert_non_empty_unicode(user_id, 'user_id') if timeout is None: timeout = self.timeout @@ -640,10 +613,8 @@ def get_session_decisions(self, user_id, session_id, timeout=None): Otherwise, raises an ApiException. """ - if not isinstance(user_id, self.UNICODE_STRING) or len(user_id.strip()) == 0: - raise ApiException("user_id must be a string") - if not isinstance(session_id, self.UNICODE_STRING) or len(session_id.strip()) == 0: - raise ApiException("session_id must be a string") + _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_unicode(session_id, 'session_id') if timeout is None: timeout = self.timeout @@ -677,10 +648,7 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): if timeout is None: timeout = self.timeout - - if session_id is None or not isinstance(session_id, self.UNICODE_STRING) or \ - len(session_id.strip()) == 0: - raise ApiException("session_id must be a string") + _assert_non_empty_unicode(session_id, 'session_id') self._validate_apply_decision_request(properties, user_id) @@ -697,7 +665,6 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def apply_content_decision(self, user_id, content_id, properties, timeout=None): """Apply decision to content @@ -717,10 +684,7 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): if timeout is None: timeout = self.timeout - - if content_id is None or not isinstance(content_id, self.UNICODE_STRING) or \ - len(content_id.strip()) == 0: - raise ApiException("content_id must be a string") + _assert_non_empty_unicode(content_id, 'content_id') self._validate_apply_decision_request(properties, user_id) @@ -737,7 +701,6 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e)) - def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -788,6 +751,7 @@ def _content_apply_decisions_url(self, account_id, user_id, content_id): return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) + class Response(object): HTTP_CODES_WITHOUT_BODY = [204, 304] @@ -818,7 +782,7 @@ def __init__(self, http_response): not_json_warning = "Failed to parse json response from {}. HTTP status code: {}.".format(self.url, self.http_status_code) raise ApiException(not_json_warning) finally: - if (int(self.http_status_code) < 200 or int(self.http_status_code) >= 300): + if int(self.http_status_code) < 200 or int(self.http_status_code) >= 300: non_2xx_warning = "{} returned non-2XX http status code {}".format(self.url, self.http_status_code) if self.api_error_message: non_2xx_warning += " with error message: {}".format(self.api_error_message) @@ -844,3 +808,24 @@ def is_ok(self): class ApiException(Exception): def __init__(self, *args, **kwargs): Exception.__init__(self, *args, **kwargs) + + + +def _assert_non_empty_unicode(val, name, error_cls=None): + error = False + if not isinstance(val, _UNICODE_STRING): + error_cls = error_cls or TypeError + error = True + elif not val: + error_cls = error_cls or ValueError + error = True + + if error: + raise error_cls('{0} must be a non-empty string'.format(name)) + + +def _assert_non_empty_dict(val, name): + if not isinstance(val, dict): + raise TypeError('{0} must be a non-empty dict'.format(name)) + elif not val: + raise ValueError('{0} must be a non-empty dict'.format(name)) diff --git a/tests/test_client.py b/tests/test_client.py index 412281d..5042a06 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,6 +41,7 @@ def valid_label_properties(): '$analyst': 'super.sleuth@example.com' } + def score_response_json(): return """{ "status": 0, @@ -170,7 +171,7 @@ def setUp(self): def test_global_api_key(self): # test for error if global key is undefined - self.assertRaises(sift.client.ApiException, sift.Client) + self.assertRaises(TypeError, sift.Client) sift.api_key = "a_test_global_api_key" local_api_key = "a_test_local_api_key" @@ -187,32 +188,32 @@ def test_global_api_key(self): assert(client2.api_key == sift.api_key) def test_constructor_requires_valid_api_key(self): - self.assertRaises(sift.client.ApiException, sift.Client, None) - self.assertRaises(sift.client.ApiException, sift.Client, '') + self.assertRaises(TypeError, sift.Client, None) + self.assertRaises(ValueError, sift.Client, '') def test_constructor_invalid_api_url(self): - self.assertRaises(sift.client.ApiException, sift.Client, self.test_key, None) - self.assertRaises(sift.client.ApiException, sift.Client, self.test_key, '') + self.assertRaises(TypeError, sift.Client, self.test_key, None) + self.assertRaises(ValueError, sift.Client, self.test_key, '') def test_constructor_api_key(self): client = sift.Client(self.test_key) self.assertEqual(client.api_key, self.test_key) def test_track_requires_valid_event(self): - self.assertRaises(sift.client.ApiException, self.sift_client.track, None, {}) - self.assertRaises(sift.client.ApiException, self.sift_client.track, '', {}) - self.assertRaises(sift.client.ApiException, self.sift_client.track, 42, {}) + self.assertRaises(TypeError, self.sift_client.track, None, {}) + self.assertRaises(ValueError, self.sift_client.track, '', {}) + self.assertRaises(TypeError, self.sift_client.track, 42, {}) def test_track_requires_properties(self): event = 'custom_event' - self.assertRaises(sift.client.ApiException, self.sift_client.track, event, None) - self.assertRaises(sift.client.ApiException, self.sift_client.track, event, 42) - self.assertRaises(sift.client.ApiException, self.sift_client.track, event, {}) + self.assertRaises(TypeError, self.sift_client.track, event, None) + self.assertRaises(TypeError, self.sift_client.track, event, 42) + self.assertRaises(ValueError, self.sift_client.track, event, {}) def test_score_requires_user_id(self): - self.assertRaises(sift.client.ApiException, self.sift_client.score, None) - self.assertRaises(sift.client.ApiException, self.sift_client.score, '') - self.assertRaises(sift.client.ApiException, self.sift_client.score, 42) + self.assertRaises(TypeError, self.sift_client.score, None) + self.assertRaises(ValueError, self.sift_client.score, '') + self.assertRaises(TypeError, self.sift_client.score, 42) def test_event_ok(self): event = '$transaction' @@ -230,7 +231,7 @@ def test_event_ok(self): headers=mock.ANY, timeout=mock.ANY, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -253,7 +254,7 @@ def test_event_with_timeout_param_ok(self): headers=mock.ANY, timeout=test_timeout, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -272,7 +273,7 @@ def test_score_ok(self): params={'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.85) @@ -294,7 +295,7 @@ def test_score_with_timeout_param_ok(self): params={'api_key': self.test_key}, headers=mock.ANY, timeout=test_timeout) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.85) @@ -318,7 +319,7 @@ def test_get_user_score_ok(self): params={'api_key': self.test_key}, headers=mock.ANY, timeout=test_timeout) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['entity_id'] == '12345') @@ -326,7 +327,6 @@ def test_get_user_score_ok(self): assert(response.body['scores']['payment_abuse']['score'] == 0.97) assert('latest_decisions' in response.body) - def test_get_user_score_with_abuse_types_ok(self): """Test the GET /{version}/users/{userId}/score?abuse_types=... API, i.e. client.get_user_score() """ @@ -346,7 +346,7 @@ def test_get_user_score_with_abuse_types_ok(self): params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, timeout=test_timeout) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['entity_id'] == '12345') @@ -354,7 +354,6 @@ def test_get_user_score_with_abuse_types_ok(self): assert(response.body['scores']['payment_abuse']['score'] == 0.97) assert('latest_decisions' in response.body) - def test_rescore_user_ok(self): """Test the POST /{version}/users/{userId}/score API, i.e. client.rescore_user() """ @@ -372,7 +371,7 @@ def test_rescore_user_ok(self): params={'api_key': self.test_key}, headers=mock.ANY, timeout=test_timeout) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['entity_id'] == '12345') @@ -380,7 +379,6 @@ def test_rescore_user_ok(self): assert(response.body['scores']['payment_abuse']['score'] == 0.97) assert('latest_decisions' in response.body) - def test_rescore_user_with_abuse_types_ok(self): """Test the POST /{version}/users/{userId}/score?abuse_types=... API, i.e. client.rescore_user() """ @@ -400,7 +398,7 @@ def test_rescore_user_with_abuse_types_ok(self): params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, timeout=test_timeout) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['entity_id'] == '12345') @@ -408,7 +406,6 @@ def test_rescore_user_with_abuse_types_ok(self): assert(response.body['scores']['payment_abuse']['score'] == 0.97) assert('latest_decisions' in response.body) - def test_sync_score_ok(self): event = '$transaction' mock_response = mock.Mock() @@ -430,7 +427,7 @@ def test_sync_score_ok(self): headers=mock.ANY, timeout=mock.ANY, params={'return_score': 'true', 'abuse_types': 'payment_abuse,content_abuse,legacy'}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -439,33 +436,33 @@ def test_sync_score_ok(self): assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) def test_get_decisions_fails(self): - try: + with self.assertRaises(ValueError): self.sift_client.get_decisions('usr') - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_get_decisions(self): mock_response = mock.Mock() - get_decisions_response_json = \ - '{' \ - '"data": [' \ - '{' \ - '"id": "block_user",' \ - '"name" : "Block user",' \ - '"description": "user has a different billing and shipping addresses",' \ - '"entity_type": "user",' \ - '"abuse_type": "legacy",' \ - '"category": "block",' \ - '"webhook_url": "http://web.hook",' \ - '"created_at": "1468005577348",' \ - '"created_by": "admin@biz.com",' \ - '"updated_at": "1469229177756",' \ - '"updated_by": "analyst@biz.com"' \ - '}' \ - '],' \ - '"has_more": "true",' \ - '"next_ref": "v3/accounts/accountId/decisions"' \ - '}' + + get_decisions_response_json = """ + { + "data": [ + { + "id": "block_user", + "name": "Block user", + "description": "user has a different billing and shipping addresses", + "entity_type": "user", + "abuse_type": "legacy", + "category": "block", + "webhook_url": "http://web.hook", + "created_at": "1468005577348", + "created_by": "admin@biz.com", + "updated_at": "1469229177756", + "updated_by": "analyst@biz.com" + } + ], + "has_more": "true", + "next_ref": "v3/accounts/accountId/decisions" + } + """ mock_response.content = get_decisions_response_json mock_response.json.return_value = json.loads(mock_response.content) @@ -483,35 +480,36 @@ def test_get_decisions(self): 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', headers=mock.ANY, auth=mock.ANY, - params={'entity_type':'user','limit':10,'abuse_types':'legacy,payment_abuse'}, + params={'entity_type': 'user', 'limit': 10, 'abuse_types': 'legacy,payment_abuse'}, timeout=3) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.body['data'][0]['id'] == 'block_user') def test_get_decisions_entity_session(self): mock_response = mock.Mock() - get_decisions_response_json = \ - '{' \ - '"data": [' \ - '{' \ - '"id": "block_session",' \ - '"name" : "Block session",' \ - '"description": "session has problems",' \ - '"entity_type": "session",' \ - '"abuse_type": "legacy",' \ - '"category": "block",' \ - '"webhook_url": "http://web.hook",' \ - '"created_at": "1468005577348",' \ - '"created_by": "admin@biz.com",' \ - '"updated_at": "1469229177756",' \ - '"updated_by": "analyst@biz.com"' \ - '}' \ - '],' \ - '"has_more": "true",' \ - '"next_ref": "v3/accounts/accountId/decisions"' \ - '}' + get_decisions_response_json = """ + { + "data": [ + { + "id": "block_session", + "name": "Block session", + "description": "session has problems", + "entity_type": "session", + "abuse_type": "legacy", + "category": "block", + "webhook_url": "http://web.hook", + "created_at": "1468005577348", + "created_by": "admin@biz.com", + "updated_at": "1469229177756", + "updated_by": "analyst@biz.com" + } + ], + "has_more": "true", + "next_ref": "v3/accounts/accountId/decisions" + } + """ mock_response.content = get_decisions_response_json mock_response.json.return_value = json.loads(mock_response.content) @@ -529,10 +527,10 @@ def test_get_decisions_entity_session(self): 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', headers=mock.ANY, auth=mock.ANY, - params={'entity_type':'session','limit':10,'abuse_types':'account_takeover'}, + params={'entity_type': 'session', 'limit': 10, 'abuse_types': 'account_takeover'}, timeout=3) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.body['data'][0]['id'] == 'block_session') @@ -546,15 +544,18 @@ def test_apply_decision_to_user_ok(self): 'description': 'called user and verified account', 'time': 1481569575 } - apply_decision_response_json = '{' \ - '"entity": {' \ - '"id": "54321",' \ - '"type": "user"' \ - '},' \ - '"decision": {' \ - '"id":"user_looks_ok_legacy"' \ - '},' \ - '"time":"1481569575"}' + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "user" + }, + "decision": { + "id": "user_looks_ok_legacy" + }, + "time": "1481569575" + } + """ mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 @@ -567,7 +568,7 @@ def test_apply_decision_to_user_ok(self): 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.body['entity']['type'] == 'user') assert(response.http_status_code == 200) assert(response.is_ok()) @@ -579,34 +580,24 @@ def test_validate_no_user_id_string_fails(self): 'analyst': 'analyst@biz.com', 'description': 'called user and verified account', } - try: + with self.assertRaises(TypeError): self.sift_client._validate_apply_decision_request(apply_decision_request, 123) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_apply_decision_to_order_fails_with_no_order_id(self): - try: + with self.assertRaises(TypeError): self.sift_client.apply_order_decision("user_id", None, {}) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_apply_decision_to_session_fails_with_no_session_id(self): - try: + with self.assertRaises(TypeError): self.sift_client.apply_session_decision("user_id", None, {}) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_get_session_decisions_fails_with_no_session_id(self): - try: + with self.assertRaises(TypeError): self.sift_client.get_session_decisions("user_id", None) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_apply_decision_to_content_fails_with_no_content_id(self): - try: + with self.assertRaises(TypeError): self.sift_client.apply_content_decision("user_id", None, {}) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_validate_apply_decision_request_no_analyst_fails(self): apply_decision_request = { @@ -615,10 +606,8 @@ def test_validate_apply_decision_request_no_analyst_fails(self): 'time': 1481569575 } - try: + with self.assertRaises(ValueError): self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_validate_apply_decision_request_no_source_fails(self): apply_decision_request = { @@ -626,73 +615,44 @@ def test_validate_apply_decision_request_no_source_fails(self): 'time': 1481569575 } - try: + with self.assertRaises(ValueError): self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_validate_empty_apply_decision_request_fails(self): apply_decision_request = {} - try: + with self.assertRaises(ValueError): self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) def test_apply_decision_manual_review_no_analyst_fails(self): user_id = '54321' - mock_response = mock.Mock() apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', 'source': 'MANUAL_REVIEW', 'time': 1481569575 } - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - try: - response = self.sift_client.apply_user_decision(user_id, apply_decision_request) - data = json.dumps(apply_decision_request) - mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + + with self.assertRaises(ValueError): + self.sift_client.apply_user_decision(user_id, apply_decision_request) def test_apply_decision_no_source_fails(self): user_id = '54321' - mock_response = mock.Mock() apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', 'time': 1481569575 } - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - try: - response = self.sift_client.apply_user_decision(user_id, apply_decision_request) - data = json.dumps(apply_decision_request) - mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + + with self.assertRaises(ValueError): + self.sift_client.apply_user_decision(user_id, apply_decision_request) def test_apply_decision_invalid_source_fails(self): user_id = '54321' - mock_response = mock.Mock() apply_decision_request = { 'decision_id': 'user_looks_ok_legacy', 'source': 'INVALID_SOURCE', 'time': 1481569575 } - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - try: - response = self.sift_client.apply_user_decision(user_id, apply_decision_request) - data = json.dumps(apply_decision_request) - mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + + self.assertRaises(ValueError, self.sift_client.apply_user_decision, user_id, apply_decision_request) def test_apply_decision_to_order_ok(self): user_id = '54321' @@ -704,15 +664,18 @@ def test_apply_decision_to_order_ok(self): 'time': 1481569575 } - apply_decision_response_json = '{' \ - '"entity": {' \ - '"id": "54321",' \ - '"type": "order"' \ - '},' \ - '"decision": {' \ - '"id":"order_looks_bad_payment_abuse"' \ - '},' \ - '"time":"1481569575"}' + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "order" + }, + "decision": { + "id": "order_looks_bad_payment_abuse" + }, + "time": "1481569575" + } + """ mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) @@ -723,9 +686,9 @@ def test_apply_decision_to_order_ok(self): response = self.sift_client.apply_order_decision(user_id, order_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id,order_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id, order_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.http_status_code == 200) assert(response.body['entity']['type'] == 'order') @@ -740,15 +703,18 @@ def test_apply_decision_to_session_ok(self): 'time': 1481569575 } - apply_decision_response_json = '{' \ - '"entity": {' \ - '"id": "54321",' \ - '"type": "login"' \ - '},' \ - '"decision": {' \ - '"id":"session_looks_bad_ato"' \ - '},' \ - '"time":"1481569575"}' + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "login" + }, + "decision": { + "id": "session_looks_bad_ato" + }, + "time": "1481569575" + } + """ mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) @@ -759,9 +725,9 @@ def test_apply_decision_to_session_ok(self): response = self.sift_client.apply_session_decision(user_id, session_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id,session_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id, session_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.http_status_code == 200) assert(response.body['entity']['type'] == 'login') @@ -776,15 +742,18 @@ def test_apply_decision_to_content_ok(self): 'time': 1481569575 } - apply_decision_response_json = '{' \ - '"entity": {' \ - '"id": "54321",' \ - '"type": "create_content"' \ - '},' \ - '"decision": {' \ - '"id":"content_looks_bad_content_abuse"' \ - '},' \ - '"time":"1481569575"}' + apply_decision_response_json = """ + { + "entity": { + "id": "54321", + "type": "create_content" + }, + "decision": { + "id": "content_looks_bad_content_abuse" + }, + "time": "1481569575" + } + """ mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) @@ -795,9 +764,9 @@ def test_apply_decision_to_content_ok(self): response = self.sift_client.apply_content_decision(user_id, content_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id,content_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id, content_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.http_status_code == 200) assert(response.body['entity']['type'] == 'create_content') @@ -824,7 +793,7 @@ def test_label_user_ok(self): mock_post.assert_called_with( 'https://api.siftscience.com/v205/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=mock.ANY, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -853,7 +822,7 @@ def test_label_user_with_timeout_param_ok(self): mock_post.assert_called_with( 'https://api.siftscience.com/v205/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=test_timeout, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -870,7 +839,7 @@ def test_unlabel_user_ok(self): headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key, 'abuse_type': 'account_abuse'}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) def test_unicode_string_parameter_support(self): @@ -910,7 +879,7 @@ def test_unlabel_user_with_special_chars_ok(self): headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) def test_label_user__with_special_chars_ok(self): @@ -939,7 +908,7 @@ def test_label_user__with_special_chars_ok(self): headers=mock.ANY, timeout=mock.ANY, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -959,7 +928,7 @@ def test_score__with_special_user_id_chars_ok(self): params={'api_key': self.test_key, 'abuse_types': 'legacy'}, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.85) @@ -971,31 +940,24 @@ def test_exception_during_track_call(self): with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.track( - '$transaction', valid_transaction_properties()) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + with self.assertRaises(sift.client.ApiException): + self.sift_client.track('$transaction', valid_transaction_properties()) def test_exception_during_score_call(self): warnings.simplefilter("always") with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.score('Fred') - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + with self.assertRaises(sift.client.ApiException): + self.sift_client.score('Fred') def test_exception_during_unlabel_call(self): warnings.simplefilter("always") with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.unlabel('Fred') - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + with self.assertRaises(sift.client.ApiException): + self.sift_client.unlabel('Fred') def test_return_actions_on_track(self): event = '$transaction' @@ -1018,7 +980,7 @@ def test_return_actions_on_track(self): timeout=mock.ANY, params={'return_action': 'true'}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -1031,7 +993,46 @@ def test_return_actions_on_track(self): def test_get_workflow_status(self): mock_response = mock.Mock() - mock_response.content = '{"id":"4zxwibludiaaa","config":{"id":"5rrbr4iaaa","version":"1468367620871"},"config_display_name":"workflow config","abuse_types":["payment_abuse"],"state":"running","entity":{"id":"example_user","type":"user"},"history":[{"app":"decision","name":"decision","state":"running","config":{"decision_id":"user_decision"}},{"app":"event","name":"Event","state":"finished","config":{}},{"app":"user","name":"Entity","state":"finished","config":{}}]}' + mock_response.content = """ + { + "id": "4zxwibludiaaa", + "config": { + "id": "5rrbr4iaaa", + "version": "1468367620871" + }, + "config_display_name": "workflow config", + "abuse_types": [ + "payment_abuse" + ], + "state": "running", + "entity": { + "id": "example_user", + "type": "user" + }, + "history": [ + { + "app": "decision", + "name": "decision", + "state": "running", + "config": { + "decision_id": "user_decision" + } + }, + { + "app": "event", + "name": "Event", + "state": "finished", + "config": {} + }, + { + "app": "user", + "name": "Entity", + "state": "finished", + "config": {} + } + ] + } + """ mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -1044,13 +1045,25 @@ def test_get_workflow_status(self): 'https://api3.siftscience.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa', headers=mock.ANY, auth=mock.ANY, timeout=3) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.body['state'] == 'running') def test_get_user_decisions(self): mock_response = mock.Mock() - mock_response.content = '{"decisions":{"payment_abuse":{"decision":{"id":"user_decision"},"time":1468707128659,"webhook_succeeded":false}}}' + mock_response.content = """ + { + "decisions": { + "payment_abuse": { + "decision": { + "id": "user_decision" + }, + "time": 1468707128659, + "webhook_succeeded": false + } + } + } + """ mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -1063,13 +1076,32 @@ def test_get_user_decisions(self): 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') def test_get_order_decisions(self): mock_response = mock.Mock() - mock_response.content = '{"decisions":{"payment_abuse":{"decision":{"id":"decision7"},"time":1468599638005,"webhook_succeeded":false},"promotion_abuse":{"decision":{"id":"good_order"},"time":1468517407135,"webhook_succeeded":true}}}' + mock_response.content = """ + { + "decisions": { + "payment_abuse": { + "decision": { + "id": "decision7" + }, + "time": 1468599638005, + "webhook_succeeded": false + }, + "promotion_abuse": { + "decision": { + "id": "good_order" + }, + "time": 1468517407135, + "webhook_succeeded": true + } + } + } + """ mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -1082,14 +1114,26 @@ def test_get_order_decisions(self): 'https://api3.siftscience.com/v3/accounts/ACCT/orders/example_order/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') def test_get_session_decisions(self): mock_response = mock.Mock() - mock_response.content = '{"decisions":{"account_takeover": {"decision": {"id": "session_decision"},"time": 1461963839151,"webhook_succeeded": true}}}' + mock_response.content = """ + { + "decisions": { + "account_takeover": { + "decision": { + "id": "session_decision" + }, + "time": 1461963839151, + "webhook_succeeded": true + } + } + } + """ mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -1097,18 +1141,30 @@ def test_get_session_decisions(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_session_decisions('example_user','example_session') + response = self.sift_client.get_session_decisions('example_user', 'example_session') mock_get.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') def test_get_content_decisions(self): mock_response = mock.Mock() - mock_response.content = '{"decisions":{"content_abuse":{"decision":{"id":"content_looks_bad_content_abuse"},"time":1468517407135,"webhook_succeeded":true}}}' + mock_response.content = """ + { + "decisions": { + "content_abuse": { + "decision": { + "id": "content_looks_bad_content_abuse" + }, + "time": 1468517407135, + "webhook_succeeded": true + } + } + } + """ mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() @@ -1121,7 +1177,7 @@ def test_get_content_decisions(self): 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') @@ -1144,5 +1200,6 @@ def test_provided_session(self): def main(): unittest.main() + if __name__ == '__main__': main() diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index 073710c..7eeff45 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -95,20 +95,20 @@ def setUp(self): self.sift_client_v204 = sift.Client(self.test_key) def test_track_requires_valid_event(self): - self.assertRaises(sift.client.ApiException, self.sift_client.track, None, {}) - self.assertRaises(sift.client.ApiException, self.sift_client.track, '', {}) - self.assertRaises(sift.client.ApiException, self.sift_client_v204.track, 42, {'version':'203'}) + self.assertRaises(TypeError, self.sift_client.track, None, {}) + self.assertRaises(ValueError, self.sift_client.track, '', {}) + self.assertRaises(TypeError, self.sift_client_v204.track, 42, {'version': '203'}) def test_track_requires_properties(self): event = 'custom_event' - self.assertRaises(sift.client.ApiException, self.sift_client.track, event, None, {}) - self.assertRaises(sift.client.ApiException, self.sift_client_v204.track, event, 42, {'version':'203'}) - self.assertRaises(sift.client.ApiException, self.sift_client.track, event, {}) + self.assertRaises(TypeError, self.sift_client.track, event, None, {}) + self.assertRaises(TypeError, self.sift_client_v204.track, event, 42, {'version': '203'}) + self.assertRaises(ValueError, self.sift_client.track, event, {}) def test_score_requires_user_id(self): - self.assertRaises(sift.client.ApiException, self.sift_client_v204.score, None, {'version':'203'}) - self.assertRaises(sift.client.ApiException, self.sift_client.score, '', {}) - self.assertRaises(sift.client.ApiException, self.sift_client.score, 42, {}) + self.assertRaises(TypeError, self.sift_client_v204.score, None, {'version': '203'}) + self.assertRaises(ValueError, self.sift_client.score, '', {}) + self.assertRaises(TypeError, self.sift_client.score, 42, {}) def test_event_ok(self): event = '$transaction' @@ -126,7 +126,7 @@ def test_event_ok(self): headers=mock.ANY, timeout=mock.ANY, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -149,7 +149,7 @@ def test_event_with_timeout_param_ok(self): headers=mock.ANY, timeout=test_timeout, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -168,7 +168,7 @@ def test_score_ok(self): params={'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.55) @@ -188,7 +188,7 @@ def test_score_with_timeout_param_ok(self): params={'api_key': self.test_key}, headers=mock.ANY, timeout=test_timeout) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.55) @@ -211,7 +211,7 @@ def test_sync_score_ok(self): headers=mock.ANY, timeout=mock.ANY, params={'return_score': 'true'}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -239,7 +239,7 @@ def test_label_user_ok(self): mock_post.assert_called_with( 'https://api.siftscience.com/v203/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=mock.ANY, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -268,7 +268,7 @@ def test_label_user_with_timeout_param_ok(self): mock_post.assert_called_with( 'https://api.siftscience.com/v203/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=test_timeout, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -285,7 +285,7 @@ def test_unlabel_user_ok(self): headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) def test_unicode_string_parameter_support(self): @@ -326,7 +326,7 @@ def test_unlabel_user_with_special_chars_ok(self): headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) def test_label_user__with_special_chars_ok(self): @@ -355,7 +355,7 @@ def test_label_user__with_special_chars_ok(self): headers=mock.ANY, timeout=mock.ANY, params={}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") @@ -375,7 +375,7 @@ def test_score__with_special_user_id_chars_ok(self): params={'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") assert(response.body['score'] == 0.55) @@ -385,31 +385,25 @@ def test_exception_during_track_call(self): with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.track( - '$transaction', valid_transaction_properties()) - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + self.assertRaises( + sift.client.ApiException, self.sift_client.track, + '$transaction', valid_transaction_properties()) def test_exception_during_score_call(self): warnings.simplefilter("always") with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.score('Fred') - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + self.assertRaises( + sift.client.ApiException, self.sift_client.score, 'Fred') def test_exception_during_unlabel_call(self): warnings.simplefilter("always") with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) - try: - response = self.sift_client.unlabel('Fred') - except Exception as e: - assert(isinstance(e, sift.client.ApiException)) + self.assertRaises( + sift.client.ApiException, self.sift_client.unlabel, 'Fred') def test_return_actions_on_track(self): event = '$transaction' @@ -432,7 +426,7 @@ def test_return_actions_on_track(self): timeout=mock.ANY, params={'return_action': 'true'}) - assert(isinstance(response, sift.client.Response)) + self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_status == 0) assert(response.api_error_message == "OK") From 007e0c2f438e17f3037162111940e6fb91e8db00 Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Mon, 29 Oct 2018 11:47:11 -0700 Subject: [PATCH 044/112] Add parsed response to ApiException ApiException is currently an Exception with just a string that encapsulates either the error from Requests or an error from a 4xx or 5xx response. This change exposes the HTTP status code and fields in the JSON response as attributes on ApiException. --- sift/client.py | 119 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 40 deletions(-) diff --git a/sift/client.py b/sift/client.py index 55b0bb8..22345b7 100644 --- a/sift/client.py +++ b/sift/client.py @@ -164,7 +164,7 @@ def track( params=params) return Response(response) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), path) def score(self, user_id, timeout=None, abuse_types=None, version=None): """Retrieves a user's fraud score from the Sift Science API. @@ -200,15 +200,17 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): if abuse_types: params['abuse_types'] = ','.join(abuse_types) + url = self._score_url(user_id, version) + try: response = self.session.get( - self._score_url(user_id, version), + url, headers=headers, timeout=timeout, params=params) return Response(response) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def get_user_score(self, user_id, timeout=None, abuse_types=None): """Fetches the latest score(s) computed for the specified user and abuse types from the Sift Science API. @@ -236,6 +238,7 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None): if timeout is None: timeout = self.timeout + url = self._user_score_url(user_id, self.version) headers = {'User-Agent': self._user_agent()} params = {'api_key': self.api_key} if abuse_types: @@ -243,13 +246,13 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None): try: response = self.session.get( - self._user_score_url(user_id, self.version), + url, headers=headers, timeout=timeout, params=params) return Response(response) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def rescore_user(self, user_id, timeout=None, abuse_types=None): """Rescores the specified user for the specified abuse types and returns the resulting score(s). @@ -274,6 +277,7 @@ def rescore_user(self, user_id, timeout=None, abuse_types=None): if timeout is None: timeout = self.timeout + url = self._user_score_url(user_id, self.version) headers = {'User-Agent': self._user_agent()} params = {'api_key': self.api_key} if abuse_types: @@ -281,13 +285,13 @@ def rescore_user(self, user_id, timeout=None, abuse_types=None): try: response = self.session.post( - self._user_score_url(user_id, self.version), + url, headers=headers, timeout=timeout, params=params) return Response(response) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def label(self, user_id, properties, timeout=None, version=None): """Labels a user as either good or bad through the Sift Science API. @@ -348,22 +352,22 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): if version is None: version = self.version + url = self._label_url(user_id, version) headers = {'User-Agent': self._user_agent()} params = {'api_key': self.api_key} if abuse_type: params['abuse_type'] = abuse_type try: - response = self.session.delete( - self._label_url(user_id, version), + url, headers=headers, timeout=timeout, params=params) return Response(response) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def get_workflow_status(self, run_id, timeout=None): """Gets the status of a workflow run. @@ -378,18 +382,19 @@ def get_workflow_status(self, run_id, timeout=None): """ _assert_non_empty_unicode(run_id, 'run_id') + url = self._workflow_status_url(self.account_id, run_id) if timeout is None: timeout = self.timeout try: return Response(self.session.get( - self._workflow_status_url(self.account_id, run_id), + url, auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=None, timeout=None): """Get decisions available to customer @@ -425,13 +430,15 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No if abuse_types: params['abuse_types'] = abuse_types + url = self._get_decisions_url(self.account_id) + try: - return Response(self.session.get(self._get_decisions_url(self.account_id), params=params, + return Response(self.session.get(url, params=params, auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def apply_user_decision(self, user_id, properties, timeout=None): """Apply decision to user @@ -451,10 +458,11 @@ def apply_user_decision(self, user_id, properties, timeout=None): timeout = self.timeout self._validate_apply_decision_request(properties, user_id) + url = self._user_decisions_url(self.account_id, user_id) try: return Response(self.session.post( - self._user_decisions_url(self.account_id, user_id), + url, data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'Content-type': 'application/json', @@ -463,7 +471,7 @@ def apply_user_decision(self, user_id, properties, timeout=None): timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def apply_order_decision(self, user_id, order_id, properties, timeout=None): """Apply decision to order @@ -489,9 +497,11 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) + url = self._order_apply_decisions_url(self.account_id, user_id, order_id) + try: return Response(self.session.post( - self._order_apply_decisions_url(self.account_id, user_id, order_id), + url, data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'Content-type': 'application/json', @@ -500,7 +510,7 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def _validate_apply_decision_request(self, properties, user_id): _assert_non_empty_unicode(user_id, 'user_id') @@ -519,7 +529,7 @@ def _validate_apply_decision_request(self, properties, user_id): properties.update({'source': source.upper()}) if source == 'MANUAL_REVIEW' and not properties.get('analyst', None): - raise ValueError("must provide 'analyst' for decision 'source':'MANUAL_REVIEW'") + raise ValueError("must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'") def get_user_decisions(self, user_id, timeout=None): """Gets the decisions for a user. @@ -537,15 +547,17 @@ def get_user_decisions(self, user_id, timeout=None): if timeout is None: timeout = self.timeout + url = self._user_decisions_url(self.account_id, user_id) + try: return Response(self.session.get( - self._user_decisions_url(self.account_id, user_id), + url, auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def get_order_decisions(self, order_id, timeout=None): """Gets the decisions for an order. @@ -563,15 +575,17 @@ def get_order_decisions(self, order_id, timeout=None): if timeout is None: timeout = self.timeout + url = self._order_decisions_url(self.account_id, order_id) + try: return Response(self.session.get( - self._order_decisions_url(self.account_id, order_id), + url, auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def get_content_decisions(self, user_id, content_id, timeout=None): """Gets the decisions for a piece of content. @@ -591,15 +605,17 @@ def get_content_decisions(self, user_id, content_id, timeout=None): if timeout is None: timeout = self.timeout + url = self._content_decisions_url(self.account_id, user_id, content_id) + try: return Response(self.session.get( - self._content_decisions_url(self.account_id, user_id, content_id), + url, auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def get_session_decisions(self, user_id, session_id, timeout=None): """Gets the decisions for a user's session. @@ -619,15 +635,17 @@ def get_session_decisions(self, user_id, session_id, timeout=None): if timeout is None: timeout = self.timeout + url = self._session_decisions_url(self.account_id, user_id, session_id) + try: return Response(self.session.get( - self._session_decisions_url(self.account_id, user_id, session_id), + url, auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def apply_session_decision(self, user_id, session_id, properties, timeout=None): """Apply decision to session @@ -652,9 +670,11 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) + url = self._session_apply_decisions_url(self.account_id, user_id, session_id) + try: return Response(self.session.post( - self._session_apply_decisions_url(self.account_id, user_id, session_id), + url, data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'Content-type': 'application/json', @@ -663,7 +683,7 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def apply_content_decision(self, user_id, content_id, properties, timeout=None): """Apply decision to content @@ -688,9 +708,11 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) + url = self._content_apply_decisions_url(self.account_id, user_id, content_id) + try: return Response(self.session.post( - self._content_apply_decisions_url(self.account_id, user_id, content_id), + url, data=json.dumps(properties), auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'Content-type': 'application/json', @@ -699,7 +721,7 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): timeout=timeout)) except requests.exceptions.RequestException as e: - raise ApiException(str(e)) + raise ApiException(str(e), url) def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -779,14 +801,24 @@ def __init__(self, http_response): if 'request' in self.body.keys() and isinstance(self.body['request'], str): self.request = json.loads(self.body['request']) except ValueError: - not_json_warning = "Failed to parse json response from {}. HTTP status code: {}.".format(self.url, self.http_status_code) - raise ApiException(not_json_warning) + raise ApiException( + 'Failed to parse json response from {0}'.format(self.url), + url=self.url, + http_status_code=self.http_status_code, + body=self.body, + api_status=self.api_status, + api_error_message=self.api_error_message, + request=self.request) finally: if int(self.http_status_code) < 200 or int(self.http_status_code) >= 300: - non_2xx_warning = "{} returned non-2XX http status code {}".format(self.url, self.http_status_code) - if self.api_error_message: - non_2xx_warning += " with error message: {}".format(self.api_error_message) - raise ApiException(non_2xx_warning) + raise ApiException( + '{0} returned non-2XX http status code {1}'.format(self.url, self.http_status_code), + url=self.url, + http_status_code=self.http_status_code, + body=self.body, + api_status=self.api_status, + api_error_message=self.api_error_message, + request=self.request) def __str__(self): return ('{%s "http_status_code": %s}' % @@ -806,9 +838,16 @@ def is_ok(self): class ApiException(Exception): - def __init__(self, *args, **kwargs): - Exception.__init__(self, *args, **kwargs) - + def __init__(self, message, url, http_status_code=None, body=None, api_status=None, + api_error_message=None, request=None): + Exception.__init__(self, message) + + self.url = url + self.http_status_code = http_status_code + self.body = body + self.api_status = api_status + self.api_error_message = api_error_message + self.request = request def _assert_non_empty_unicode(val, name, error_cls=None): From 81b59437de418789f2f4bc43bc4aef29ac28902b Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Tue, 8 Jan 2019 11:59:05 -0800 Subject: [PATCH 045/112] Version 5.0.0 --- CHANGES.md | 22 ++++++++++++++++++++++ README.md | 1 + sift/version.py | 2 +- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 59b19ad..4bc1b5f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,25 @@ +5.0.0 2019-01-08 +================ +- Add connection pooling + +INCOMPATIBLE CHANGES INTRODUCED IN 5.0.0: + +- Removed support for Python 2.6 + +- Fix url encoding for all endpoints + + Previously, encoding user ids in URLs was inconsistent between endpoints, encoded for some + endpoints, unencoded for others. Additionally, when encoded in the URL path, forward slashes + weren't encoded. Callers with workarounds for this bug must remove these workarounds when + upgrading to 5.0.0. + +- Improved error handling + + Previously, illegal arguments passed to methods like `Client.track()` and failed calls resulting + from server-side errors both raised `ApiExceptions`. Illegal arguments validated in the client + now raise either `TypeErrors` or `ValueErrors`. Server-side errors still raise `ApiExceptions`, + and `ApiException` has been augmented with metadata for handling the error. + 4.3.0.0 2018-07-31 ================== - Add support for rescore_user and get_user_score APIs diff --git a/README.md b/README.md index 786092c..e1082b9 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Here's an example: ```python +import json import sift.client sift.api_key = '' diff --git a/sift/version.py b/sift/version.py index 3e0f491..34ba4ff 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '4.3.0' +VERSION = '5.0.0' API_VERSION = '205' From 4991418672156aaa49ef05dc4171048c24d60420 Mon Sep 17 00:00:00 2001 From: Hung Dang Date: Tue, 22 Jan 2019 11:02:21 -0800 Subject: [PATCH 046/112] Update README for sift rebrand --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e1082b9..e86b866 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Sift Science Python Bindings [![Build Status](https://travis-ci.org/SiftScience/sift-python.svg?branch=master)](https://travis-ci.org/SiftScience/sift-python) +# Sift Bindings [![Build Status](https://travis-ci.org/SiftScience/sift-python.svg?branch=master)](https://travis-ci.org/SiftScience/sift-python) -Bindings for Sift Science's APIs -- including the -[Events](https://siftscience.com/resources/references/events-api.html), -[Labels](https://siftscience.com/resources/references/labels-api.html), +Bindings for Sift's APIs -- including the +[Events](https://sift.com/resources/references/events-api.html), +[Labels](https://sift.com/resources/references/labels-api.html), and -[Score](https://siftscience.com/resources/references/score-api.html) +[Score](https://sift.com/resources/references/score-api.html) APIs. @@ -39,7 +39,7 @@ Python 3: ## Documentation -Please see [here](https://siftscience.com/developers/docs/python/events-api/overview) for the +Please see [here](https://sift.com/developers/docs/python/events-api/overview) for the most up-to-date documentation. ## Changelog From 6b5f83695c29b067138444fdf3145eddd09e7930 Mon Sep 17 00:00:00 2001 From: Hung Dang Date: Tue, 29 Jan 2019 10:43:33 -0800 Subject: [PATCH 047/112] add back Python word --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e86b866..4937b51 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Sift Bindings [![Build Status](https://travis-ci.org/SiftScience/sift-python.svg?branch=master)](https://travis-ci.org/SiftScience/sift-python) +# Sift Python Bindings [![Build Status](https://travis-ci.org/SiftScience/sift-python.svg?branch=master)](https://travis-ci.org/SiftScience/sift-python) Bindings for Sift's APIs -- including the [Events](https://sift.com/resources/references/events-api.html), From 083e2ec144f9e6a3fc812496d8b291255dc1eb6d Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Fri, 1 Mar 2019 15:41:06 -0800 Subject: [PATCH 048/112] Update example constructor call in readme --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 4937b51..45bdd4d 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,7 @@ Here's an example: import json import sift.client -sift.api_key = '' -sift.account_id = '' -client = sift.Client() +client = sift.Client(api_key='', account_id='') # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ user_id = "23056" From 039f1c8634219e9449cc437ef2cd81209a5532b3 Mon Sep 17 00:00:00 2001 From: Phil Freo Date: Mon, 4 Mar 2019 17:23:54 -0500 Subject: [PATCH 049/112] Add Python version classifiers to setup.py --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 5ac353b..192d993 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,8 @@ classifiers=[ "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", From a3dbfc5f043e4992ed6aa977da579fab49001cb4 Mon Sep 17 00:00:00 2001 From: David Ehrmann Date: Thu, 7 Mar 2019 15:15:55 -0800 Subject: [PATCH 050/112] 5.0.1 release (#72) --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 4bc1b5f..19e39fd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.0.1 2019-03-07 +- Update metadata in setup.py + 5.0.0 2019-01-08 ================ - Add connection pooling diff --git a/sift/version.py b/sift/version.py index 34ba4ff..6558a05 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.0.0' +VERSION = '5.0.1' API_VERSION = '205' From 6bb7119ab469ab757ca07ecbec81681cd1a4ced1 Mon Sep 17 00:00:00 2001 From: jyothish6190 Date: Tue, 2 Mar 2021 11:28:47 +0530 Subject: [PATCH 051/112] Upgrade Python Client Lib --- sift/client.py | 12 ++++++------ tests/test_client.py | 16 ++++++++-------- tests/test_client_v203.py | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/sift/client.py b/sift/client.py index 22345b7..adf690e 100644 --- a/sift/client.py +++ b/sift/client.py @@ -7,10 +7,10 @@ import requests.auth import sys if sys.version_info[0] < 3: - import urllib - _UNICODE_STRING = basestring + import urllib.request, urllib.parse, urllib.error + _UNICODE_STRING = str else: - import urllib.parse as urllib + import urllib.parse _UNICODE_STRING = str import sift @@ -24,7 +24,7 @@ def _quote_path(s): # by default, urllib.quote doesn't escape forward slash; pass the # optional arg to override this - return urllib.quote(s, '') + return urllib.parse.quote(s, '') class Client(object): @@ -733,7 +733,7 @@ def _score_url(self, user_id, version): return self.url + '/v%s/score/%s' % (version, _quote_path(user_id)) def _user_score_url(self, user_id, version): - return self.url + '/v%s/users/%s/score' % (version, urllib.quote(user_id)) + return self.url + '/v%s/users/%s/score' % (version, urllib.parse.quote(user_id)) def _label_url(self, user_id, version): return self.url + '/v%s/users/%s/labels' % (version, _quote_path(user_id)) @@ -798,7 +798,7 @@ def __init__(self, http_response): self.api_status = self.body['status'] if 'error_message' in self.body: self.api_error_message = self.body['error_message'] - if 'request' in self.body.keys() and isinstance(self.body['request'], str): + if 'request' in list(self.body.keys()) and isinstance(self.body['request'], str): self.request = json.loads(self.body['request']) except ValueError: raise ApiException( diff --git a/tests/test_client.py b/tests/test_client.py index 5042a06..6c0b8f1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,9 +7,9 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib + import urllib.request, urllib.parse, urllib.error else: - import urllib.parse as urllib + import urllib.parse def valid_transaction_properties(): @@ -852,12 +852,12 @@ def test_unicode_string_parameter_support(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - user_id = u'23056' + user_id = '23056' with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response assert(self.sift_client.track( - u'$transaction', + '$transaction', valid_transaction_properties())) assert(self.sift_client.label( user_id, @@ -865,7 +865,7 @@ def test_unicode_string_parameter_support(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response assert(self.sift_client.score( - user_id, abuse_types=[u'payment_abuse', 'content_abuse'])) + user_id, abuse_types=['payment_abuse', 'content_abuse'])) def test_unlabel_user_with_special_chars_ok(self): user_id = "54321=.-_+@:&^%!$" @@ -875,7 +875,7 @@ def test_unlabel_user_with_special_chars_ok(self): mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id) mock_delete.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.quote(user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) @@ -903,7 +903,7 @@ def test_label_user__with_special_chars_ok(self): properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.quote(user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), data=data, headers=mock.ANY, timeout=mock.ANY, @@ -924,7 +924,7 @@ def test_score__with_special_user_id_chars_ok(self): mock_get.return_value = mock_response response = self.sift_client.score(user_id, abuse_types=['legacy']) mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/%s' % urllib.quote(user_id), + 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote(user_id), params={'api_key': self.test_key, 'abuse_types': 'legacy'}, headers=mock.ANY, timeout=mock.ANY) diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index 7eeff45..a18b3d0 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -7,9 +7,9 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib + import urllib.request, urllib.parse, urllib.error else: - import urllib.parse as urllib + import urllib.parse def valid_transaction_properties(): @@ -298,13 +298,13 @@ def test_unicode_string_parameter_support(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - user_id = u'23056' + user_id = '23056' with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response assert( self.sift_client.track( - u'$transaction', + '$transaction', valid_transaction_properties())) assert( self.sift_client.label( @@ -322,7 +322,7 @@ def test_unlabel_user_with_special_chars_ok(self): mock_delete.return_value = mock_response response = self.sift_client_v204.unlabel(user_id, version='203') mock_delete.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % urllib.quote(user_id), + 'https://api.siftscience.com/v203/users/%s/labels' % urllib.parse.quote(user_id), headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) @@ -350,7 +350,7 @@ def test_label_user__with_special_chars_ok(self): properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % urllib.quote(user_id), + 'https://api.siftscience.com/v203/users/%s/labels' % urllib.parse.quote(user_id), data=data, headers=mock.ANY, timeout=mock.ANY, @@ -371,7 +371,7 @@ def test_score__with_special_user_id_chars_ok(self): mock_get.return_value = mock_response response = self.sift_client.score(user_id) mock_get.assert_called_with( - 'https://api.siftscience.com/v203/score/%s' % urllib.quote(user_id), + 'https://api.siftscience.com/v203/score/%s' % urllib.parse.quote(user_id), params={'api_key': self.test_key}, headers=mock.ANY, timeout=mock.ANY) From e4d8d80d359803c4619a899644b9f0f116058a95 Mon Sep 17 00:00:00 2001 From: jyothish6190 Date: Thu, 25 Mar 2021 20:50:36 +0530 Subject: [PATCH 052/112] verification end points added --- README.md | 58 ++++++++- sift/client.py | 261 ++++++++++++++++++++++++++++++++++++++-- tests/test_client.py | 280 +++++++++++++++++++++++++++++++++---------- 3 files changed, 523 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 45bdd4d..a7f099c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ and [Score](https://sift.com/resources/references/score-api.html) APIs. - ## Installation Set up a virtual environment with virtualenv (otherwise you will need @@ -36,7 +35,6 @@ Python 3: pip3 install git+https://github.com/SiftScience/sift-python - ## Documentation Please see [here](https://sift.com/developers/docs/python/events-api/overview) for the @@ -52,7 +50,6 @@ Note, that in v2.0.0, the API semantics were changed to raise an exception in the case of error to be more pythonic. Client code will need to be updated to catch `sift.client.ApiException` exceptions. - ## Usage Here's an example: @@ -156,8 +153,61 @@ try: except sift.client.ApiException: # request failed pass -``` +# The send call triggers the generation of a OTP code that is stored by Sift and emails the code to the user. + +send_properties = { + "$user_id": "billy_jones_301", + "$send_to": "billy_jones_301@gmail.com", + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$event": { + "$session_id": "09f7f361575d11ff", + "$verified_event": "$login", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" + } + } + } + +try: + response = client.send(send_properties) +except sift.client.ApiException: + # request failed + pass + +# The check call is used for sending the OTP provided by the end user to Sift. +check_properties = { + "$user_id" : "billy_jones_301", + "$code": 123456, + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" + } + +try: + response = client.check(check_properties) +except sift.client.ApiException: + # request failed + pass + +# The resend call generates a new OTP and sends it to the original recipient with the same settings +resend_properties = { + "$user_id": "billy_jones_301", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" + } + +try: + response = client.resend(resend_properties) +except sift.client.ApiException: + # request failed + pass + + +``` ## Testing diff --git a/sift/client.py b/sift/client.py index adf690e..f871ad6 100644 --- a/sift/client.py +++ b/sift/client.py @@ -7,7 +7,9 @@ import requests.auth import sys if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import urllib.request + import urllib.parse + import urllib.error _UNICODE_STRING = str else: import urllib.parse @@ -18,8 +20,17 @@ API_URL = 'https://api.siftscience.com' API3_URL = 'https://api3.siftscience.com' +API_URL_VERIFICATION = 'https://api.sift.com/v1.1/verification/' + DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] +VERIFIED_EVENTS = [ + "$add_item_to_cart", "$add_promotion", "$content_status", + "$create_account", "$create_content", "$create_order", "$flag_content", + "$login", "$order_status", "$remove_item_from_cart", "$transaction", "$transaction", + "$update_account", "$update_content", "$update_order", "$update_password" +] + def _quote_path(s): # by default, urllib.quote doesn't escape forward slash; pass the @@ -417,7 +428,8 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No _assert_non_empty_unicode(entity_type, 'entity_type') if entity_type.lower() not in ['user', 'order', 'session', 'content']: - raise ValueError("entity_type must be one of {user, order, session, content}") + raise ValueError( + "entity_type must be one of {user, order, session, content}") params['entity_type'] = entity_type @@ -434,7 +446,8 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No try: return Response(self.session.get(url, params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + auth=requests.auth.HTTPBasicAuth( + self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: @@ -497,7 +510,8 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) - url = self._order_apply_decisions_url(self.account_id, user_id, order_id) + url = self._order_apply_decisions_url( + self.account_id, user_id, order_id) try: return Response(self.session.post( @@ -524,12 +538,14 @@ def _validate_apply_decision_request(self, properties, user_id): _assert_non_empty_unicode(source, 'source', error_cls=ValueError) if source not in DECISION_SOURCES: - raise ValueError("decision 'source' must be one of [{0}]".format(", ".join(DECISION_SOURCES))) + raise ValueError("decision 'source' must be one of [{0}]".format( + ", ".join(DECISION_SOURCES))) properties.update({'source': source.upper()}) if source == 'MANUAL_REVIEW' and not properties.get('analyst', None): - raise ValueError("must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'") + raise ValueError( + "must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'") def get_user_decisions(self, user_id, timeout=None): """Gets the decisions for a user. @@ -670,7 +686,8 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) - url = self._session_apply_decisions_url(self.account_id, user_id, session_id) + url = self._session_apply_decisions_url( + self.account_id, user_id, session_id) try: return Response(self.session.post( @@ -708,7 +725,128 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) - url = self._content_apply_decisions_url(self.account_id, user_id, content_id) + url = self._content_apply_decisions_url( + self.account_id, user_id, content_id) + + try: + return Response(self.session.post( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def check(self, properties, timeout=None, version=None): + """The check call is used for sending the OTP provided by the end user to Sift. + Sift then compares the OTP, checks rate limits and responds with a decision whether the user should be able to proceed or not. + This call is blocking. Check out https://sift.com/developers/docs/curl/verification-api/check + for more information on our check response structure. + + Args: + + properties: + $user_id: id of user + $code: The code the user sent to the customer for validation.. + $verified_event(optional): This will be the event type that triggered the verification. + $verified_entity_id(optional): The ID of the entity impacted by the event being verified. + + timeout(optional): Use a custom timeout (in seconds) for this call. + version(optional): Use a different version of the Sift Science API for this call. + + Returns: + A sift.client.Response object if the check call succeeded, or raises + an ApiException. + """ + if timeout is None: + timeout = self.timeout + + self._validate_check_request(properties) + + url = self._check_url() + + try: + return Response(self.session.post( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def _validate_check_request(self, properties): + """ This method is used to validate arguments passed to the check method. """ + + if not isinstance(properties, dict): + raise TypeError("properties must be a dict") + elif not properties: + raise ValueError("properties dictionary may not be empty") + + user_id = properties.get('$user_id') + _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) + + otp_code = properties.get('$code') + if otp_code is None: + raise ValueError("Code is required") + + if otp_code and type(otp_code) != int: + raise ValueError("Code should be a number") + + verified_event = properties.get('$verified_event') + if (verified_event and verified_event not in VERIFIED_EVENTS): + raise ValueError(" 'verified_event' must be one of [{0}]".format( + ", ".join(VERIFIED_EVENTS))) + + verified_entity_id = properties.get('$verified_entity_id') + _assert_non_empty_unicode( + verified_entity_id, 'verified_entity_id', error_cls=ValueError) + + def send(self, properties, timeout=None, version=None): + """The send call triggers the generation of a OTP code that is stored by Sift and emails the code to the user. + This call is blocking. Check out https://sift.com/developers/docs/curl/verification-api/send + for more information on our send response structure. + + Args: + + properties: + + $user_id: User ID of user being verified, e.g. johndoe123. + $send_to: The phone / email to send the OTP to. + $verification_type: The channel used for verification + $brand_name(optional): Name of the brand of product or service the user interacts with. + $language(optional): Language of the content of the web site. + $event: + $session_id: The session being verified. See $verification in the Sift Events API documentation. + $verified_event: The type of the reserved event being verified. + $reason(optional): The trigger for the verification. See $verification in the Sift Events API documentation. + $ip(optional): The user’s IP address. + $browser: + $user_agent: The user agent of the browser that is verifying. Represented by the $browser object. + Use this field if the client is a browser. Note: cannot be used in conjunction with $app. + + + timeout(optional): Use a custom timeout (in seconds) for this call. + + version(optional): Use a different version of the Sift Science API for this call. + + Returns: + A sift.client.Response object if the send call succeeded, or raises an ApiException. + """ + + if timeout is None: + timeout = self.timeout + + self._validate_send_request(properties) + + url = self._send_url() try: return Response(self.session.post( @@ -723,6 +861,101 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e), url) + def _validate_send_request(self, properties): + """ This method is used to validate arguments passed to the send method. """ + + if not isinstance(properties, dict): + raise TypeError("properties must be a dict") + elif not properties: + raise ValueError("properties dictionary may not be empty") + + user_id = properties.get('$user_id') + _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) + + send_to = properties.get('$send_to') + _assert_non_empty_unicode(send_to, 'send_to', error_cls=ValueError) + + verification_type = properties.get('$verification_type') + _assert_non_empty_unicode( + verification_type, 'verification_type', error_cls=ValueError) + + event = properties.get('$event') + if not isinstance(event, dict): + raise TypeError("$event must be a dict") + elif not event: + raise ValueError("$event dictionary may not be empty") + + session_id = event.get('$session_id') + _assert_non_empty_unicode( + session_id, 'session_id', error_cls=ValueError) + + verified_event = event.get('$verified_event') + _assert_non_empty_unicode( + verified_event, 'verified_event', error_cls=ValueError) + if (verified_event and verified_event not in VERIFIED_EVENTS): + raise ValueError(" 'verified_event' must be one of [{0}]".format( + ", ".join(VERIFIED_EVENTS))) + + def resend(self, properties, timeout=None, version=None): + """A user can ask for a new OTP (one-time password) if they haven’t received the previous one, or in case the previous OTP expired. + The /resend call generates a new OTP and sends it to the original recipient with the same settings (template, verified event info). + This call is blocking. Check out https://sift.com/developers/docs/curl/verification-api/resend + for more information on our check response structure. + + Args: + properties: + user_id: A user's id. This id should be the same as the user_id used in event calls. + verified_event(optional): This will be the event type that triggered the verification. + verified_entity_id(optional): The ID of the entity impacted by the event being verified. + + timeout(optional): Use a custom timeout (in seconds) for this call. + + version(optional): Use a different version of the Sift Science API for this call. + + Returns: + A sift.client.Response object if the send call succeeded, or raises + an ApiException. + """ + if timeout is None: + timeout = self.timeout + + self._validate_resend_request(properties) + + url = self._resend_url() + + try: + return Response(self.session.post( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def _validate_resend_request(self, properties): + """ This method is used to validate arguments passed to the resend method. """ + + if not isinstance(properties, dict): + raise TypeError("properties must be a dict") + elif not properties: + raise ValueError("properties dictionary may not be empty") + + user_id = properties.get('$user_id') + _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) + + verified_event = properties.get('$verified_event') + if (verified_event and verified_event not in VERIFIED_EVENTS): + raise ValueError(" 'verified_event' must be one of [{0}]".format( + ", ".join(VERIFIED_EVENTS))) + + verified_entity_id = properties.get('$verified_entity_id') + _assert_non_empty_unicode( + verified_entity_id, 'verified_entity_id', error_cls=ValueError) + def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -773,6 +1006,15 @@ def _content_apply_decisions_url(self, account_id, user_id, content_id): return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) + def _check_url(self): + return (API_URL_VERIFICATION + 'check') + + def _send_url(self): + return (API_URL_VERIFICATION + 'send') + + def _resend_url(self): + return (API_URL_VERIFICATION + 'resend') + class Response(object): @@ -812,7 +1054,8 @@ def __init__(self, http_response): finally: if int(self.http_status_code) < 200 or int(self.http_status_code) >= 300: raise ApiException( - '{0} returned non-2XX http status code {1}'.format(self.url, self.http_status_code), + '{0} returned non-2XX http status code {1}'.format( + self.url, self.http_status_code), url=self.url, http_status_code=self.http_status_code, body=self.body, diff --git a/tests/test_client.py b/tests/test_client.py index 6c0b8f1..24e7da9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,7 +7,9 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import urllib.request + import urllib.parse + import urllib.error else: import urllib.parse @@ -167,7 +169,8 @@ class TestSiftPythonClient(unittest.TestCase): def setUp(self): self.test_key = 'a_fake_test_api_key' self.account_id = 'ACCT' - self.sift_client = sift.Client(api_key=self.test_key, account_id=self.account_id) + self.sift_client = sift.Client( + api_key=self.test_key, account_id=self.account_id) def test_global_api_key(self): # test for error if global key is undefined @@ -224,7 +227,8 @@ def test_event_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties()) + response = self.sift_client.track( + event, valid_transaction_properties()) mock_post.assert_called_with( 'https://api.siftscience.com/v205/events', data=mock.ANY, @@ -339,11 +343,13 @@ def test_get_user_score_with_abuse_types_ok(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_user_score('12345', - abuse_types=['payment_abuse', 'content_abuse'], + abuse_types=[ + 'payment_abuse', 'content_abuse'], timeout=test_timeout) mock_get.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + params={'api_key': self.test_key, + 'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) @@ -391,11 +397,13 @@ def test_rescore_user_with_abuse_types_ok(self): with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.rescore_user('12345', - abuse_types=['payment_abuse', 'content_abuse'], + abuse_types=[ + 'payment_abuse', 'content_abuse'], timeout=test_timeout) mock_post.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + params={'api_key': self.test_key, + 'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) @@ -432,8 +440,10 @@ def test_sync_score_ok(self): assert(response.api_status == 0) assert(response.api_error_message == "OK") assert(response.body['score_response']['score'] == 0.85) - assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) - assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) + assert(response.body['score_response'] + ['scores']['content_abuse']['score'] == 0.14) + assert(response.body['score_response'] + ['scores']['payment_abuse']['score'] == 0.97) def test_get_decisions_fails(self): with self.assertRaises(ValueError): @@ -480,7 +490,8 @@ def test_get_decisions(self): 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'user', 'limit': 10, 'abuse_types': 'legacy,payment_abuse'}, + params={'entity_type': 'user', 'limit': 10, + 'abuse_types': 'legacy,payment_abuse'}, timeout=3) self.assertIsInstance(response, sift.client.Response) @@ -527,7 +538,8 @@ def test_get_decisions_entity_session(self): 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'session', 'limit': 10, 'abuse_types': 'account_takeover'}, + params={'entity_type': 'session', 'limit': 10, + 'abuse_types': 'account_takeover'}, timeout=3) self.assertIsInstance(response, sift.client.Response) @@ -538,12 +550,12 @@ def test_apply_decision_to_user_ok(self): user_id = '54321' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - 'time': 1481569575 - } + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', + 'time': 1481569575 + } apply_decision_response_json = """ { "entity": { @@ -562,7 +574,8 @@ def test_apply_decision_to_user_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_user_decision(user_id, apply_decision_request) + response = self.sift_client.apply_user_decision( + user_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, @@ -575,13 +588,14 @@ def test_apply_decision_to_user_ok(self): def test_validate_no_user_id_string_fails(self): apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - } + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', + } with self.assertRaises(TypeError): - self.sift_client._validate_apply_decision_request(apply_decision_request, 123) + self.sift_client._validate_apply_decision_request( + apply_decision_request, 123) def test_apply_decision_to_order_fails_with_no_order_id(self): with self.assertRaises(TypeError): @@ -607,7 +621,8 @@ def test_validate_apply_decision_request_no_analyst_fails(self): } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId") def test_validate_apply_decision_request_no_source_fails(self): apply_decision_request = { @@ -616,12 +631,14 @@ def test_validate_apply_decision_request_no_source_fails(self): } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId") def test_validate_empty_apply_decision_request_fails(self): apply_decision_request = {} with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId") def test_apply_decision_manual_review_no_analyst_fails(self): user_id = '54321' @@ -632,7 +649,8 @@ def test_apply_decision_manual_review_no_analyst_fails(self): } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision(user_id, apply_decision_request) + self.sift_client.apply_user_decision( + user_id, apply_decision_request) def test_apply_decision_no_source_fails(self): user_id = '54321' @@ -642,7 +660,8 @@ def test_apply_decision_no_source_fails(self): } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision(user_id, apply_decision_request) + self.sift_client.apply_user_decision( + user_id, apply_decision_request) def test_apply_decision_invalid_source_fails(self): user_id = '54321' @@ -652,17 +671,18 @@ def test_apply_decision_invalid_source_fails(self): 'time': 1481569575 } - self.assertRaises(ValueError, self.sift_client.apply_user_decision, user_id, apply_decision_request) + self.assertRaises( + ValueError, self.sift_client.apply_user_decision, user_id, apply_decision_request) def test_apply_decision_to_order_ok(self): user_id = '54321' order_id = '43210' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'order_looks_bad_payment_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'order_looks_bad_payment_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -683,10 +703,12 @@ def test_apply_decision_to_order_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_order_decision(user_id, order_id, apply_decision_request) + response = self.sift_client.apply_order_decision( + user_id, order_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id, order_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % ( + user_id, order_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) @@ -698,10 +720,10 @@ def test_apply_decision_to_session_ok(self): session_id = 'gigtleqddo84l8cm15qe4il' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'session_looks_bad_ato', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'session_looks_bad_ato', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -722,10 +744,12 @@ def test_apply_decision_to_session_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_session_decision(user_id, session_id, apply_decision_request) + response = self.sift_client.apply_session_decision( + user_id, session_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id, session_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % ( + user_id, session_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) @@ -737,10 +761,10 @@ def test_apply_decision_to_content_ok(self): content_id = 'listing-1231' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'content_looks_bad_content_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'content_looks_bad_content_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -761,10 +785,12 @@ def test_apply_decision_to_content_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_content_decision(user_id, content_id, apply_decision_request) + response = self.sift_client.apply_content_decision( + user_id, content_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id, content_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % ( + user_id, content_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) @@ -780,7 +806,8 @@ def test_label_user_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.label(user_id, valid_label_properties()) + response = self.sift_client.label( + user_id, valid_label_properties()) properties = { '$abuse_type': 'content_abuse', '$is_bad': True, @@ -833,7 +860,8 @@ def test_unlabel_user_ok(self): mock_response.status_code = 204 with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.return_value = mock_response - response = self.sift_client.unlabel(user_id, abuse_type='account_abuse') + response = self.sift_client.unlabel( + user_id, abuse_type='account_abuse') mock_delete.assert_called_with( 'https://api.siftscience.com/v205/users/%s/labels' % user_id, headers=mock.ANY, @@ -875,7 +903,8 @@ def test_unlabel_user_with_special_chars_ok(self): mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id) mock_delete.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote( + user_id), headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) @@ -903,7 +932,8 @@ def test_label_user__with_special_chars_ok(self): properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote( + user_id), data=data, headers=mock.ANY, timeout=mock.ANY, @@ -924,7 +954,8 @@ def test_score__with_special_user_id_chars_ok(self): mock_get.return_value = mock_response response = self.sift_client.score(user_id, abuse_types=['legacy']) mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote(user_id), + 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote( + user_id), params={'api_key': self.test_key, 'abuse_types': 'legacy'}, headers=mock.ANY, timeout=mock.ANY) @@ -941,7 +972,8 @@ def test_exception_during_track_call(self): mock_post.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) with self.assertRaises(sift.client.ApiException): - self.sift_client.track('$transaction', valid_transaction_properties()) + self.sift_client.track( + '$transaction', valid_transaction_properties()) def test_exception_during_score_call(self): warnings.simplefilter("always") @@ -1040,7 +1072,8 @@ def test_get_workflow_status(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_workflow_status('4zxwibludiaaa', timeout=3) + response = self.sift_client.get_workflow_status( + '4zxwibludiaaa', timeout=3) mock_get.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa', headers=mock.ANY, auth=mock.ANY, timeout=3) @@ -1078,7 +1111,8 @@ def test_get_user_decisions(self): self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') + assert(response.body['decisions']['payment_abuse'] + ['decision']['id'] == 'user_decision') def test_get_order_decisions(self): mock_response = mock.Mock() @@ -1116,8 +1150,10 @@ def test_get_order_decisions(self): self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') - assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') + assert(response.body['decisions']['payment_abuse'] + ['decision']['id'] == 'decision7') + assert(response.body['decisions']['promotion_abuse'] + ['decision']['id'] == 'good_order') def test_get_session_decisions(self): mock_response = mock.Mock() @@ -1141,14 +1177,16 @@ def test_get_session_decisions(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_session_decisions('example_user', 'example_session') + response = self.sift_client.get_session_decisions( + 'example_user', 'example_session') mock_get.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') + assert(response.body['decisions']['account_takeover'] + ['decision']['id'] == 'session_decision') def test_get_content_decisions(self): mock_response = mock.Mock() @@ -1172,18 +1210,21 @@ def test_get_content_decisions(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_content_decisions('example_user', 'example_content') + response = self.sift_client.get_content_decisions( + 'example_user', 'example_content') mock_get.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') + assert(response.body['decisions']['content_abuse'] + ['decision']['id'] == 'content_looks_bad_content_abuse') def test_provided_session(self): session = mock.Mock() - client = sift.Client(api_key=self.test_key, account_id=self.account_id, session=session) + client = sift.Client(api_key=self.test_key, + account_id=self.account_id, session=session) mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' @@ -1196,6 +1237,119 @@ def test_provided_session(self): client.track(event, valid_transaction_properties()) session.post.assert_called_once() + def test_check_ok(self): + user_id = '54321' + mock_response = mock.Mock() + check_request = { + "$user_id": user_id, + "$code": 524313, + "$verified_event": "$login", + "$verified_entity_id": "09f7f361575d11ff", + } + + check_response_json = """ + { + "error_message": "OK", + "checked_at": 1616599678245, + "http_status_code": 200 + } + """ + + mock_response.content = check_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.check(check_request) + data = json.dumps(check_request) + mock_post.assert_called_with( + 'https://api.sift.com/v1.1/verification/check', + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + self.assertIsInstance(response, sift.client.Response) + assert(response.is_ok()) + assert(response.body['error_message'] == 'OK') + + def test_send_ok(self): + mock_response = mock.Mock() + send_request = { + "$user_id": "billy_jones_301", + "$send_to": "billy_jones_301@gmail.com", + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$event": { + "$session_id": "09f7f361575d11ff", + "$verified_event": "$login", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" + } + } + } + + send_response_json = """ + { + "status": 0, + "error_message": "OK", + "sent_at": 1616684387524, + "segment_id": "abf820a5-102c-46a7-826a-3001a06bf6c1", + "segment_name": "Default template", + "brand_name": "", + "site_country": "", + "content_language": "", + "http_status_code": 200 + } + """ + + mock_response.content = send_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.send(send_request) + data = json.dumps(send_request) + mock_post.assert_called_with( + 'https://api.sift.com/v1.1/verification/send', + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + self.assertIsInstance(response, sift.client.Response) + assert(response.is_ok()) + assert(response.body['error_message'] == 'OK') + + def test_resend_ok(self): + mock_response = mock.Mock() + resend_request = { + "$user_id": "billy_jones_301", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" + } + + resend_response_json = """ + { + "status": 0, + "error_message": "OK", + "sent_at": 1566324368002, + "http_status_code": 200 + } + """ + + mock_response.content = resend_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.resend(resend_request) + data = json.dumps(resend_request) + mock_post.assert_called_with( + 'https://api.sift.com/v1.1/verification/resend', + auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + self.assertIsInstance(response, sift.client.Response) + assert(response.is_ok()) + assert(response.body['error_message'] == 'OK') + def main(): unittest.main() From 84901fb6869d3b54cd104ff1329045b51d57d119 Mon Sep 17 00:00:00 2001 From: Brian Higgins Date: Thu, 1 Apr 2021 16:23:43 -0700 Subject: [PATCH 053/112] Revert "Msue 40" --- README.md | 58 +-------- sift/client.py | 261 ++-------------------------------------- tests/test_client.py | 280 ++++++++++--------------------------------- 3 files changed, 76 insertions(+), 523 deletions(-) diff --git a/README.md b/README.md index a7f099c..45bdd4d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ and [Score](https://sift.com/resources/references/score-api.html) APIs. + ## Installation Set up a virtual environment with virtualenv (otherwise you will need @@ -35,6 +36,7 @@ Python 3: pip3 install git+https://github.com/SiftScience/sift-python + ## Documentation Please see [here](https://sift.com/developers/docs/python/events-api/overview) for the @@ -50,6 +52,7 @@ Note, that in v2.0.0, the API semantics were changed to raise an exception in the case of error to be more pythonic. Client code will need to be updated to catch `sift.client.ApiException` exceptions. + ## Usage Here's an example: @@ -153,62 +156,9 @@ try: except sift.client.ApiException: # request failed pass - -# The send call triggers the generation of a OTP code that is stored by Sift and emails the code to the user. - -send_properties = { - "$user_id": "billy_jones_301", - "$send_to": "billy_jones_301@gmail.com", - "$verification_type": "$email", - "$brand_name": "MyTopBrand", - "$language": "en", - "$event": { - "$session_id": "09f7f361575d11ff", - "$verified_event": "$login", - "$reason": "$automated_rule", - "$ip": "192.168.1.1", - "$browser": { - "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" - } - } - } - -try: - response = client.send(send_properties) -except sift.client.ApiException: - # request failed - pass - -# The check call is used for sending the OTP provided by the end user to Sift. -check_properties = { - "$user_id" : "billy_jones_301", - "$code": 123456, - "$verified_event": "$login", - "$verified_entity_id": "SOME_SESSION_ID" - } - -try: - response = client.check(check_properties) -except sift.client.ApiException: - # request failed - pass - -# The resend call generates a new OTP and sends it to the original recipient with the same settings -resend_properties = { - "$user_id": "billy_jones_301", - "$verified_event": "$login", - "$verified_entity_id": "SOME_SESSION_ID" - } - -try: - response = client.resend(resend_properties) -except sift.client.ApiException: - # request failed - pass - - ``` + ## Testing Before submitting a change, make sure the following commands run without diff --git a/sift/client.py b/sift/client.py index f871ad6..adf690e 100644 --- a/sift/client.py +++ b/sift/client.py @@ -7,9 +7,7 @@ import requests.auth import sys if sys.version_info[0] < 3: - import urllib.request - import urllib.parse - import urllib.error + import urllib.request, urllib.parse, urllib.error _UNICODE_STRING = str else: import urllib.parse @@ -20,17 +18,8 @@ API_URL = 'https://api.siftscience.com' API3_URL = 'https://api3.siftscience.com' -API_URL_VERIFICATION = 'https://api.sift.com/v1.1/verification/' - DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] -VERIFIED_EVENTS = [ - "$add_item_to_cart", "$add_promotion", "$content_status", - "$create_account", "$create_content", "$create_order", "$flag_content", - "$login", "$order_status", "$remove_item_from_cart", "$transaction", "$transaction", - "$update_account", "$update_content", "$update_order", "$update_password" -] - def _quote_path(s): # by default, urllib.quote doesn't escape forward slash; pass the @@ -428,8 +417,7 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No _assert_non_empty_unicode(entity_type, 'entity_type') if entity_type.lower() not in ['user', 'order', 'session', 'content']: - raise ValueError( - "entity_type must be one of {user, order, session, content}") + raise ValueError("entity_type must be one of {user, order, session, content}") params['entity_type'] = entity_type @@ -446,8 +434,7 @@ def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=No try: return Response(self.session.get(url, params=params, - auth=requests.auth.HTTPBasicAuth( - self.api_key, ''), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), headers={'User-Agent': self._user_agent()}, timeout=timeout)) except requests.exceptions.RequestException as e: @@ -510,8 +497,7 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) - url = self._order_apply_decisions_url( - self.account_id, user_id, order_id) + url = self._order_apply_decisions_url(self.account_id, user_id, order_id) try: return Response(self.session.post( @@ -538,14 +524,12 @@ def _validate_apply_decision_request(self, properties, user_id): _assert_non_empty_unicode(source, 'source', error_cls=ValueError) if source not in DECISION_SOURCES: - raise ValueError("decision 'source' must be one of [{0}]".format( - ", ".join(DECISION_SOURCES))) + raise ValueError("decision 'source' must be one of [{0}]".format(", ".join(DECISION_SOURCES))) properties.update({'source': source.upper()}) if source == 'MANUAL_REVIEW' and not properties.get('analyst', None): - raise ValueError( - "must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'") + raise ValueError("must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'") def get_user_decisions(self, user_id, timeout=None): """Gets the decisions for a user. @@ -686,8 +670,7 @@ def apply_session_decision(self, user_id, session_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) - url = self._session_apply_decisions_url( - self.account_id, user_id, session_id) + url = self._session_apply_decisions_url(self.account_id, user_id, session_id) try: return Response(self.session.post( @@ -725,128 +708,7 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) - url = self._content_apply_decisions_url( - self.account_id, user_id, content_id) - - try: - return Response(self.session.post( - url, - data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - - except requests.exceptions.RequestException as e: - raise ApiException(str(e), url) - - def check(self, properties, timeout=None, version=None): - """The check call is used for sending the OTP provided by the end user to Sift. - Sift then compares the OTP, checks rate limits and responds with a decision whether the user should be able to proceed or not. - This call is blocking. Check out https://sift.com/developers/docs/curl/verification-api/check - for more information on our check response structure. - - Args: - - properties: - $user_id: id of user - $code: The code the user sent to the customer for validation.. - $verified_event(optional): This will be the event type that triggered the verification. - $verified_entity_id(optional): The ID of the entity impacted by the event being verified. - - timeout(optional): Use a custom timeout (in seconds) for this call. - version(optional): Use a different version of the Sift Science API for this call. - - Returns: - A sift.client.Response object if the check call succeeded, or raises - an ApiException. - """ - if timeout is None: - timeout = self.timeout - - self._validate_check_request(properties) - - url = self._check_url() - - try: - return Response(self.session.post( - url, - data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - - except requests.exceptions.RequestException as e: - raise ApiException(str(e), url) - - def _validate_check_request(self, properties): - """ This method is used to validate arguments passed to the check method. """ - - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - user_id = properties.get('$user_id') - _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) - - otp_code = properties.get('$code') - if otp_code is None: - raise ValueError("Code is required") - - if otp_code and type(otp_code) != int: - raise ValueError("Code should be a number") - - verified_event = properties.get('$verified_event') - if (verified_event and verified_event not in VERIFIED_EVENTS): - raise ValueError(" 'verified_event' must be one of [{0}]".format( - ", ".join(VERIFIED_EVENTS))) - - verified_entity_id = properties.get('$verified_entity_id') - _assert_non_empty_unicode( - verified_entity_id, 'verified_entity_id', error_cls=ValueError) - - def send(self, properties, timeout=None, version=None): - """The send call triggers the generation of a OTP code that is stored by Sift and emails the code to the user. - This call is blocking. Check out https://sift.com/developers/docs/curl/verification-api/send - for more information on our send response structure. - - Args: - - properties: - - $user_id: User ID of user being verified, e.g. johndoe123. - $send_to: The phone / email to send the OTP to. - $verification_type: The channel used for verification - $brand_name(optional): Name of the brand of product or service the user interacts with. - $language(optional): Language of the content of the web site. - $event: - $session_id: The session being verified. See $verification in the Sift Events API documentation. - $verified_event: The type of the reserved event being verified. - $reason(optional): The trigger for the verification. See $verification in the Sift Events API documentation. - $ip(optional): The user’s IP address. - $browser: - $user_agent: The user agent of the browser that is verifying. Represented by the $browser object. - Use this field if the client is a browser. Note: cannot be used in conjunction with $app. - - - timeout(optional): Use a custom timeout (in seconds) for this call. - - version(optional): Use a different version of the Sift Science API for this call. - - Returns: - A sift.client.Response object if the send call succeeded, or raises an ApiException. - """ - - if timeout is None: - timeout = self.timeout - - self._validate_send_request(properties) - - url = self._send_url() + url = self._content_apply_decisions_url(self.account_id, user_id, content_id) try: return Response(self.session.post( @@ -861,101 +723,6 @@ def send(self, properties, timeout=None, version=None): except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def _validate_send_request(self, properties): - """ This method is used to validate arguments passed to the send method. """ - - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - user_id = properties.get('$user_id') - _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) - - send_to = properties.get('$send_to') - _assert_non_empty_unicode(send_to, 'send_to', error_cls=ValueError) - - verification_type = properties.get('$verification_type') - _assert_non_empty_unicode( - verification_type, 'verification_type', error_cls=ValueError) - - event = properties.get('$event') - if not isinstance(event, dict): - raise TypeError("$event must be a dict") - elif not event: - raise ValueError("$event dictionary may not be empty") - - session_id = event.get('$session_id') - _assert_non_empty_unicode( - session_id, 'session_id', error_cls=ValueError) - - verified_event = event.get('$verified_event') - _assert_non_empty_unicode( - verified_event, 'verified_event', error_cls=ValueError) - if (verified_event and verified_event not in VERIFIED_EVENTS): - raise ValueError(" 'verified_event' must be one of [{0}]".format( - ", ".join(VERIFIED_EVENTS))) - - def resend(self, properties, timeout=None, version=None): - """A user can ask for a new OTP (one-time password) if they haven’t received the previous one, or in case the previous OTP expired. - The /resend call generates a new OTP and sends it to the original recipient with the same settings (template, verified event info). - This call is blocking. Check out https://sift.com/developers/docs/curl/verification-api/resend - for more information on our check response structure. - - Args: - properties: - user_id: A user's id. This id should be the same as the user_id used in event calls. - verified_event(optional): This will be the event type that triggered the verification. - verified_entity_id(optional): The ID of the entity impacted by the event being verified. - - timeout(optional): Use a custom timeout (in seconds) for this call. - - version(optional): Use a different version of the Sift Science API for this call. - - Returns: - A sift.client.Response object if the send call succeeded, or raises - an ApiException. - """ - if timeout is None: - timeout = self.timeout - - self._validate_resend_request(properties) - - url = self._resend_url() - - try: - return Response(self.session.post( - url, - data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - - except requests.exceptions.RequestException as e: - raise ApiException(str(e), url) - - def _validate_resend_request(self, properties): - """ This method is used to validate arguments passed to the resend method. """ - - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - user_id = properties.get('$user_id') - _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) - - verified_event = properties.get('$verified_event') - if (verified_event and verified_event not in VERIFIED_EVENTS): - raise ValueError(" 'verified_event' must be one of [{0}]".format( - ", ".join(VERIFIED_EVENTS))) - - verified_entity_id = properties.get('$verified_entity_id') - _assert_non_empty_unicode( - verified_entity_id, 'verified_entity_id', error_cls=ValueError) - def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -1006,15 +773,6 @@ def _content_apply_decisions_url(self, account_id, user_id, content_id): return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) - def _check_url(self): - return (API_URL_VERIFICATION + 'check') - - def _send_url(self): - return (API_URL_VERIFICATION + 'send') - - def _resend_url(self): - return (API_URL_VERIFICATION + 'resend') - class Response(object): @@ -1054,8 +812,7 @@ def __init__(self, http_response): finally: if int(self.http_status_code) < 200 or int(self.http_status_code) >= 300: raise ApiException( - '{0} returned non-2XX http status code {1}'.format( - self.url, self.http_status_code), + '{0} returned non-2XX http status code {1}'.format(self.url, self.http_status_code), url=self.url, http_status_code=self.http_status_code, body=self.body, diff --git a/tests/test_client.py b/tests/test_client.py index 24e7da9..6c0b8f1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,9 +7,7 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib.request - import urllib.parse - import urllib.error + import urllib.request, urllib.parse, urllib.error else: import urllib.parse @@ -169,8 +167,7 @@ class TestSiftPythonClient(unittest.TestCase): def setUp(self): self.test_key = 'a_fake_test_api_key' self.account_id = 'ACCT' - self.sift_client = sift.Client( - api_key=self.test_key, account_id=self.account_id) + self.sift_client = sift.Client(api_key=self.test_key, account_id=self.account_id) def test_global_api_key(self): # test for error if global key is undefined @@ -227,8 +224,7 @@ def test_event_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track( - event, valid_transaction_properties()) + response = self.sift_client.track(event, valid_transaction_properties()) mock_post.assert_called_with( 'https://api.siftscience.com/v205/events', data=mock.ANY, @@ -343,13 +339,11 @@ def test_get_user_score_with_abuse_types_ok(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response response = self.sift_client.get_user_score('12345', - abuse_types=[ - 'payment_abuse', 'content_abuse'], + abuse_types=['payment_abuse', 'content_abuse'], timeout=test_timeout) mock_get.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, - 'abuse_types': 'payment_abuse,content_abuse'}, + params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) @@ -397,13 +391,11 @@ def test_rescore_user_with_abuse_types_ok(self): with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response response = self.sift_client.rescore_user('12345', - abuse_types=[ - 'payment_abuse', 'content_abuse'], + abuse_types=['payment_abuse', 'content_abuse'], timeout=test_timeout) mock_post.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, - 'abuse_types': 'payment_abuse,content_abuse'}, + params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) @@ -440,10 +432,8 @@ def test_sync_score_ok(self): assert(response.api_status == 0) assert(response.api_error_message == "OK") assert(response.body['score_response']['score'] == 0.85) - assert(response.body['score_response'] - ['scores']['content_abuse']['score'] == 0.14) - assert(response.body['score_response'] - ['scores']['payment_abuse']['score'] == 0.97) + assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) + assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) def test_get_decisions_fails(self): with self.assertRaises(ValueError): @@ -490,8 +480,7 @@ def test_get_decisions(self): 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'user', 'limit': 10, - 'abuse_types': 'legacy,payment_abuse'}, + params={'entity_type': 'user', 'limit': 10, 'abuse_types': 'legacy,payment_abuse'}, timeout=3) self.assertIsInstance(response, sift.client.Response) @@ -538,8 +527,7 @@ def test_get_decisions_entity_session(self): 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'session', 'limit': 10, - 'abuse_types': 'account_takeover'}, + params={'entity_type': 'session', 'limit': 10, 'abuse_types': 'account_takeover'}, timeout=3) self.assertIsInstance(response, sift.client.Response) @@ -550,12 +538,12 @@ def test_apply_decision_to_user_ok(self): user_id = '54321' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - 'time': 1481569575 - } + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', + 'time': 1481569575 + } apply_decision_response_json = """ { "entity": { @@ -574,8 +562,7 @@ def test_apply_decision_to_user_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_user_decision( - user_id, apply_decision_request) + response = self.sift_client.apply_user_decision(user_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, @@ -588,14 +575,13 @@ def test_apply_decision_to_user_ok(self): def test_validate_no_user_id_string_fails(self): apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - } + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', + } with self.assertRaises(TypeError): - self.sift_client._validate_apply_decision_request( - apply_decision_request, 123) + self.sift_client._validate_apply_decision_request(apply_decision_request, 123) def test_apply_decision_to_order_fails_with_no_order_id(self): with self.assertRaises(TypeError): @@ -621,8 +607,7 @@ def test_validate_apply_decision_request_no_analyst_fails(self): } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request( - apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") def test_validate_apply_decision_request_no_source_fails(self): apply_decision_request = { @@ -631,14 +616,12 @@ def test_validate_apply_decision_request_no_source_fails(self): } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request( - apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") def test_validate_empty_apply_decision_request_fails(self): apply_decision_request = {} with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request( - apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") def test_apply_decision_manual_review_no_analyst_fails(self): user_id = '54321' @@ -649,8 +632,7 @@ def test_apply_decision_manual_review_no_analyst_fails(self): } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision( - user_id, apply_decision_request) + self.sift_client.apply_user_decision(user_id, apply_decision_request) def test_apply_decision_no_source_fails(self): user_id = '54321' @@ -660,8 +642,7 @@ def test_apply_decision_no_source_fails(self): } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision( - user_id, apply_decision_request) + self.sift_client.apply_user_decision(user_id, apply_decision_request) def test_apply_decision_invalid_source_fails(self): user_id = '54321' @@ -671,18 +652,17 @@ def test_apply_decision_invalid_source_fails(self): 'time': 1481569575 } - self.assertRaises( - ValueError, self.sift_client.apply_user_decision, user_id, apply_decision_request) + self.assertRaises(ValueError, self.sift_client.apply_user_decision, user_id, apply_decision_request) def test_apply_decision_to_order_ok(self): user_id = '54321' order_id = '43210' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'order_looks_bad_payment_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'order_looks_bad_payment_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -703,12 +683,10 @@ def test_apply_decision_to_order_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_order_decision( - user_id, order_id, apply_decision_request) + response = self.sift_client.apply_order_decision(user_id, order_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % ( - user_id, order_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id, order_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) @@ -720,10 +698,10 @@ def test_apply_decision_to_session_ok(self): session_id = 'gigtleqddo84l8cm15qe4il' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'session_looks_bad_ato', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'session_looks_bad_ato', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -744,12 +722,10 @@ def test_apply_decision_to_session_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_session_decision( - user_id, session_id, apply_decision_request) + response = self.sift_client.apply_session_decision(user_id, session_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % ( - user_id, session_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id, session_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) @@ -761,10 +737,10 @@ def test_apply_decision_to_content_ok(self): content_id = 'listing-1231' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'content_looks_bad_content_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'content_looks_bad_content_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -785,12 +761,10 @@ def test_apply_decision_to_content_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_content_decision( - user_id, content_id, apply_decision_request) + response = self.sift_client.apply_content_decision(user_id, content_id, apply_decision_request) data = json.dumps(apply_decision_request) mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % ( - user_id, content_id), + 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id, content_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) @@ -806,8 +780,7 @@ def test_label_user_ok(self): mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - response = self.sift_client.label( - user_id, valid_label_properties()) + response = self.sift_client.label(user_id, valid_label_properties()) properties = { '$abuse_type': 'content_abuse', '$is_bad': True, @@ -860,8 +833,7 @@ def test_unlabel_user_ok(self): mock_response.status_code = 204 with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: mock_delete.return_value = mock_response - response = self.sift_client.unlabel( - user_id, abuse_type='account_abuse') + response = self.sift_client.unlabel(user_id, abuse_type='account_abuse') mock_delete.assert_called_with( 'https://api.siftscience.com/v205/users/%s/labels' % user_id, headers=mock.ANY, @@ -903,8 +875,7 @@ def test_unlabel_user_with_special_chars_ok(self): mock_delete.return_value = mock_response response = self.sift_client.unlabel(user_id) mock_delete.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote( - user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), headers=mock.ANY, timeout=mock.ANY, params={'api_key': self.test_key}) @@ -932,8 +903,7 @@ def test_label_user__with_special_chars_ok(self): properties.update({'$api_key': self.test_key, '$type': '$label'}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote( - user_id), + 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), data=data, headers=mock.ANY, timeout=mock.ANY, @@ -954,8 +924,7 @@ def test_score__with_special_user_id_chars_ok(self): mock_get.return_value = mock_response response = self.sift_client.score(user_id, abuse_types=['legacy']) mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote( - user_id), + 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote(user_id), params={'api_key': self.test_key, 'abuse_types': 'legacy'}, headers=mock.ANY, timeout=mock.ANY) @@ -972,8 +941,7 @@ def test_exception_during_track_call(self): mock_post.side_effect = mock.Mock( side_effect=requests.exceptions.RequestException("Failed")) with self.assertRaises(sift.client.ApiException): - self.sift_client.track( - '$transaction', valid_transaction_properties()) + self.sift_client.track('$transaction', valid_transaction_properties()) def test_exception_during_score_call(self): warnings.simplefilter("always") @@ -1072,8 +1040,7 @@ def test_get_workflow_status(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_workflow_status( - '4zxwibludiaaa', timeout=3) + response = self.sift_client.get_workflow_status('4zxwibludiaaa', timeout=3) mock_get.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa', headers=mock.ANY, auth=mock.ANY, timeout=3) @@ -1111,8 +1078,7 @@ def test_get_user_decisions(self): self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse'] - ['decision']['id'] == 'user_decision') + assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') def test_get_order_decisions(self): mock_response = mock.Mock() @@ -1150,10 +1116,8 @@ def test_get_order_decisions(self): self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse'] - ['decision']['id'] == 'decision7') - assert(response.body['decisions']['promotion_abuse'] - ['decision']['id'] == 'good_order') + assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') + assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') def test_get_session_decisions(self): mock_response = mock.Mock() @@ -1177,16 +1141,14 @@ def test_get_session_decisions(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_session_decisions( - 'example_user', 'example_session') + response = self.sift_client.get_session_decisions('example_user', 'example_session') mock_get.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['account_takeover'] - ['decision']['id'] == 'session_decision') + assert(response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') def test_get_content_decisions(self): mock_response = mock.Mock() @@ -1210,21 +1172,18 @@ def test_get_content_decisions(self): with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_content_decisions( - 'example_user', 'example_content') + response = self.sift_client.get_content_decisions('example_user', 'example_content') mock_get.assert_called_with( 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions', headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) - assert(response.body['decisions']['content_abuse'] - ['decision']['id'] == 'content_looks_bad_content_abuse') + assert(response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') def test_provided_session(self): session = mock.Mock() - client = sift.Client(api_key=self.test_key, - account_id=self.account_id, session=session) + client = sift.Client(api_key=self.test_key, account_id=self.account_id, session=session) mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' @@ -1237,119 +1196,6 @@ def test_provided_session(self): client.track(event, valid_transaction_properties()) session.post.assert_called_once() - def test_check_ok(self): - user_id = '54321' - mock_response = mock.Mock() - check_request = { - "$user_id": user_id, - "$code": 524313, - "$verified_event": "$login", - "$verified_entity_id": "09f7f361575d11ff", - } - - check_response_json = """ - { - "error_message": "OK", - "checked_at": 1616599678245, - "http_status_code": 200 - } - """ - - mock_response.content = check_response_json - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.check(check_request) - data = json.dumps(check_request) - mock_post.assert_called_with( - 'https://api.sift.com/v1.1/verification/check', - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['error_message'] == 'OK') - - def test_send_ok(self): - mock_response = mock.Mock() - send_request = { - "$user_id": "billy_jones_301", - "$send_to": "billy_jones_301@gmail.com", - "$verification_type": "$email", - "$brand_name": "MyTopBrand", - "$language": "en", - "$event": { - "$session_id": "09f7f361575d11ff", - "$verified_event": "$login", - "$reason": "$automated_rule", - "$ip": "192.168.1.1", - "$browser": { - "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" - } - } - } - - send_response_json = """ - { - "status": 0, - "error_message": "OK", - "sent_at": 1616684387524, - "segment_id": "abf820a5-102c-46a7-826a-3001a06bf6c1", - "segment_name": "Default template", - "brand_name": "", - "site_country": "", - "content_language": "", - "http_status_code": 200 - } - """ - - mock_response.content = send_response_json - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.send(send_request) - data = json.dumps(send_request) - mock_post.assert_called_with( - 'https://api.sift.com/v1.1/verification/send', - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['error_message'] == 'OK') - - def test_resend_ok(self): - mock_response = mock.Mock() - resend_request = { - "$user_id": "billy_jones_301", - "$verified_event": "$login", - "$verified_entity_id": "SOME_SESSION_ID" - } - - resend_response_json = """ - { - "status": 0, - "error_message": "OK", - "sent_at": 1566324368002, - "http_status_code": 200 - } - """ - - mock_response.content = resend_response_json - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.resend(resend_request) - data = json.dumps(resend_request) - mock_post.assert_called_with( - 'https://api.sift.com/v1.1/verification/resend', - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) - self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['error_message'] == 'OK') - def main(): unittest.main() From 2d046297332260d3ea42e0b7872209cdb85d8a27 Mon Sep 17 00:00:00 2001 From: Jyothish Jose Date: Thu, 20 Jan 2022 16:50:01 +0530 Subject: [PATCH 054/112] python 2 urllib issue fixed --- sift/client.py | 2 +- tests/test_client.py | 2 +- tests/test_client_v203.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sift/client.py b/sift/client.py index adf690e..6bd0cd9 100644 --- a/sift/client.py +++ b/sift/client.py @@ -7,7 +7,7 @@ import requests.auth import sys if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib _UNICODE_STRING = str else: import urllib.parse diff --git a/tests/test_client.py b/tests/test_client.py index 6c0b8f1..9fb7e5e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,7 +7,7 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib else: import urllib.parse diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index a18b3d0..0da8f11 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -7,7 +7,7 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib else: import urllib.parse From a5ac2b6864137a1def8712b791ac26415081443d Mon Sep 17 00:00:00 2001 From: Jyothish Jose Date: Fri, 21 Jan 2022 18:30:25 +0530 Subject: [PATCH 055/112] python 2 urllib issue fixed --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 192d993..c0fd675 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ packages=['sift'], install_requires=[ "requests >= 0.14.1", + "six >= 1.16.0", ], extras_require={ 'test': [ From b6bef53e73ba856de66ae0b13b0b549339d82419 Mon Sep 17 00:00:00 2001 From: Mark Pierotti Date: Mon, 24 Jan 2022 16:59:41 -0800 Subject: [PATCH 056/112] 5.0.2 release --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 19e39fd..de1b963 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.0.2 2022-01-24 +- Fix usage of urllib for Python 2.7 + 5.0.1 2019-03-07 - Update metadata in setup.py diff --git a/sift/version.py b/sift/version.py index 6558a05..abef49f 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.0.1' +VERSION = '5.0.2' API_VERSION = '205' From 9cbbd1133a8a987a51231331dfa528b1c8f332d2 Mon Sep 17 00:00:00 2001 From: Mark Pierotti Date: Mon, 24 Jan 2022 17:03:17 -0800 Subject: [PATCH 057/112] Remove Travis CI badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 45bdd4d..63fc2ec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Sift Python Bindings [![Build Status](https://travis-ci.org/SiftScience/sift-python.svg?branch=master)](https://travis-ci.org/SiftScience/sift-python) +# Sift Python Bindings Bindings for Sift's APIs -- including the [Events](https://sift.com/resources/references/events-api.html), From 89f535aa16631a39e2adf1dea29e0ad5b04010bd Mon Sep 17 00:00:00 2001 From: jyothish6190 Date: Tue, 14 Jun 2022 20:03:13 +0530 Subject: [PATCH 058/112] json serialization - bug fixed --- sift/client.py | 10 ++++++++-- tests/test_client.py | 5 +++-- tests/test_client_v203.py | 5 +++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/sift/client.py b/sift/client.py index adf690e..1bac4da 100644 --- a/sift/client.py +++ b/sift/client.py @@ -2,12 +2,13 @@ See: https://siftscience.com/docs/references/events-api """ +import decimal import json import requests import requests.auth import sys if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib _UNICODE_STRING = str else: import urllib.parse @@ -26,6 +27,11 @@ def _quote_path(s): # optional arg to override this return urllib.parse.quote(s, '') +class DecimalEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, decimal.Decimal): + return (str(o),) + return super(DecimalEncoder, self).default(o) class Client(object): @@ -158,7 +164,7 @@ def track( try: response = self.session.post( path, - data=json.dumps(properties), + data=json.dumps(properties, cls=DecimalEncoder), headers=headers, timeout=timeout, params=params) diff --git a/tests/test_client.py b/tests/test_client.py index 6c0b8f1..9641567 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ import datetime +from decimal import Decimal import warnings import json import mock @@ -7,7 +8,7 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib else: import urllib.parse @@ -16,7 +17,7 @@ def valid_transaction_properties(): return { '$buyer_user_id': '123456', '$seller_user_id': '654321', - '$amount': 1253200, + '$amount': Decimal('1253200.0'), '$currency_code': 'USD', '$time': int(datetime.datetime.now().strftime('%s')), '$transaction_id': 'my_transaction_id', diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index a18b3d0..a7af5b8 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -1,4 +1,5 @@ import datetime +from decimal import Decimal import warnings import json import mock @@ -7,7 +8,7 @@ import sys import requests.exceptions if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib else: import urllib.parse @@ -16,7 +17,7 @@ def valid_transaction_properties(): return { '$buyer_user_id': '123456', '$seller_user_id': '654321', - '$amount': 1253200, + '$amount': Decimal('1253200.0'), '$currency_code': 'USD', '$time': int(datetime.datetime.now().strftime('%s')), '$transaction_id': 'my_transaction_id', From e25d453a4d316e8dc258c3f5cf17cdf30eae45cf Mon Sep 17 00:00:00 2001 From: Sasha Bogolii Date: Wed, 22 Jun 2022 15:09:03 +0300 Subject: [PATCH 059/112] API-6560: Added return_route_info param --- CHANGES.md | 3 +++ sift/client.py | 7 ++++++ sift/version.py | 2 +- tests/test_client.py | 57 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index de1b963..c4c69c6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.1.0 2022-06-22 +- Added return_route_info query parameter + 5.0.2 2022-01-24 - Fix usage of urllib for Python 2.7 diff --git a/sift/client.py b/sift/client.py index 6bd0cd9..cef3d57 100644 --- a/sift/client.py +++ b/sift/client.py @@ -79,6 +79,7 @@ def track( return_score=False, return_action=False, return_workflow_status=False, + return_route_info=False, force_workflow_run=False, abuse_types=None, timeout=None, @@ -106,6 +107,9 @@ def track( include the status of any workflow run as a result of the tracked event. + return_route_info: Whether to get the route information from the Workflow Decision. + This parameter must be used with the return_workflow_status query parameter. + force_workflow_run: TODO:(rlong) Add after Rishabh adds documentation. abuse_types(optional): List of abuse types, specifying for which abuse types a score @@ -152,6 +156,9 @@ def track( if return_workflow_status: params['return_workflow_status'] = 'true' + if return_route_info: + params['return_route_info'] = 'true' + if force_workflow_run: params['force_workflow_run'] = 'true' diff --git a/sift/version.py b/sift/version.py index abef49f..3435dbb 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.0.2' +VERSION = '5.1.0' API_VERSION = '205' diff --git a/tests/test_client.py b/tests/test_client.py index 9fb7e5e..0829e4e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,11 +1,14 @@ import datetime -import warnings import json -import mock -import sift -import unittest import sys +import unittest +import warnings + +import mock import requests.exceptions + +import sift + if sys.version_info[0] < 3: import six.moves.urllib as urllib else: @@ -73,6 +76,23 @@ def score_response_json(): }""" +def workflow_statuses_json(): + return """ { + "route" : { + "name" : "my route" + } + "history": [ + { + "app": "decision", + "name": "Order Looks OK", + "state": "running", + "config": { + "decision_id": "order_looks_ok_payment_abuse" + } + } + ] + }""" + # A sample response from the /{version}/users/{userId}/score API. USER_SCORE_RESPONSE_JSON = """{ "status": 0, @@ -435,6 +455,35 @@ def test_sync_score_ok(self): assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) + def test_sync_workflow_ok(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = ('{"status": 0, "error_message": "OK", "workflow_statuses": %s}' + % workflow_statuses_json()) + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track( + event, + valid_transaction_properties(), + return_workflow_status=True, + return_route_info=True, + abuse_types=['payment_abuse', 'content_abuse', 'legacy']) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={'return_workflow_status': 'true', 'return_route_info': 'true', + 'abuse_types': 'payment_abuse,content_abuse,legacy'}) + self.assertIsInstance(response, sift.client.Response) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + assert(response.body['workflow_statuses']['route']['name'] == 'my route') + def test_get_decisions_fails(self): with self.assertRaises(ValueError): self.sift_client.get_decisions('usr') From 308a340d2f516030518adafd04fddbbafceefd39 Mon Sep 17 00:00:00 2001 From: Sasha Bogolii Date: Wed, 22 Jun 2022 15:23:07 +0300 Subject: [PATCH 060/112] API-6560: Fixed test mock response --- tests/test_client.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 0829e4e..bb32ce3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -77,20 +77,20 @@ def score_response_json(): def workflow_statuses_json(): - return """ { - "route" : { - "name" : "my route" + return """{ + "route" : { + "name" : "my route" + }, + "history": [ + { + "app": "decision", + "name": "Order Looks OK", + "state": "running", + "config": { + "decision_id": "order_looks_ok_payment_abuse" + } } - "history": [ - { - "app": "decision", - "name": "Order Looks OK", - "state": "running", - "config": { - "decision_id": "order_looks_ok_payment_abuse" - } - } - ] + ] }""" # A sample response from the /{version}/users/{userId}/score API. From 83d20b3af6ad38abe40987973f1e489e93f2d831 Mon Sep 17 00:00:00 2001 From: Brian Ho Date: Fri, 24 Jun 2022 12:54:05 -0700 Subject: [PATCH 061/112] modify CHANGES.md for 5.1.0 release --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index c4c69c6..4b31bce 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ 5.1.0 2022-06-22 - Added return_route_info query parameter +- Fix Json Serialization bug 5.0.2 2022-01-24 - Fix usage of urllib for Python 2.7 From 5f8e9b077e53dfe060b79b7c2012d0f394a35459 Mon Sep 17 00:00:00 2001 From: Brian Ho Date: Fri, 24 Jun 2022 13:16:09 -0700 Subject: [PATCH 062/112] more descriptive change --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 4b31bce..fe23ca9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ 5.1.0 2022-06-22 - Added return_route_info query parameter -- Fix Json Serialization bug +- Fixed decimal amount json serialization bug 5.0.2 2022-01-24 - Fix usage of urllib for Python 2.7 From fa227c04e2ac9e61afe777ec71d2c85ed1c87101 Mon Sep 17 00:00:00 2001 From: jyothish6190 Date: Thu, 27 Oct 2022 12:00:58 +0530 Subject: [PATCH 063/112] feat: Update client libraries with PSP Merchant Management API --- sift/client.py | 131 ++++++++++++- tests/test_client.py | 448 ++++++++++++++++++++++++++++++------------- 2 files changed, 446 insertions(+), 133 deletions(-) diff --git a/sift/client.py b/sift/client.py index adf690e..dd9565f 100644 --- a/sift/client.py +++ b/sift/client.py @@ -2,12 +2,13 @@ See: https://siftscience.com/docs/references/events-api """ +import decimal import json import requests import requests.auth import sys if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib _UNICODE_STRING = str else: import urllib.parse @@ -26,6 +27,11 @@ def _quote_path(s): # optional arg to override this return urllib.parse.quote(s, '') +class DecimalEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, decimal.Decimal): + return (str(o),) + return super(DecimalEncoder, self).default(o) class Client(object): @@ -79,6 +85,7 @@ def track( return_score=False, return_action=False, return_workflow_status=False, + return_route_info=False, force_workflow_run=False, abuse_types=None, timeout=None, @@ -106,6 +113,9 @@ def track( include the status of any workflow run as a result of the tracked event. + return_route_info: Whether to get the route information from the Workflow Decision. + This parameter must be used with the return_workflow_status query parameter. + force_workflow_run: TODO:(rlong) Add after Rishabh adds documentation. abuse_types(optional): List of abuse types, specifying for which abuse types a score @@ -152,13 +162,16 @@ def track( if return_workflow_status: params['return_workflow_status'] = 'true' + if return_route_info: + params['return_route_info'] = 'true' + if force_workflow_run: params['force_workflow_run'] = 'true' try: response = self.session.post( path, - data=json.dumps(properties), + data=json.dumps(properties, cls=DecimalEncoder), headers=headers, timeout=timeout, params=params) @@ -723,6 +736,112 @@ def apply_content_decision(self, user_id, content_id, properties, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e), url) + def create_psp_merchant_profile(self, properties, timeout=None): + """Create a new PSP Merchant profile + Args: + properties: A dict of merchant profile data. + Returns + A sift.client.Response object if the call succeeded, else raises an ApiException + """ + + if timeout is None: + timeout = self.timeout + + url = self._psp_merchant_url(self.account_id) + + try: + return Response(self.session.post( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def update_psp_merchant_profile(self, merchant_id, properties, timeout=None): + """Update already existing PSP Merchant profile + Args: + merchant_id: id of merchant + properties: A dict of merchant profile data. + Returns + A sift.client.Response object if the call succeeded, else raises an ApiException + """ + + if timeout is None: + timeout = self.timeout + + url = self._psp_merchant_id_url(self.account_id, merchant_id) + + try: + return Response(self.session.put( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def get_psp_merchant_profiles(self, batch_token=None, batch_size=None, timeout=None): + """Gets all PSP merchant profiles. + + Returns: + A sift.client.Response object if the call succeeded. + Otherwise, raises an ApiException. + """ + + if timeout is None: + timeout = self.timeout + + url = self._psp_merchant_url(self.account_id) + params = {} + + if batch_size: + params['batch_size'] = batch_size + + if batch_token: + params['batch_token'] = batch_token + try: + return Response(self.session.get( + url, + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, + params=params, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def get_a_psp_merchant_profile(self, merchant_id, timeout=None): + """Gets a PSP merchant profile using merchant id. + + Returns: + A sift.client.Response object if the call succeeded. + Otherwise, raises an ApiException. + """ + + if timeout is None: + timeout = self.timeout + + url = self._psp_merchant_id_url(self.account_id, merchant_id) + + try: + return Response(self.session.get( + url, + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'User-Agent': self._user_agent()}, + timeout=timeout)) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -773,6 +892,14 @@ def _content_apply_decisions_url(self, account_id, user_id, content_id): return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) + def _psp_merchant_url(self, account_id): + return (self.url + '/v3/accounts/%s/psp_management/merchants' % + (_quote_path(account_id))) + + def _psp_merchant_id_url(self, account_id, merchant_id): + return (self.url + '/v3/accounts/%s/psp_management/merchants/%s' % + (_quote_path(account_id), _quote_path(merchant_id))) + class Response(object): diff --git a/tests/test_client.py b/tests/test_client.py index 6c0b8f1..58c5883 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,13 +1,17 @@ import datetime -import warnings import json -import mock -import sift -import unittest import sys +import unittest +import warnings +from decimal import Decimal + +import mock import requests.exceptions + +import sift + if sys.version_info[0] < 3: - import urllib.request, urllib.parse, urllib.error + import six.moves.urllib as urllib else: import urllib.parse @@ -16,7 +20,7 @@ def valid_transaction_properties(): return { '$buyer_user_id': '123456', '$seller_user_id': '654321', - '$amount': 1253200, + '$amount': Decimal('1253200.0'), '$currency_code': 'USD', '$time': int(datetime.datetime.now().strftime('%s')), '$transaction_id': 'my_transaction_id', @@ -42,6 +46,56 @@ def valid_label_properties(): } +def valid_psp_merchant_properties(): + return { + "$id": "api-key-1", + "$name": "Wonderful Payments Inc.", + "$description": "Wonderful Payments payment provider.", + "$address": { + "$name": "Alany", + "$address_1": "Big Payment blvd, 22", + "$address_2": "apt, 8", + "$city": "New Orleans", + "$region": "NA", + "$country": "US", + "$zipcode": "76830", + "$phone": "0394888320", + }, + "$category": "1002", + "$service_level": "Platinum", + "$status": "active", + "$risk_profile": { + "$level": "low", + "$score": 10 + } + } + + +def valid_psp_merchant_properties_response(): + return """{ + "id":"api-key-1", + "name": "Wonderful Payments Inc.", + "description": "Wonderful Payments payment provider.", + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": { + "level": "low", + "score": "10" + }, + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320" + } + }""" + + def score_response_json(): return """{ "status": 0, @@ -73,6 +127,24 @@ def score_response_json(): }""" +def workflow_statuses_json(): + return """{ + "route" : { + "name" : "my route" + }, + "history": [ + { + "app": "decision", + "name": "Order Looks OK", + "state": "running", + "config": { + "decision_id": "order_looks_ok_payment_abuse" + } + } + ] + }""" + + # A sample response from the /{version}/users/{userId}/score API. USER_SCORE_RESPONSE_JSON = """{ "status": 0, @@ -179,13 +251,13 @@ def test_global_api_key(self): client2 = sift.Client(local_api_key) # test that global api key is assigned - assert(client1.api_key == sift.api_key) + assert (client1.api_key == sift.api_key) # test that local api key is assigned - assert(client2.api_key == local_api_key) + assert (client2.api_key == local_api_key) client2 = sift.Client() # test that client2 is assigned a new object with global api_key - assert(client2.api_key == sift.api_key) + assert (client2.api_key == sift.api_key) def test_constructor_requires_valid_api_key(self): self.assertRaises(TypeError, sift.Client, None) @@ -232,9 +304,9 @@ def test_event_ok(self): timeout=mock.ANY, params={}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") def test_event_with_timeout_param_ok(self): event = '$transaction' @@ -255,9 +327,9 @@ def test_event_with_timeout_param_ok(self): timeout=test_timeout, params={}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") def test_score_ok(self): mock_response = mock.Mock() @@ -274,11 +346,11 @@ def test_score_ok(self): headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.85) - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['score'] == 0.85) + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) def test_score_with_timeout_param_ok(self): test_timeout = 5 @@ -296,11 +368,11 @@ def test_score_with_timeout_param_ok(self): headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.85) - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['score'] == 0.85) + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) def test_get_user_score_ok(self): """Test the GET /{version}/users/{userId}/score API, i.e. client.get_user_score() @@ -320,12 +392,12 @@ def test_get_user_score_ok(self): headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['entity_id'] == '12345') + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) + assert ('latest_decisions' in response.body) def test_get_user_score_with_abuse_types_ok(self): """Test the GET /{version}/users/{userId}/score?abuse_types=... API, i.e. client.get_user_score() @@ -347,12 +419,12 @@ def test_get_user_score_with_abuse_types_ok(self): headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['entity_id'] == '12345') + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) + assert ('latest_decisions' in response.body) def test_rescore_user_ok(self): """Test the POST /{version}/users/{userId}/score API, i.e. client.rescore_user() @@ -372,12 +444,12 @@ def test_rescore_user_ok(self): headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['entity_id'] == '12345') + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) + assert ('latest_decisions' in response.body) def test_rescore_user_with_abuse_types_ok(self): """Test the POST /{version}/users/{userId}/score?abuse_types=... API, i.e. client.rescore_user() @@ -399,12 +471,12 @@ def test_rescore_user_with_abuse_types_ok(self): headers=mock.ANY, timeout=test_timeout) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['entity_id'] == '12345') - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) - assert('latest_decisions' in response.body) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['entity_id'] == '12345') + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) + assert ('latest_decisions' in response.body) def test_sync_score_ok(self): event = '$transaction' @@ -428,12 +500,41 @@ def test_sync_score_ok(self): timeout=mock.ANY, params={'return_score': 'true', 'abuse_types': 'payment_abuse,content_abuse,legacy'}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - assert(response.body['score_response']['score'] == 0.85) - assert(response.body['score_response']['scores']['content_abuse']['score'] == 0.14) - assert(response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") + assert (response.body['score_response']['score'] == 0.85) + assert (response.body['score_response']['scores']['content_abuse']['score'] == 0.14) + assert (response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) + + def test_sync_workflow_ok(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = ('{"status": 0, "error_message": "OK", "workflow_statuses": %s}' + % workflow_statuses_json()) + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track( + event, + valid_transaction_properties(), + return_workflow_status=True, + return_route_info=True, + abuse_types=['payment_abuse', 'content_abuse', 'legacy']) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={'return_workflow_status': 'true', 'return_route_info': 'true', + 'abuse_types': 'payment_abuse,content_abuse,legacy'}) + self.assertIsInstance(response, sift.client.Response) + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") + assert (response.body['workflow_statuses']['route']['name'] == 'my route') def test_get_decisions_fails(self): with self.assertRaises(ValueError): @@ -484,8 +585,8 @@ def test_get_decisions(self): timeout=3) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['data'][0]['id'] == 'block_user') + assert (response.is_ok()) + assert (response.body['data'][0]['id'] == 'block_user') def test_get_decisions_entity_session(self): mock_response = mock.Mock() @@ -531,19 +632,19 @@ def test_get_decisions_entity_session(self): timeout=3) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['data'][0]['id'] == 'block_session') + assert (response.is_ok()) + assert (response.body['data'][0]['id'] == 'block_session') def test_apply_decision_to_user_ok(self): user_id = '54321' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - 'time': 1481569575 - } + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', + 'time': 1481569575 + } apply_decision_response_json = """ { "entity": { @@ -569,17 +670,17 @@ def test_apply_decision_to_user_ok(self): auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.body['entity']['type'] == 'user') - assert(response.http_status_code == 200) - assert(response.is_ok()) + assert (response.body['entity']['type'] == 'user') + assert (response.http_status_code == 200) + assert (response.is_ok()) def test_validate_no_user_id_string_fails(self): apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - } + 'decision_id': 'user_looks_ok_legacy', + 'source': 'MANUAL_REVIEW', + 'analyst': 'analyst@biz.com', + 'description': 'called user and verified account', + } with self.assertRaises(TypeError): self.sift_client._validate_apply_decision_request(apply_decision_request, 123) @@ -659,10 +760,10 @@ def test_apply_decision_to_order_ok(self): order_id = '43210' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'order_looks_bad_payment_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'order_looks_bad_payment_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -689,19 +790,19 @@ def test_apply_decision_to_order_ok(self): 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id, order_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.http_status_code == 200) - assert(response.body['entity']['type'] == 'order') + assert (response.is_ok()) + assert (response.http_status_code == 200) + assert (response.body['entity']['type'] == 'order') def test_apply_decision_to_session_ok(self): user_id = '54321' session_id = 'gigtleqddo84l8cm15qe4il' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'session_looks_bad_ato', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'session_looks_bad_ato', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -728,19 +829,19 @@ def test_apply_decision_to_session_ok(self): 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id, session_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.http_status_code == 200) - assert(response.body['entity']['type'] == 'login') + assert (response.is_ok()) + assert (response.http_status_code == 200) + assert (response.body['entity']['type'] == 'login') def test_apply_decision_to_content_ok(self): user_id = '54321' content_id = 'listing-1231' mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'content_looks_bad_content_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 - } + 'decision_id': 'content_looks_bad_content_abuse', + 'source': 'AUTOMATED_RULE', + 'time': 1481569575 + } apply_decision_response_json = """ { @@ -767,9 +868,9 @@ def test_apply_decision_to_content_ok(self): 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id, content_id), auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.http_status_code == 200) - assert(response.body['entity']['type'] == 'create_content') + assert (response.is_ok()) + assert (response.http_status_code == 200) + assert (response.body['entity']['type'] == 'create_content') def test_label_user_ok(self): user_id = '54321' @@ -794,9 +895,9 @@ def test_label_user_ok(self): 'https://api.siftscience.com/v205/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=mock.ANY, params={}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") def test_label_user_with_timeout_param_ok(self): user_id = '54321' @@ -823,9 +924,9 @@ def test_label_user_with_timeout_param_ok(self): 'https://api.siftscience.com/v205/users/%s/labels' % user_id, data=data, headers=mock.ANY, timeout=test_timeout, params={}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") def test_unlabel_user_ok(self): user_id = '54321' @@ -840,7 +941,7 @@ def test_unlabel_user_ok(self): timeout=mock.ANY, params={'api_key': self.test_key, 'abuse_type': 'account_abuse'}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) + assert (response.is_ok()) def test_unicode_string_parameter_support(self): # str is unicode in python 3, so no need to check as this was covered @@ -856,15 +957,15 @@ def test_unicode_string_parameter_support(self): with mock.patch.object(self.sift_client.session, 'post') as mock_post: mock_post.return_value = mock_response - assert(self.sift_client.track( + assert (self.sift_client.track( '$transaction', valid_transaction_properties())) - assert(self.sift_client.label( + assert (self.sift_client.label( user_id, valid_label_properties())) with mock.patch.object(self.sift_client.session, 'get') as mock_get: mock_get.return_value = mock_response - assert(self.sift_client.score( + assert (self.sift_client.score( user_id, abuse_types=['payment_abuse', 'content_abuse'])) def test_unlabel_user_with_special_chars_ok(self): @@ -880,7 +981,7 @@ def test_unlabel_user_with_special_chars_ok(self): timeout=mock.ANY, params={'api_key': self.test_key}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) + assert (response.is_ok()) def test_label_user__with_special_chars_ok(self): user_id = '54321=.-_+@:&^%!$' @@ -909,9 +1010,9 @@ def test_label_user__with_special_chars_ok(self): timeout=mock.ANY, params={}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") def test_score__with_special_user_id_chars_ok(self): user_id = '54321=.-_+@:&^%!$' @@ -929,11 +1030,11 @@ def test_score__with_special_user_id_chars_ok(self): headers=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.85) - assert(response.body['scores']['content_abuse']['score'] == 0.14) - assert(response.body['scores']['payment_abuse']['score'] == 0.97) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['score'] == 0.85) + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) def test_exception_during_track_call(self): warnings.simplefilter("always") @@ -981,15 +1082,15 @@ def test_return_actions_on_track(self): params={'return_action': 'true'}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") actions = response.body["score_response"]['actions'] - assert(actions) - assert(actions[0]['action']) - assert(actions[0]['action']['id'] == 'freds_action') - assert(actions[0]['triggers']) + assert (actions) + assert (actions[0]['action']) + assert (actions[0]['action']['id'] == 'freds_action') + assert (actions[0]['triggers']) def test_get_workflow_status(self): mock_response = mock.Mock() @@ -1046,8 +1147,8 @@ def test_get_workflow_status(self): headers=mock.ANY, auth=mock.ANY, timeout=3) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['state'] == 'running') + assert (response.is_ok()) + assert (response.body['state'] == 'running') def test_get_user_decisions(self): mock_response = mock.Mock() @@ -1077,8 +1178,8 @@ def test_get_user_decisions(self): headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') + assert (response.is_ok()) + assert (response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') def test_get_order_decisions(self): mock_response = mock.Mock() @@ -1115,9 +1216,9 @@ def test_get_order_decisions(self): headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') - assert(response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') + assert (response.is_ok()) + assert (response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') + assert (response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') def test_get_session_decisions(self): mock_response = mock.Mock() @@ -1147,8 +1248,8 @@ def test_get_session_decisions(self): headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') + assert (response.is_ok()) + assert (response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') def test_get_content_decisions(self): mock_response = mock.Mock() @@ -1178,8 +1279,8 @@ def test_get_content_decisions(self): headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') + assert (response.is_ok()) + assert (response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') def test_provided_session(self): session = mock.Mock() @@ -1196,6 +1297,91 @@ def test_provided_session(self): client.track(event, valid_transaction_properties()) session.post.assert_called_once() + def test_get_psp_merchant_profile(self): + """Test the GET /{version}/accounts/{accountId}/scorepsp_management/merchants?batch_type=...""" + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'get') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.get_psp_merchant_profiles( + timeout=test_timeout) + mock_post.assert_called_with( + 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants', + params={}, + headers=mock.ANY, auth=mock.ANY, + timeout=test_timeout) + self.assertIsInstance(response, sift.client.Response) + assert ('address' in response.body) + + def test_get_psp_merchant_profile_id(self): + """Test the GET /{version}/accounts/{accountId}/scorepsp_management/merchants/{merchantId} + """ + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'get') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.get_a_psp_merchant_profile( + merchant_id='api-key-1', timeout=test_timeout) + mock_post.assert_called_with( + 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants/api-key-1', + headers=mock.ANY, + auth=mock.ANY, + timeout=test_timeout) + self.assertIsInstance(response, sift.client.Response) + assert ('address' in response.body) + + def test_create_psp_merchant_profile(self): + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.create_psp_merchant_profile( + valid_psp_merchant_properties()) + mock_post.assert_called_with( + 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants', + data=json.dumps(valid_psp_merchant_properties()), + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY) + + self.assertIsInstance(response, sift.client.Response) + assert ('address' in response.body) + + def test_update_psp_merchant_profile(self): + mock_response = mock.Mock() + mock_response.content = valid_psp_merchant_properties_response() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + + with mock.patch.object(self.sift_client.session, 'put') as mock_post: + mock_post.return_value = mock_response + + response = self.sift_client.update_psp_merchant_profile('api-key-1', + valid_psp_merchant_properties()) + mock_post.assert_called_with( + 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants/api-key-1', + data=json.dumps(valid_psp_merchant_properties()), + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY) + + self.assertIsInstance(response, sift.client.Response) + assert ('address' in response.body) + def main(): unittest.main() From af235967e84d31be3a1daffadbc351cd724f84f0 Mon Sep 17 00:00:00 2001 From: sbogolii-sift Date: Mon, 7 Nov 2022 17:16:45 +0200 Subject: [PATCH 064/112] API-6867: Updated version --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index fe23ca9..fbb1207 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.2.0 2022-11-07 +- Update PSP Merchant Management API + 5.1.0 2022-06-22 - Added return_route_info query parameter - Fixed decimal amount json serialization bug diff --git a/sift/version.py b/sift/version.py index 3435dbb..a3aeddc 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.1.0' +VERSION = '5.2.0' API_VERSION = '205' From cccf16efabdecb7937bc0db57e44c1ffcd49c5c6 Mon Sep 17 00:00:00 2001 From: Mark Pierotti Date: Wed, 9 Nov 2022 19:22:11 -0500 Subject: [PATCH 065/112] Specify proper markdown and content type in setup script --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index c0fd675..6d35a62 100644 --- a/setup.py +++ b/setup.py @@ -10,8 +10,8 @@ here = os.path.abspath(os.path.dirname(__file__)) try: - README = open(os.path.join(here, 'README.rst')).read() - CHANGES = open(os.path.join(here, 'CHANGES.rst')).read() + README = open(os.path.join(here, 'README.md')).read() + CHANGES = open(os.path.join(here, 'CHANGES.md')).read() except Exception: README = '' CHANGES = '' @@ -28,6 +28,7 @@ author='Sift Science', author_email='support@siftscience.com', + long_description_content_type="text/markdown", long_description=README + '\n\n' + CHANGES, packages=['sift'], From 01d25558a6cdb473636e1693f15a8abeda4318b7 Mon Sep 17 00:00:00 2001 From: ssalamakha Date: Mon, 19 Dec 2022 19:49:41 +0100 Subject: [PATCH 066/112] Add workflow to push package to PyPI --- .github/workflows/publishing2PyPI.yml | 154 ++++++++++++++++++++++++++ .gitignore | 2 + 2 files changed, 156 insertions(+) create mode 100644 .github/workflows/publishing2PyPI.yml diff --git a/.github/workflows/publishing2PyPI.yml b/.github/workflows/publishing2PyPI.yml new file mode 100644 index 0000000..1883eca --- /dev/null +++ b/.github/workflows/publishing2PyPI.yml @@ -0,0 +1,154 @@ +name: publishing2PyPI +on: + workflow_dispatch: + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + check_version: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - name: Get remote tags + run: | + TAGS=NOT_SET + TAGS=$(gh api \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository_owner }}/${{ github.event.repository.name }}/git/matching-refs/ | jq -c '.[] | select (.ref | startswith("refs/tags"))' | jq -c '.ref' | cut -d'/' -f3 | cut -d'"' -f1) + [[ $TAGS == "NOT_SET" ]] && echo "Failed to set Remote Tags" && exit 1 + echo "remote_tags=$(echo $TAGS)" >> $GITHUB_ENV + - name: Get package version + run: | + VERSION=NOT_SET + VERSION=$(cat ./sift/version.py | grep -E -i '^VERSION.*' | cut -d'=' -f2 | cut -d\' -f2) + [[ $VERSION == "NOT_SET" ]] && echo "Version in version.py NOT_SET" && exit 1 + echo "curr_version=$(echo $VERSION)" >> $GITHUB_ENV + mkdir -p ./curr_version + echo "curr_version=$(echo $VERSION)" > ./curr_version/curr_version.txt + - uses: actions/upload-artifact@v2 + with: + name: curr_version + path: ./curr_version/curr_version.txt + - name: Compare package version and remote tags + run: | + for TAG in $remote_tags; do + if [[ $TAG == *"$curr_version"* ]]; then + echo "Version $curr_version alredy exists in $TAG" + exit 1 + fi + done + upload_on_test_PYPI: + needs: check_version + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - name: Configure pypirc + run: | + cat << EOF > ~/.pypirc + [distutils] + index-servers = + testpypi + [testpypi] + username=${{ secrets.USER_T }} + password=${{ secrets.PASS_T }} + EOF + - name: Create distribution files + run: | + python3 setup.py sdist + - uses: actions/download-artifact@v2 + with: + name: curr_version + - name: Save curr_version from file to env var + run: | + v=$(cat ./curr_version.txt | grep curr_version | cut -d'=' -f2) + echo "curr_version=$(echo $v)" >> $GITHUB_ENV + - name: Upload distribution files + run: | + python3 -m pip install --user --upgrade twine + ls dist/ | xargs -I % python3 -m twine upload --repository testpypi dist/% + - name: Download and check new package version + run: | + pip3 install -i https://test.pypi.org/simple/ Sift + pip3 show Sift | grep "Version: $curr_version" > /dev/null + if [ $? != 0 ]; then echo "packege with version $curr_version does not exist"; fi + upload_on_PYPI: + needs: upload_on_test_PYPI + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - name: Configure pypirc + run: | + cat << EOF > ~/.pypirc + [distutils] + index-servers = + pypi + [pypi] + username=${{ secrets.USER }} + password=${{ secrets.PASS }} + EOF + - name: Create distribution files + run: | + python3 setup.py sdist + - uses: actions/download-artifact@v2 + with: + name: curr_version + - name: Save curr_version from file to env var + run: | + v=$(cat ./curr_version.txt | grep curr_version | cut -d'=' -f2) + echo "curr_version=$(echo $v)" >> $GITHUB_ENV + - name: Upload distribution files + run: | + python3 -m pip install --user --upgrade twine + ls dist/ | xargs -I % python3 -m twine upload --repository pypi dist/% + - name: Download and check new package version + run: | + pip3 install Sift + pip3 show Sift | grep "Version: $curr_version" > /dev/null + if [ $? != 0 ]; then echo "packege with version $curr_version does not exist"; fi + create_release: + needs: upload_on_PYPI + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - uses: actions/download-artifact@v2 + with: + name: curr_version + - name: Save curr_version from file to env var + run: | + v=$(cat ./curr_version.txt | grep curr_version | cut -d'=' -f2) + echo "curr_version=$(echo $v)" >> $GITHUB_ENV + - name: Read Changes + run: | + VERSION=${{ env.curr_version }} + start='false' + pattern="^(\d+\.?){3,}\s.*" + out="" + while read -r line; do + if [[ $start == 'true' ]]; then + echo $line | grep -P $pattern > /dev/null && break + if [[ ! -z "$line" ]]; then + ln=$(echo $line | cut -d'-' -f2) + out="${out}${ln}
" + fi + fi + [[ $line == $VERSION* ]] && start='true' + done < ./CHANGES.md + + echo "changes=$(echo $out)" >> $GITHUB_ENV + - name: Create Release + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository_owner }}/${{ github.event.repository.name }}/releases \ + -f tag_name="v${{ env.curr_version }}" \ + -f name="Version ${{ env.curr_version }}" \ + -f body="$changes" \ + -F draft=false \ + -F prerelease=false \ + -F generate_release_notes=false \ No newline at end of file diff --git a/.gitignore b/.gitignore index d2d6f36..15fafde 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ nosetests.xml .mr.developer.cfg .project .pydevproject + +.DS_Store From 2057ebb43870b80e9ea61025c870264e90f55998 Mon Sep 17 00:00:00 2001 From: ssalamakha Date: Thu, 12 Jan 2023 15:28:33 +0100 Subject: [PATCH 067/112] Update workflow steps --- .github/workflows/publishing2PyPI.yml | 132 +++----------------------- 1 file changed, 11 insertions(+), 121 deletions(-) diff --git a/.github/workflows/publishing2PyPI.yml b/.github/workflows/publishing2PyPI.yml index 1883eca..871d813 100644 --- a/.github/workflows/publishing2PyPI.yml +++ b/.github/workflows/publishing2PyPI.yml @@ -1,85 +1,30 @@ name: publishing2PyPI on: - workflow_dispatch: + release: + types: [published] env: GH_TOKEN: ${{ github.token }} jobs: - check_version: - runs-on: ubuntu-latest + build_and_publish: + runs-on: ubuntu-latest steps: - name: Check out repository code uses: actions/checkout@v3 - - name: Get remote tags - run: | - TAGS=NOT_SET - TAGS=$(gh api \ - -H "Accept: application/vnd.github+json" \ - /repos/${{ github.repository_owner }}/${{ github.event.repository.name }}/git/matching-refs/ | jq -c '.[] | select (.ref | startswith("refs/tags"))' | jq -c '.ref' | cut -d'/' -f3 | cut -d'"' -f1) - [[ $TAGS == "NOT_SET" ]] && echo "Failed to set Remote Tags" && exit 1 - echo "remote_tags=$(echo $TAGS)" >> $GITHUB_ENV - name: Get package version run: | VERSION=NOT_SET VERSION=$(cat ./sift/version.py | grep -E -i '^VERSION.*' | cut -d'=' -f2 | cut -d\' -f2) [[ $VERSION == "NOT_SET" ]] && echo "Version in version.py NOT_SET" && exit 1 echo "curr_version=$(echo $VERSION)" >> $GITHUB_ENV - mkdir -p ./curr_version - echo "curr_version=$(echo $VERSION)" > ./curr_version/curr_version.txt - - uses: actions/upload-artifact@v2 - with: - name: curr_version - path: ./curr_version/curr_version.txt - - name: Compare package version and remote tags + - name: Compare package version and Releas tag run: | - for TAG in $remote_tags; do - if [[ $TAG == *"$curr_version"* ]]; then - echo "Version $curr_version alredy exists in $TAG" - exit 1 - fi - done - upload_on_test_PYPI: - needs: check_version - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v3 - - name: Configure pypirc - run: | - cat << EOF > ~/.pypirc - [distutils] - index-servers = - testpypi - [testpypi] - username=${{ secrets.USER_T }} - password=${{ secrets.PASS_T }} - EOF - - name: Create distribution files - run: | - python3 setup.py sdist - - uses: actions/download-artifact@v2 - with: - name: curr_version - - name: Save curr_version from file to env var - run: | - v=$(cat ./curr_version.txt | grep curr_version | cut -d'=' -f2) - echo "curr_version=$(echo $v)" >> $GITHUB_ENV - - name: Upload distribution files - run: | - python3 -m pip install --user --upgrade twine - ls dist/ | xargs -I % python3 -m twine upload --repository testpypi dist/% - - name: Download and check new package version - run: | - pip3 install -i https://test.pypi.org/simple/ Sift - pip3 show Sift | grep "Version: $curr_version" > /dev/null - if [ $? != 0 ]; then echo "packege with version $curr_version does not exist"; fi - upload_on_PYPI: - needs: upload_on_test_PYPI - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v3 + TAG=${GITHUB_REF##*/} + if [[ $TAG != *"$curr_version"* ]]; then + echo "Version $curr_version does not match tag $TAG" + exit 1 + fi - name: Configure pypirc run: | cat << EOF > ~/.pypirc @@ -93,62 +38,7 @@ jobs: - name: Create distribution files run: | python3 setup.py sdist - - uses: actions/download-artifact@v2 - with: - name: curr_version - - name: Save curr_version from file to env var - run: | - v=$(cat ./curr_version.txt | grep curr_version | cut -d'=' -f2) - echo "curr_version=$(echo $v)" >> $GITHUB_ENV - name: Upload distribution files run: | python3 -m pip install --user --upgrade twine - ls dist/ | xargs -I % python3 -m twine upload --repository pypi dist/% - - name: Download and check new package version - run: | - pip3 install Sift - pip3 show Sift | grep "Version: $curr_version" > /dev/null - if [ $? != 0 ]; then echo "packege with version $curr_version does not exist"; fi - create_release: - needs: upload_on_PYPI - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v3 - - uses: actions/download-artifact@v2 - with: - name: curr_version - - name: Save curr_version from file to env var - run: | - v=$(cat ./curr_version.txt | grep curr_version | cut -d'=' -f2) - echo "curr_version=$(echo $v)" >> $GITHUB_ENV - - name: Read Changes - run: | - VERSION=${{ env.curr_version }} - start='false' - pattern="^(\d+\.?){3,}\s.*" - out="" - while read -r line; do - if [[ $start == 'true' ]]; then - echo $line | grep -P $pattern > /dev/null && break - if [[ ! -z "$line" ]]; then - ln=$(echo $line | cut -d'-' -f2) - out="${out}${ln}
" - fi - fi - [[ $line == $VERSION* ]] && start='true' - done < ./CHANGES.md - - echo "changes=$(echo $out)" >> $GITHUB_ENV - - name: Create Release - run: | - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - /repos/${{ github.repository_owner }}/${{ github.event.repository.name }}/releases \ - -f tag_name="v${{ env.curr_version }}" \ - -f name="Version ${{ env.curr_version }}" \ - -f body="$changes" \ - -F draft=false \ - -F prerelease=false \ - -F generate_release_notes=false \ No newline at end of file + ls dist/ | xargs -I % python3 -m twine upload --repository pypi dist/% \ No newline at end of file From a235fd02f712e2b7a1ecbcfd3d870a4837563dec Mon Sep 17 00:00:00 2001 From: ansukoshy-ima360 Date: Fri, 3 Feb 2023 16:48:23 +0530 Subject: [PATCH 068/112] msue-147: Update sift-python score_percentiles --- sift/client.py | 21 +++++++++++++----- tests/test_client.py | 53 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/sift/client.py b/sift/client.py index dd9565f..2a86c7c 100644 --- a/sift/client.py +++ b/sift/client.py @@ -7,11 +7,14 @@ import requests import requests.auth import sys + if sys.version_info[0] < 3: - import six.moves.urllib as urllib + import six.moves.urllib as urllib + _UNICODE_STRING = str else: import urllib.parse + _UNICODE_STRING = str import sift @@ -27,12 +30,14 @@ def _quote_path(s): # optional arg to override this return urllib.parse.quote(s, '') + class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, decimal.Decimal): return (str(o),) return super(DecimalEncoder, self).default(o) + class Client(object): def __init__( @@ -89,7 +94,8 @@ def track( force_workflow_run=False, abuse_types=None, timeout=None, - version=None): + version=None, + include_score_percentiles=False): """Track an event and associated properties to the Sift Science client. This call is blocking. Check out https://siftscience.com/resources/references/events-api for more information on what types of events you can send and fields you can add to the @@ -126,6 +132,9 @@ def track( version(optional): Use a different version of the Sift Science API for this call. + include_score_percentiles(optional) : Whether to add new parameter in the query parameter. + if include_score_percentiles is true then add a new parameter called fields in the query parameter + Returns: A sift.client.Response object if the track call succeeded, otherwise raises an ApiException. @@ -168,6 +177,10 @@ def track( if force_workflow_run: params['force_workflow_run'] = 'true' + if include_score_percentiles: + field_types = ['SCORE_PERCENTILES'] + params['fields'] = ','.join(field_types) + try: response = self.session.post( path, @@ -841,7 +854,6 @@ def get_a_psp_merchant_profile(self, merchant_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -902,7 +914,6 @@ def _psp_merchant_id_url(self, account_id, merchant_id): class Response(object): - HTTP_CODES_WITHOUT_BODY = [204, 304] def __init__(self, http_response): @@ -950,7 +961,7 @@ def __init__(self, http_response): def __str__(self): return ('{%s "http_status_code": %s}' % ('' if self.body is None else '"body": ' + - json.dumps(self.body) + ',', str(self.http_status_code))) + json.dumps(self.body) + ',', str(self.http_status_code))) def is_ok(self): diff --git a/tests/test_client.py b/tests/test_client.py index fdf5ced..adca48e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,7 +11,7 @@ import sift if sys.version_info[0] < 3: - import six.moves.urllib as urllib + import six.moves.urllib as urllib else: import urllib.parse @@ -144,6 +144,7 @@ def workflow_statuses_json(): ] }""" + # A sample response from the /{version}/users/{userId}/score API. USER_SCORE_RESPONSE_JSON = """{ "status": 0, @@ -559,10 +560,10 @@ def test_sync_workflow_ok(self): params={'return_workflow_status': 'true', 'return_route_info': 'true', 'abuse_types': 'payment_abuse,content_abuse,legacy'}) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - assert(response.body['workflow_statuses']['route']['name'] == 'my route') + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") + assert (response.body['workflow_statuses']['route']['name'] == 'my route') def test_get_decisions_fails(self): with self.assertRaises(ValueError): @@ -1410,6 +1411,48 @@ def test_update_psp_merchant_profile(self): self.assertIsInstance(response, sift.client.Response) assert ('address' in response.body) + def test_with__include_score_percentiles_ok(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track(event, valid_transaction_properties(), include_score_percentiles=True) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={'fields': 'SCORE_PERCENTILES'}) + self.assertIsInstance(response, sift.client.Response) + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") + + def test_include_score_percentiles_as_false_ok(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track(event, valid_transaction_properties(), include_score_percentiles=False) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={}) + self.assertIsInstance(response, sift.client.Response) + assert (response.is_ok()) + assert (response.api_status == 0) + assert (response.api_error_message == "OK") + def main(): unittest.main() From f5d06efa0f3bd83439a6398dad48c924322acf97 Mon Sep 17 00:00:00 2001 From: Sasha Bogolii Date: Fri, 3 Feb 2023 13:34:26 +0200 Subject: [PATCH 069/112] Updated version --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index fbb1207..e4fef7b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.3.0 2023-02-03 +- Added support for score_percentiles + 5.2.0 2022-11-07 - Update PSP Merchant Management API diff --git a/sift/version.py b/sift/version.py index a3aeddc..7e0ca6a 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.2.0' +VERSION = '5.3.0' API_VERSION = '205' From c78a2cc4e37d2fd29176ef6973ea6e6c2b751726 Mon Sep 17 00:00:00 2001 From: Dima Ostapchuk <126080228+dostapchuk-sift@users.noreply.github.com> Date: Thu, 9 Mar 2023 15:05:29 +0200 Subject: [PATCH 070/112] Add score percentiles example --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 63fc2ec..5400cb2 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,33 @@ except sift.client.ApiException: # request failed pass +# Track a transaсtion event and receive a score with percentiles in response (sync flow). +# Note: `return_score` or `return_workflow_status` must be set `True`. +properties = { + "$user_id": user_id, + "$user_email": "buyer@gmail.com", + "$seller_user_id": "2371", + "seller_user_email": "seller@gmail.com", + "$transaction_id": "573050", + "$payment_method": { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444" + }, + "$currency_code": "USD", + "$amount": 15230000, +} + +try: + response = client.track("$transaction", properties, return_score=True, include_score_percentiles=True, abuse_types=["promotion_abuse", "content_abuse", "payment_abuse"]) + if response.is_ok(): + score_response = response.body["score_response"] + print(score_response) +except sift.client.ApiException: + # request failed + pass + # Request a score for the user with user_id 23056 try: response = client.score(user_id) From a317d0a3ddb866315451bf966c33b4191bbba9c6 Mon Sep 17 00:00:00 2001 From: Phani Kumar Mallampati <85580100+pmallampati-sift@users.noreply.github.com> Date: Fri, 10 Mar 2023 15:13:23 -0800 Subject: [PATCH 071/112] Add .circleci/config.yml --- .circleci/config.yml | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..1c5b439 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,47 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/2.0/configuration-reference +version: 2.1 + +# Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects. +# See: https://circleci.com/docs/2.0/orb-intro/ +orbs: + # The python orb contains a set of prepackaged CircleCI configuration you can use repeatedly in your configuration files + # Orb commands and jobs help you with common scripting around a language/tool + # so you dont have to copy and paste it everywhere. + # See the orb documentation here: https://circleci.com/developer/orbs/orb/circleci/python + python: circleci/python@1.5.0 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/2.0/configuration-reference/#jobs +jobs: + build-and-test: # This is the name of the job, feel free to change it to better match what you're trying to do! + # These next lines defines a Docker executors: https://circleci.com/docs/2.0/executor-types/ + # You can specify an image from Dockerhub or use one of the convenience images from CircleCI's Developer Hub + # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python + # The executor is the environment in which the steps below will be executed - below will use a python 3.10.2 container + # Change the version below to your required version of python + docker: + - image: cimg/python:3.10.2 + # Checkout the code as the first step. This is a dedicated CircleCI step. + # The python orb's install-packages step will install the dependencies from a Pipfile via Pipenv by default. + # Here we're making sure we use just use the system-wide pip. By default it uses the project root's requirements.txt. + # Then run your tests! + # CircleCI will report the results back to your VCS provider. + steps: + - checkout + - python/install-packages: + pkg-manager: pip + # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory. + # pip-dependency-file: test-requirements.txt # if you have a different name for your requirements file, maybe one that combines your runtime and test requirements. + - run: + name: Run tests + # This assumes pytest is installed via the install-package step above + command: pytest + +# Invoke jobs via workflows +# See: https://circleci.com/docs/2.0/configuration-reference/#workflows +workflows: + sample: # This is the name of the workflow, feel free to change it to better match your workflow. + # Inside the workflow, you define the jobs you want to run. + jobs: + - build-and-test From 6c10d3f97a4d99977009f4bc7bc1a338c9ab3bb1 Mon Sep 17 00:00:00 2001 From: Phani Kumar Mallampati <85580100+pmallampati-sift@users.noreply.github.com> Date: Fri, 10 Mar 2023 15:13:47 -0800 Subject: [PATCH 072/112] Add .circleci/config.yml From 0a7b830b10805751004e627302d1ea34b220ab4b Mon Sep 17 00:00:00 2001 From: Vitalii Iaskal <112494848+viaskal-sift@users.noreply.github.com> Date: Fri, 7 Apr 2023 15:13:23 +0300 Subject: [PATCH 073/112] PR template (#95) 1. Fix CI to run unit tests 2. PR Template --- .circleci/config.yml | 69 ++++++++++++++++---------------- .github/pull_request_template.md | 12 ++++++ 2 files changed, 46 insertions(+), 35 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.circleci/config.yml b/.circleci/config.yml index 1c5b439..4cbf05d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,47 +1,46 @@ -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/2.0/configuration-reference version: 2.1 -# Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects. -# See: https://circleci.com/docs/2.0/orb-intro/ orbs: - # The python orb contains a set of prepackaged CircleCI configuration you can use repeatedly in your configuration files - # Orb commands and jobs help you with common scripting around a language/tool - # so you dont have to copy and paste it everywhere. - # See the orb documentation here: https://circleci.com/developer/orbs/orb/circleci/python - python: circleci/python@1.5.0 + python: circleci/python@2.1.1 + +commands: + install-dependencies-run-tests: + parameters: + mock-version: + type: string + requests-version: + type: string + steps: + - run: + name: Install test dependencies + command: | + pip install mock==<> + pip install requests==<> + - run: + name: Run tests + command: python -m unittest discover -# Define a job to be invoked later in a workflow. -# See: https://circleci.com/docs/2.0/configuration-reference/#jobs jobs: - build-and-test: # This is the name of the job, feel free to change it to better match what you're trying to do! - # These next lines defines a Docker executors: https://circleci.com/docs/2.0/executor-types/ - # You can specify an image from Dockerhub or use one of the convenience images from CircleCI's Developer Hub - # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python - # The executor is the environment in which the steps below will be executed - below will use a python 3.10.2 container - # Change the version below to your required version of python + build-and-test-python3: docker: - image: cimg/python:3.10.2 - # Checkout the code as the first step. This is a dedicated CircleCI step. - # The python orb's install-packages step will install the dependencies from a Pipfile via Pipenv by default. - # Here we're making sure we use just use the system-wide pip. By default it uses the project root's requirements.txt. - # Then run your tests! - # CircleCI will report the results back to your VCS provider. steps: - checkout - - python/install-packages: - pkg-manager: pip - # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory. - # pip-dependency-file: test-requirements.txt # if you have a different name for your requirements file, maybe one that combines your runtime and test requirements. - - run: - name: Run tests - # This assumes pytest is installed via the install-package step above - command: pytest + - install-dependencies-run-tests: + mock-version: 5.0.1 + requests-version: 2.28.2 + + build-and-test-python2: + docker: + - image: cimg/python:2.7.18 + steps: + - checkout + - install-dependencies-run-tests: + mock-version: 3.0.5 + requests-version: 2.27.1 -# Invoke jobs via workflows -# See: https://circleci.com/docs/2.0/configuration-reference/#workflows workflows: - sample: # This is the name of the workflow, feel free to change it to better match your workflow. - # Inside the workflow, you define the jobs you want to run. + build-and-test-wf: jobs: - - build-and-test + - build-and-test-python3 + - build-and-test-python2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7055ae1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## Purpose + +## Summary + +## Testing + +## Checklist +- [ ] The change was thoroughly tested manually +- [ ] The change was covered with unit tests +- [ ] The change was tested with real API calls (if applicable) +- [ ] Necessary changes were made in the integration tests (if applicable) +- [ ] New functionality is reflected in README From 06cd95ece0b2ecacf263962043a083b4d52a255a Mon Sep 17 00:00:00 2001 From: haneeshv <135820493+haneeshv@users.noreply.github.com> Date: Wed, 26 Jul 2023 17:20:46 +0530 Subject: [PATCH 074/112] Verification endpoints implemented (#96) * Verification API (v1) --- README.md | 52 +++++++- sift/client.py | 204 +++++++++++++++++++++++++++++++- tests/test_verification_apis.py | 164 +++++++++++++++++++++++++ 3 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 tests/test_verification_apis.py diff --git a/README.md b/README.md index 5400cb2..6e928fa 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,58 @@ try: except sift.client.ApiException: # request failed pass -``` +# The send call triggers the generation of a OTP code that is stored by Sift and email/sms the code to the user. +send_properties = { + "$user_id": "billy_jones_301", + "$send_to": "billy_jones_301@gmail.com", + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$site_country": "IN", + "$event": { + "$session_id": "SOME_SESSION_ID", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" + } + } +} +try: + response = client.verification_send(send_properties) +except sift.client.ApiException: + # request failed + pass + +# The resend call generates a new OTP and sends it to the original recipient with the same settings. +resend_properties = { + "$user_id": "billy_jones_301", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" +} +try: + response = client.verification_resend(resend_properties) +except sift.client.ApiException: + # request failed + pass + +# The check call is used for verifying the OTP provided by the end user to Sift. +check_properties = { + "$user_id": "billy_jones_301", + "$code": 123456, + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" +} +try: + response = client.verification_check(check_properties) +except sift.client.ApiException: + # request failed + pass + +``` ## Testing diff --git a/sift/client.py b/sift/client.py index 2a86c7c..ec94853 100644 --- a/sift/client.py +++ b/sift/client.py @@ -22,6 +22,8 @@ API_URL = 'https://api.siftscience.com' API3_URL = 'https://api3.siftscience.com' +API_URL_VERIFICATION = 'https://api.sift.com/v1/verification/' + DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] @@ -180,7 +182,6 @@ def track( if include_score_percentiles: field_types = ['SCORE_PERCENTILES'] params['fields'] = ','.join(field_types) - try: response = self.session.post( path, @@ -485,7 +486,6 @@ def apply_user_decision(self, user_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) url = self._user_decisions_url(self.account_id, user_id) - try: return Response(self.session.post( url, @@ -524,7 +524,6 @@ def apply_order_decision(self, user_id, order_id, properties, timeout=None): self._validate_apply_decision_request(properties, user_id) url = self._order_apply_decisions_url(self.account_id, user_id, order_id) - try: return Response(self.session.post( url, @@ -788,7 +787,6 @@ def update_psp_merchant_profile(self, merchant_id, properties, timeout=None): timeout = self.timeout url = self._psp_merchant_id_url(self.account_id, merchant_id) - try: return Response(self.session.put( url, @@ -854,6 +852,196 @@ def get_a_psp_merchant_profile(self, merchant_id, timeout=None): except requests.exceptions.RequestException as e: raise ApiException(str(e), url) + def verification_send(self, properties, timeout=None, version=None): + """The send call triggers the generation of a OTP code that is stored by Sift and email/sms the code to the user. + This call is blocking. Check out https://sift.com/developers/docs/python/verification-api/send + for more information on our send response structure. + + Args: + + properties: + + $user_id: User ID of user being verified, e.g. johndoe123. + $send_to: The phone / email to send the OTP to. + $verification_type: The channel used for verification. Should be either $email or $sms. + $brand_name(optional): Name of the brand of product or service the user interacts with. + $language(optional): Language of the content of the web site. + $site_country(optional): Country of the content of the site. + $event: + $session_id: The session being verified. See $verification in the Sift Events API documentation. + $verified_event: The type of the reserved event being verified. + $reason(optional): The trigger for the verification. See $verification in the Sift Events API documentation. + $ip(optional): The user's IP address. + $browser: + $user_agent: The user agent of the browser that is verifying. Represented by the $browser object. + Use this field if the client is a browser. + + + timeout(optional): Use a custom timeout (in seconds) for this call. + + version(optional): Use a different version of the Sift Science API for this call. + + Returns: + A sift.client.Response object if the send call succeeded, or raises an ApiException. + """ + + if timeout is None: + timeout = self.timeout + + self._validate_send_request(properties) + + url = self._verification_send_url() + + try: + return Response(self.session.post( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def _validate_send_request(self, properties): + """ This method is used to validate arguments passed to the send method. """ + + if not isinstance(properties, dict): + raise TypeError("properties must be a dict") + elif not properties: + raise ValueError("properties dictionary may not be empty") + + user_id = properties.get('$user_id') + _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) + + send_to = properties.get('$send_to') + _assert_non_empty_unicode(send_to, 'send_to', error_cls=ValueError) + + verification_type = properties.get('$verification_type') + _assert_non_empty_unicode( + verification_type, 'verification_type', error_cls=ValueError) + + event = properties.get('$event') + if not isinstance(event, dict): + raise TypeError("$event must be a dict") + elif not event: + raise ValueError("$event dictionary may not be empty") + + session_id = event.get('$session_id') + _assert_non_empty_unicode( + session_id, 'session_id', error_cls=ValueError) + + def verification_resend(self, properties, timeout=None, version=None): + """A user can ask for a new OTP (one-time password) if they haven’t received the previous one, + or in case the previous OTP expired. + This call is blocking. Check out https://sift.com/developers/docs/python/verification-api/resend + for more information on our send response structure. + + Args: + + properties: + + $user_id: User ID of user being verified, e.g. johndoe123. + $verified_event(optional): This will be the event type that triggered the verification. + $verified_entity_id(optional): The ID of the entity impacted by the event being verified. + + timeout(optional): Use a custom timeout (in seconds) for this call. + + version(optional): Use a different version of the Sift Science API for this call. + + Returns: + A sift.client.Response object if the send call succeeded, or raises an ApiException. + """ + + if timeout is None: + timeout = self.timeout + + self._validate_resend_request(properties) + + url = self._verification_resend_url() + + try: + return Response(self.session.post( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def _validate_resend_request(self, properties): + """ This method is used to validate arguments passed to the send method. """ + + if not isinstance(properties, dict): + raise TypeError("properties must be a dict") + elif not properties: + raise ValueError("properties dictionary may not be empty") + + user_id = properties.get('$user_id') + _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) + + def verification_check(self, properties, timeout=None, version=None): + """The verification_check call is used for checking the OTP provided by the end user to Sift. + Sift then compares the OTP, checks rate limits and responds with a decision whether the user should be able to proceed or not. + This call is blocking. Check out https://sift.com/developers/docs/python/verification-api/check + for more information on our check response structure. + + Args: + + properties: + $user_id: User ID of user being verified, e.g. johndoe123. + $code: The code the user sent to the customer for validation.. + $verified_event(optional): This will be the event type that triggered the verification. + $verified_entity_id(optional): The ID of the entity impacted by the event being verified. + + timeout(optional): Use a custom timeout (in seconds) for this call. + version(optional): Use a different version of the Sift Science API for this call. + + Returns: + A sift.client.Response object if the check call succeeded, or raises + an ApiException. + """ + if timeout is None: + timeout = self.timeout + + self._validate_check_request(properties) + + url = self._verification_check_url() + + try: + return Response(self.session.post( + url, + data=json.dumps(properties), + auth=requests.auth.HTTPBasicAuth(self.api_key, ''), + headers={'Content-type': 'application/json', + 'Accept': '*/*', + 'User-Agent': self._user_agent()}, + timeout=timeout)) + + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) + + def _validate_check_request(self, properties): + """ This method is used to validate arguments passed to the check method. """ + + if not isinstance(properties, dict): + raise TypeError("properties must be a dict") + elif not properties: + raise ValueError("properties dictionary may not be empty") + + user_id = properties.get('$user_id') + _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) + + otp_code = properties.get('$code') + if otp_code is None: + raise ValueError("code is required") + def _user_agent(self): return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) @@ -912,6 +1100,14 @@ def _psp_merchant_id_url(self, account_id, merchant_id): return (self.url + '/v3/accounts/%s/psp_management/merchants/%s' % (_quote_path(account_id), _quote_path(merchant_id))) + def _verification_send_url(self): + return (API_URL_VERIFICATION + 'send') + + def _verification_resend_url(self): + return (API_URL_VERIFICATION + 'resend') + + def _verification_check_url(self): + return (API_URL_VERIFICATION + 'check') class Response(object): HTTP_CODES_WITHOUT_BODY = [204, 304] diff --git a/tests/test_verification_apis.py b/tests/test_verification_apis.py new file mode 100644 index 0000000..af56011 --- /dev/null +++ b/tests/test_verification_apis.py @@ -0,0 +1,164 @@ +from decimal import Decimal +import unittest +import warnings +import json +import mock +import sift +import sys + +import requests.exceptions + +def valid_verification_send_properties(): + return { + "$user_id": "billy_jones_301", + "$send_to": "billy_jones_301@gmail.com", + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$site_country": "IN", + "$event": { + "$session_id": "SOME_SESSION_ID", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" + } + } +} + +def valid_verification_resend_properties(): + return { + "$user_id": "billy_jones_301", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" + } + +def valid_verification_check_properties(): + return { + "$user_id": "billy_jones_301", + "$code": "123456", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID" + } + +def response_with_data_header(): + return { + "content-length": 1, + "content-type": "application/json; charset=UTF-8" + } + +class TestVerificationAPI(unittest.TestCase): + def setUp(self): + self.test_key = "a_fake_test_api_key" + self.sift_client = sift.Client(self.test_key) + + def test_verification_send_ok(self): + mock_response = mock.Mock() + + send_response_json = """ + { + "status": 0, + "error_message": "OK", + "sent_at": 1689316615034, + "segment_id": "143", + "segment_name": "Verification Template", + "brand_name": "", + "site_country": "", + "content_language": "", + "http_status_code": 200 + } + """ + + mock_response.content = send_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.verification_send(valid_verification_send_properties()) + data = json.dumps(valid_verification_send_properties()) + mock_post.assert_called_with( + "https://api.sift.com/v1/verification/send", + auth=mock.ANY, + data=data, + headers=mock.ANY, + timeout=mock.ANY) + self.assertIsInstance(response, sift.client.Response) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_verification_resend_ok(self): + mock_response = mock.Mock() + + resend_response_json = """ + { + "status": 0, + "error_message": "OK", + "sent_at": 1689316615034, + "segment_id": "143", + "segment_name": "Verification Template", + "brand_name": "", + "site_country": "", + "content_language": "", + "http_status_code": 200 + } + """ + + mock_response.content = resend_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.verification_resend(valid_verification_resend_properties()) + data = json.dumps(valid_verification_resend_properties()) + mock_post.assert_called_with( + "https://api.sift.com/v1/verification/resend", + auth=mock.ANY, + data=data, + headers=mock.ANY, + timeout=mock.ANY) + self.assertIsInstance(response, sift.client.Response) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + + def test_verification_check_ok(self): + mock_response = mock.Mock() + + check_response_json = """ + { + "status": 0, + "error_message": "OK", + "checked_at": 1689316615034, + "http_status_code": 200 + } + """ + + mock_response.content = check_response_json + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, "post") as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.verification_check(valid_verification_check_properties()) + data = json.dumps(valid_verification_check_properties()) + mock_post.assert_called_with( + "https://api.sift.com/v1/verification/check", + auth=mock.ANY, + data=data, + headers=mock.ANY, + timeout=mock.ANY) + self.assertIsInstance(response, sift.client.Response) + assert(response.is_ok()) + assert(response.api_status == 0) + assert(response.api_error_message == "OK") + +def main(): + unittest.main() + +if __name__ == "__main__": + main() From b32ed80101dbe1a8498a5a4a482a08c530f97e21 Mon Sep 17 00:00:00 2001 From: Vitalii Iaskal <112494848+viaskal-sift@users.noreply.github.com> Date: Wed, 26 Jul 2023 15:00:52 +0300 Subject: [PATCH 075/112] fix encoding issue (#97) --- sift/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sift/client.py b/sift/client.py index ec94853..e3242a2 100644 --- a/sift/client.py +++ b/sift/client.py @@ -934,7 +934,7 @@ def _validate_send_request(self, properties): session_id, 'session_id', error_cls=ValueError) def verification_resend(self, properties, timeout=None, version=None): - """A user can ask for a new OTP (one-time password) if they haven’t received the previous one, + """A user can ask for a new OTP (one-time password) if they haven't received the previous one, or in case the previous OTP expired. This call is blocking. Check out https://sift.com/developers/docs/python/verification-api/resend for more information on our send response structure. From f8bd37f17c4423c898e003b5117f1152f1fa45b3 Mon Sep 17 00:00:00 2001 From: Vitalii Iaskal <112494848+viaskal-sift@users.noreply.github.com> Date: Mon, 31 Jul 2023 13:51:43 +0300 Subject: [PATCH 076/112] Release 5.4.0 (#98) --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index e4fef7b..62622ec 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.4.0 2023-07-26 +- Support for Verification API + 5.3.0 2023-02-03 - Added support for score_percentiles diff --git a/sift/version.py b/sift/version.py index 7e0ca6a..db10107 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.3.0' +VERSION = '5.4.0' API_VERSION = '205' From 089e47452a580bf84a1b45465c646377eb18bf8a Mon Sep 17 00:00:00 2001 From: mjouahri-sift <113396109+mjouahri-sift@users.noreply.github.com> Date: Tue, 3 Oct 2023 03:22:29 -0700 Subject: [PATCH 077/112] [API-7343] Enable scores percentiles in the scores api. (#100) Enable scores percentiles in the scores api --- sift/client.py | 19 ++++++++++++++---- tests/test_client.py | 47 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/sift/client.py b/sift/client.py index e3242a2..37c9d6e 100644 --- a/sift/client.py +++ b/sift/client.py @@ -180,8 +180,7 @@ def track( params['force_workflow_run'] = 'true' if include_score_percentiles: - field_types = ['SCORE_PERCENTILES'] - params['fields'] = ','.join(field_types) + params['fields'] = 'SCORE_PERCENTILES' try: response = self.session.post( path, @@ -193,7 +192,7 @@ def track( except requests.exceptions.RequestException as e: raise ApiException(str(e), path) - def score(self, user_id, timeout=None, abuse_types=None, version=None): + def score(self, user_id, timeout=None, abuse_types=None, version=None, include_score_percentiles=False): """Retrieves a user's fraud score from the Sift Science API. This call is blocking. Check out https://siftscience.com/resources/references/score_api.html for more information on our Score response structure. @@ -210,6 +209,9 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): version(optional): Use a different version of the Sift Science API for this call. + include_score_percentiles(optional) : Whether to add new parameter in the query parameter. + if include_score_percentiles is true then add a new parameter called fields in the query parameter + Returns: A sift.client.Response object if the score call succeeded, or raises an ApiException. @@ -227,6 +229,9 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): if abuse_types: params['abuse_types'] = ','.join(abuse_types) + if include_score_percentiles: + params['fields'] = 'SCORE_PERCENTILES' + url = self._score_url(user_id, version) try: @@ -239,7 +244,7 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None): except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_user_score(self, user_id, timeout=None, abuse_types=None): + def get_user_score(self, user_id, timeout=None, abuse_types=None, include_score_percentiles=False): """Fetches the latest score(s) computed for the specified user and abuse types from the Sift Science API. As opposed to client.score() and client.rescore_user(), this *does not* compute a new score for the user; it simply fetches the latest score(s) which have computed. These scores may be arbitrarily old. @@ -256,6 +261,9 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None): should be returned (if scores were requested). If not specified, a score will be returned for every abuse_type to which you are subscribed. + include_score_percentiles(optional) : Whether to add new parameter in the query parameter. + if include_score_percentiles is true then add a new parameter called fields in the query parameter + Returns: A sift.client.Response object if the score call succeeded, or raises an ApiException. @@ -271,6 +279,9 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None): if abuse_types: params['abuse_types'] = ','.join(abuse_types) + if include_score_percentiles: + params['fields'] = 'SCORE_PERCENTILES' + try: response = self.session.get( url, diff --git a/tests/test_client.py b/tests/test_client.py index adca48e..b7a6553 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1411,7 +1411,7 @@ def test_update_psp_merchant_profile(self): self.assertIsInstance(response, sift.client.Response) assert ('address' in response.body) - def test_with__include_score_percentiles_ok(self): + def test_with_include_score_percentiles_ok(self): event = '$transaction' mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' @@ -1453,6 +1453,51 @@ def test_include_score_percentiles_as_false_ok(self): assert (response.api_status == 0) assert (response.api_error_message == "OK") + def test_score_api_include_score_percentiles_ok(self): + mock_response = mock.Mock() + mock_response.content = score_response_json() + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response + response = self.sift_client.score(user_id='12345', include_score_percentiles=True) + mock_get.assert_called_with( + 'https://api.siftscience.com/v205/score/12345', + params={'api_key': self.test_key, 'fields': 'SCORE_PERCENTILES'}, + headers=mock.ANY, + timeout=mock.ANY) + self.assertIsInstance(response, sift.client.Response) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['score'] == 0.85) + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) + + def test_get_user_score_include_score_percentiles_ok(self): + """Test the GET /{version}/users/{userId}/score API, i.e. client.get_user_score() + """ + test_timeout = 5 + mock_response = mock.Mock() + mock_response.content = USER_SCORE_RESPONSE_JSON + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + mock_response.headers = response_with_data_header() + with mock.patch.object(self.sift_client.session, 'get') as mock_get: + mock_get.return_value = mock_response + response = self.sift_client.get_user_score(user_id='12345', timeout=test_timeout, include_score_percentiles=True) + mock_get.assert_called_with( + 'https://api.siftscience.com/v205/users/12345/score', + params={'api_key': self.test_key, 'fields': 'SCORE_PERCENTILES'}, + headers=mock.ANY, + timeout=test_timeout) + self.assertIsInstance(response, sift.client.Response) + assert (response.is_ok()) + assert (response.api_error_message == "OK") + assert (response.body['entity_id'] == '12345') + assert (response.body['scores']['content_abuse']['score'] == 0.14) + assert (response.body['scores']['payment_abuse']['score'] == 0.97) + assert ('latest_decisions' in response.body) def main(): unittest.main() From adbd15f806a0692624659cfc648fe97ba1b85bf8 Mon Sep 17 00:00:00 2001 From: Vitalii Iaskal <112494848+viaskal-sift@users.noreply.github.com> Date: Tue, 3 Oct 2023 13:29:56 +0300 Subject: [PATCH 078/112] Release 5.5.0 (#101) Release 5.5.0 --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 62622ec..ba98b37 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.5.0 2023-10-03 +- Score percentiles for Score API + 5.4.0 2023-07-26 - Support for Verification API diff --git a/sift/version.py b/sift/version.py index db10107..e3f5976 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.4.0' +VERSION = '5.5.0' API_VERSION = '205' From 22a950d028f20f9a0af0e168d4afd0b66b0542a9 Mon Sep 17 00:00:00 2001 From: haneeshv <135820493+haneeshv@users.noreply.github.com> Date: Fri, 13 Oct 2023 19:23:05 +0530 Subject: [PATCH 079/112] Msue 191 integration testing app new (#104) Integration testing app --- .../decisions_api/__init__.py | 1 + .../decisions_api/test_decisions_api.py | 66 + test_integration_app/events_api/__init__.py | 1 + .../events_api/test_events_api.py | 1307 +++++++++++++++++ test_integration_app/globals.py | 4 + test_integration_app/main.py | 101 ++ .../psp_merchant_api/__init__.py | 1 + .../psp_merchant_api/test_psp_merchant_api.py | 70 + test_integration_app/score_api/__init__.py | 1 + .../score_api/test_score_api.py | 14 + .../verifications_api/__init__.py | 1 + .../test_verification_api.py | 52 + .../workflows_api/__init__.py | 1 + .../workflows_api/test_workflows_api.py | 20 + tests/test_client.py | 2 +- tests/test_client_v203.py | 2 +- 16 files changed, 1642 insertions(+), 2 deletions(-) create mode 100644 test_integration_app/decisions_api/__init__.py create mode 100644 test_integration_app/decisions_api/test_decisions_api.py create mode 100644 test_integration_app/events_api/__init__.py create mode 100644 test_integration_app/events_api/test_events_api.py create mode 100644 test_integration_app/globals.py create mode 100644 test_integration_app/main.py create mode 100644 test_integration_app/psp_merchant_api/__init__.py create mode 100644 test_integration_app/psp_merchant_api/test_psp_merchant_api.py create mode 100644 test_integration_app/score_api/__init__.py create mode 100644 test_integration_app/score_api/test_score_api.py create mode 100644 test_integration_app/verifications_api/__init__.py create mode 100644 test_integration_app/verifications_api/test_verification_api.py create mode 100644 test_integration_app/workflows_api/__init__.py create mode 100644 test_integration_app/workflows_api/test_workflows_api.py diff --git a/test_integration_app/decisions_api/__init__.py b/test_integration_app/decisions_api/__init__.py new file mode 100644 index 0000000..e6af361 --- /dev/null +++ b/test_integration_app/decisions_api/__init__.py @@ -0,0 +1 @@ +from decisions_api import test_decisions_api diff --git a/test_integration_app/decisions_api/test_decisions_api.py b/test_integration_app/decisions_api/test_decisions_api.py new file mode 100644 index 0000000..1993f3d --- /dev/null +++ b/test_integration_app/decisions_api/test_decisions_api.py @@ -0,0 +1,66 @@ +import sift +import globals + +from os import environ as env + +class DecisionAPI(): + # Get the value of API_KEY from environment variable + api_key = env['API_KEY'] + account_id = env['ACCOUNT_ID'] + client = sift.Client(api_key = api_key, account_id = account_id) + globals.initialize() + user_id = globals.user_id + + def apply_user_decision(self): + applyDecisionRequest = { + "decision_id" : "block_user_payment_abuse", + "source" : "MANUAL_REVIEW", + "analyst" : "analyst@example.com", + "description" : "User linked to three other payment abusers and ordering high value items" + } + + return self.client.apply_user_decision(self.user_id, applyDecisionRequest) + + def apply_order_decision(self): + applyOrderDecisionRequest = { + "decision_id" : "block_order_payment_abuse", + "source" : "AUTOMATED_RULE", + "description" : "Auto block pending order as score exceeded risk threshold of 90" + } + + return self.client.apply_order_decision(self.user_id, "ORDER-1234567", applyOrderDecisionRequest) + + def apply_session_decision(self): + applySessionDecisionRequest = { + "decision_id" : "session_looks_fraud_account_takover", + "source" : "MANUAL_REVIEW", + "analyst" : "analyst@example.com", + "description" : "compromised account reported to customer service" + } + + return self.client.apply_session_decision(self.user_id, "session_id", applySessionDecisionRequest) + + def apply_content_decision(self): + applyContentDecisionRequest = { + "decision_id" : "content_looks_fraud_content_abuse", + "source" : "MANUAL_REVIEW", + "analyst" : "analyst@example.com", + "description" : "fraudulent listing" + } + + return self.client.apply_content_decision(self.user_id, "content_id", applyContentDecisionRequest) + + def get_user_decisions(self): + return self.client.get_user_decisions(self.user_id) + + def get_order_decisions(self): + return self.client.get_order_decisions("ORDER-1234567") + + def get_content_decisions(self): + return self.client.get_content_decisions(self.user_id, "CONTENT_ID") + + def get_session_decisions(self): + return self.client.get_session_decisions(self.user_id, "SESSION_ID") + + def get_decisions(self): + return self.client.get_decisions(entity_type='user', limit=10, start_from=5, abuse_types='legacy,payment_abuse') diff --git a/test_integration_app/events_api/__init__.py b/test_integration_app/events_api/__init__.py new file mode 100644 index 0000000..3bf15fe --- /dev/null +++ b/test_integration_app/events_api/__init__.py @@ -0,0 +1 @@ +from events_api import test_events_api diff --git a/test_integration_app/events_api/test_events_api.py b/test_integration_app/events_api/test_events_api.py new file mode 100644 index 0000000..f123a73 --- /dev/null +++ b/test_integration_app/events_api/test_events_api.py @@ -0,0 +1,1307 @@ +import sift +import globals + +from os import environ as env + +class EventsAPI(): + # Get the value of API_KEY from environment variable + api_key = env['API_KEY'] + client = sift.Client(api_key = api_key) + globals.initialize() + user_id = globals.user_id + user_email = globals.user_email + + def add_item_to_cart(self): + add_item_to_cart_properties = { + # Required Fields + "$user_id" : self.user_id, + # Supported Fields + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$item" : { + "$item_id" : "B004834GQO", + "$product_title" : "The Slanket Blanket-Texas Tea", + "$price" : 39990000, # $39.99 + "$currency_code" : "USD", + "$upc" : "6786211451001", + "$sku" : "004834GQ", + "$brand" : "Slanket", + "$manufacturer" : "Slanket", + "$category" : "Blankets & Throws", + "$tags" : ["Awesome", "Wintertime specials"], + "$color" : "Texas Tea", + "$quantity" : 16 + }, + "$brand_name" : "sift", + "$site_domain" : "sift.com", + "$site_country" : "US", + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$add_item_to_cart", add_item_to_cart_properties) + + + def add_promotion(self): + add_promotion_properties = { + # Required fields. + "$user_id" : self.user_id, + # Supported fields. + "$promotions" : [ + # Example of a promotion for monetary discounts off good or services + { + "$promotion_id" : "NewRideDiscountMay2016", + "$status" : "$success", + "$description" : "$5 off your first 5 rides", + "$referrer_user_id" : "elon-m93903", + "$discount" : { + "$amount" : 5000000, # $5 + "$currency_code" : "USD" + } + } + ], + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$add_promotion", add_promotion_properties) + + def chargeback(self): + # Sample $chargeback event + chargeback_properties = { + # Required Fields + "$order_id" : "ORDER-123124124", + "$transaction_id" : "719637215", + # Recommended Fields + "$user_id" : self.user_id, + "$chargeback_state" : "$lost", + "$chargeback_reason" : "$duplicate" + } + return self.client.track("$chargeback", chargeback_properties) + + def content_status(self): + # Sample $content_status event + content_status_properties = { + # Required Fields + "$user_id" : self.user_id, + "$content_id" : "9671500641", + "$status" : "$paused", + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$content_status", content_status_properties) + + def create_account(self): + # Sample $create_account event + create_account_properties = { + # Required Fields + "$user_id" : self.user_id, + # Supported Fields + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$user_email" : self.user_email, + "$verification_phone_number" : "+123456789012", + "$name" : "Bill Jones", + "$phone" : "1-415-555-6040", + "$referrer_user_id" : "janejane101", + "$payment_methods" : [ + { + "$payment_type" : "$credit_card", + "$card_bin" : "542486", + "$card_last4" : "4444" + } + ], + "$billing_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6040", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$shipping_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$promotions" : [ + { + "$promotion_id" : "FriendReferral", + "$status" : "$success", + "$referrer_user_id" : "janejane102", + "$credit_point" : { + "$amount" : 100, + "$credit_point_type" : "account karma" + } + } + ], + "$social_sign_on_type" : "$twitter", + "$account_types" : ["merchant", "premium"], + + # Suggested Custom Fields + "twitter_handle" : "billyjones", + "work_phone" : "1-347-555-5921", + "location" : "New London, NH", + "referral_code" : "MIKEFRIENDS", + "email_confirmed_status" : "$pending", + "phone_confirmed_status" : "$pending", + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_account", create_account_properties) + + def create_content_comment(self): + # Sample $create_content event for comments + comment_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "comment-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $comment object + "$comment" : { + "$body" : "Congrats on the new role!", + "$contact_email" : "alex_301@domain.com", + "$parent_comment_id" : "comment-23407", + "$root_content_id" : "listing-12923213", + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "An old picture" + } + ] + }, + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_content", comment_properties) + + def create_content_listing(self): + # Sample $create_content event for listings + listing_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "listing-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $listing object + "$listing" : { + "$subject" : "2 Bedroom Apartment for Rent", + "$body" : "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", + "$contact_email" : "alex_301@domain.com", + "$contact_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$locations" : [ + { + "$city" : "Seattle", + "$region" : "Washington", + "$country" : "US", + "$zipcode" : "98112" + } + ], + "$listed_items" : [ + { + "$price" : 2950000000, # $2950.00 + "$currency_code" : "USD", + "$tags" : ["heat", "washer/dryer"] + } + ], + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "Billy's picture" + } + ], + "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds + }, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_content", listing_properties) + + def create_content_message(self): + # Sample $create_content event for messages + message_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "message-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $message object + "$message" : { + "$body" : "Let’s meet at 5pm", + "$contact_email" : "alex_301@domain.com", + "$recipient_user_ids" : ["fy9h989sjphh71"], + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "My hike today!" + } + ] + }, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_content", message_properties) + + def create_content_post(self): + # Sample $create_content event for posts + post_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "post-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $post object + "$post" : { + "$subject" : "My new apartment!", + "$body" : "Moved into my new apartment yesterday.", + "$contact_email" : "alex_301@domain.com", + "$contact_address" : { + "$name" : "Bill Jones", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$locations" : [ + { + "$city" : "Seattle", + "$region" : "Washington", + "$country" : "US", + "$zipcode" : "98112" + } + ], + "$categories" : ["Personal"], + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "View from the window!" + } + ], + "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds + }, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_content", post_properties) + + def create_content_profile(self): + # Sample $create_content event for reviews + profile_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "profile-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $profile object + "$profile" : { + "$body" : "Hi! My name is Alex and I just moved to New London!", + "$contact_email" : "alex_301@domain.com", + "$contact_address" : { + "$name" : "Alex Smith", + "$phone" : "1-415-555-6041", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "Alex's picture" + } + ], + "$categories" : [ + "Friends", + "Long-term dating" + ] + }, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_content", profile_properties) + + def create_content_review(self): + # Sample $create_content event for reviews + review_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "review-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $review object + "$review" : { + "$subject" : "Amazing Tacos!", + "$body" : "I ate the tacos.", + "$contact_email" : "alex_301@domain.com", + "$locations" : [ + { + "$city" : "Seattle", + "$region" : "Washington", + "$country" : "US", + "$zipcode" : "98112" + } + ], + "$reviewed_content_id" : "listing-234234", + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "Calamari tacos." + } + ], + "$rating" : 4.5 + }, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_content", review_properties) + + def create_order(self): + # Sample $create_order event + order_properties = { + # Required Fields + "$user_id" : self.user_id, + # Supported Fields + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$order_id" : "ORDER-28168441", + "$user_email" : self.user_email, + "$verification_phone_number" : "+123456789012", + "$amount" : 115940000, # $115.94 + "$currency_code" : "USD", + "$billing_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$payment_methods" : [ + { + "$payment_type" : "$credit_card", + "$payment_gateway" : "$braintree", + "$card_bin" : "542486", + "$card_last4" : "4444" + } + ], + "$ordered_from" : { + "$store_id" : "123", + "$store_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6040", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + } + }, + "$brand_name" : "sift", + "$site_domain" : "sift.com", + "$site_country" : "US", + "$shipping_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$expedited_shipping" : True, + "$shipping_method" : "$physical", + "$shipping_carrier" : "UPS", + "$shipping_tracking_numbers": ["1Z204E380338943508", "1Z204E380338943509"], + "$items" : [ + { + "$item_id" : "12344321", + "$product_title" : "Microwavable Kettle Corn: Original Flavor", + "$price" : 4990000, # $4.99 + "$upc" : "097564307560", + "$sku" : "03586005", + "$brand" : "Peters Kettle Corn", + "$manufacturer" : "Peters Kettle Corn", + "$category" : "Food and Grocery", + "$tags" : ["Popcorn", "Snacks", "On Sale"], + "$quantity" : 4 + }, + { + "$item_id" : "B004834GQO", + "$product_title" : "The Slanket Blanket-Texas Tea", + "$price" : 39990000, # $39.99 + "$upc" : "6786211451001", + "$sku" : "004834GQ", + "$brand" : "Slanket", + "$manufacturer" : "Slanket", + "$category" : "Blankets & Throws", + "$tags" : ["Awesome", "Wintertime specials"], + "$color" : "Texas Tea", + "$quantity" : 2 + } + ], + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id" : "slinkys_emporium", + + "$promotions" : [ + { + "$promotion_id" : "FirstTimeBuyer", + "$status" : "$success", + "$description" : "$5 off", + "$discount" : { + "$amount" : 5000000, # $5.00 + "$currency_code" : "USD", + "$minimum_purchase_amount" : 25000000 # $25.00 + } + } + ], + + # Sample Custom Fields + "digital_wallet" : "apple_pay", # "google_wallet", etc. + "coupon_code" : "dollarMadness", + "shipping_choice" : "FedEx Ground Courier", + "is_first_time_buyer" : False, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$create_order", order_properties) + + def flag_content(self): + # Sample $flag_content event + flag_content_properties = { + # Required Fields + "$user_id" : self.user_id, # content creator + "$content_id" : "9671500641", + + # Supported Fields + "$flagged_by" : "jamieli89" + } + return self.client.track("$flag_content", flag_content_properties) + + def link_session_to_user(self): + # Sample $link_session_to_user event + link_session_to_user_properties = { + # Required Fields + "$user_id" : self.user_id, + "$session_id" : "gigtleqddo84l8cm15qe4il" + } + return self.client.track("$link_session_to_user", link_session_to_user_properties) + + def login(self): + # Sample $login event + login_properties = { + # Required Fields + "$user_id" : self.user_id, + "$login_status" : "$failure", + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$ip" : "128.148.1.135", + + # Optional Fields + "$user_email" : self.user_email, + "$verification_phone_number" : "+123456789012", + "$failure_reason" : "$wrong_password", + "$username" : "billjones1@example.com", + "$account_types" : ["merchant", "premium"], + "$social_sign_on_type" : "$linkedin", + "$brand_name" : "sift", + "$site_domain" : "sift.com", + "$site_country" : "US", + + # Send this information with a login from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$login", login_properties) + + def logout(self): + # Sample $logout event + logout_properties = { + # Required Fields + "$user_id" : self.user_id, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$logout", logout_properties) + + def order_status(self): + # Sample $order_status event + order_properties = { + # Required Fields + "$user_id" : self.user_id, + "$order_id" : "ORDER-28168441", + "$order_status" : "$canceled", + + # Optional Fields + "$reason" : "$payment_risk", + "$source" : "$manual_review", + "$analyst" : "someone@your-site.com", + "$webhook_id" : "3ff1082a4aea8d0c58e3643ddb7a5bb87ffffeb2492dca33", + "$description" : "Canceling because multiple fraudulent users on device", + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$order_status", order_properties) + + def remove_item_from_cart(self): + # Sample $remove_item_from_cart event + remove_item_from_cart_properties = { + # Required Fields + "$user_id" : self.user_id, + + # Supported Fields + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$item" : { + "$item_id" : "B004834GQO", + "$product_title" : "The Slanket Blanket-Texas Tea", + "$price" : 39990000, # $39.99 + "$currency_code" : "USD", + "$quantity" : 2, + "$upc" : "6786211451001", + "$sku" : "004834GQ", + "$brand" : "Slanket", + "$manufacturer" : "Slanket", + "$category" : "Blankets & Throws", + "$tags" : ["Awesome", "Wintertime specials"], + "$color" : "Texas Tea" + }, + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + + return self.client.track("$remove_item_from_cart", remove_item_from_cart_properties) + + def security_notification(self): + # Sample $security_notification event + security_notification_properties = { + # Required Fields + "$user_id" : self.user_id, + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$notification_status" : "$sent", + # Optional fields if applicable + "$notification_type" : "$email", + "$notified_value" : "billy123@domain.com", + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + + return self.client.track("$security_notification", security_notification_properties) + + def transaction(self): + # Sample $transaction event + transaction_properties = { + # Required Fields + "$user_id" : self.user_id, + "$amount" : 506790000, # $506.79 + "$currency_code" : "USD", + # Supported Fields + "$user_email" : self.user_email, + "$verification_phone_number" : "+123456789012", + "$transaction_type" : "$sale", + "$transaction_status" : "$failure", + "$decline_category" : "$bank_decline", + "$order_id" : "ORDER-123124124", + "$transaction_id" : "719637215", + "$billing_address" : { # or "$sent_address" # or "$received_address" + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$brand_name" : "sift", + "$site_domain" : "sift.com", + "$site_country" : "US", + "$ordered_from" : { + "$store_id" : "123", + "$store_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6040", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + } + }, + # Credit card example + "$payment_method" : { + "$payment_type" : "$credit_card", + "$payment_gateway" : "$braintree", + "$card_bin" : "542486", + "$card_last4" : "4444" + }, + + # Supported fields for 3DS + "$status_3ds" : "$attempted", + "$triggered_3ds" : "$processor", + "$merchant_initiated_transaction" : False, + + # Supported Fields + "$shipping_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$session_id" : "gigtleqddo84l8cm15qe4il", + + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id" : "slinkys_emporium", + + # Sample Custom Fields + "digital_wallet" : "apple_pay", # "google_wallet", etc. + "coupon_code" : "dollarMadness", + "shipping_choice" : "FedEx Ground Courier", + "is_first_time_buyer" : False, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + return self.client.track("$transaction", transaction_properties) + + def update_account(self): + # Sample $update_account event + update_account_properties = { + # Required Fields + "$user_id" : self.user_id, + + # Supported Fields + "$changed_password" : True, + "$user_email" : self.user_email, + "$verification_phone_number" : "+123456789012", + "$name" : "Bill Jones", + "$phone" : "1-415-555-6040", + "$referrer_user_id" : "janejane102", + "$payment_methods" : [ + { + "$payment_type" : "$credit_card", + "$card_bin" : "542486", + "$card_last4" : "4444" + } + ], + "$billing_address" : + { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$shipping_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + + "$social_sign_on_type" : "$twitter", + "$account_types" : ["merchant", "premium"], + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + + return self.client.track("$update_account", update_account_properties) + + def update_content_comment(self): + # Sample $update_content event for comments + update_content_comment_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "comment-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $comment object + "$comment" : { + "$body" : "Congrats on the new role!", + "$contact_email" : "alex_301@domain.com", + "$parent_comment_id" : "comment-23407", + "$root_content_id" : "listing-12923213", + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "An old picture" + } + ] + }, + # Send this information from an APP client. + "$app" : { + # Example for the iOS Calculator app. + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } + } + return self.client.track("$update_content", update_content_comment_properties) + + def update_content_listing(self): + # Sample $update_content event for listings + update_content_listing_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "listing-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $listing object + "$listing" : { + "$subject" : "2 Bedroom Apartment for Rent", + "$body" : "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", + "$contact_email" : "alex_301@domain.com", + "$contact_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$locations" : [ + { + "$city" : "Seattle", + "$region" : "Washington", + "$country" : "US", + "$zipcode" : "98112" + } + ], + "$listed_items" : [ + { + "$price" : 2950000000, # $2950.00 + "$currency_code" : "USD", + "$tags" : ["heat", "washer/dryer"] + } + ], + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "Billy's picture" + } + ], + "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds + }, + # Send this information from an APP client. + "$app" : { + # Example for the iOS Calculator app. + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } + } + return self.client.track("$update_content", update_content_listing_properties) + + def update_content_message(self): + # Sample $update_content event for messages + update_content_message_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "message-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $message object + "$message" : { + "$body" : "Lets meet at 5pm", + "$contact_email" : "alex_301@domain.com", + "$recipient_user_ids" : ["fy9h989sjphh71"], + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "My hike today!" + } + ] + }, + + # Send this information from a BROWSER client. + "$browser" : { + "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language" : "en-US", + "$content_language" : "en-GB" + } + } + + return self.client.track("$update_content", update_content_message_properties) + + def update_content_post(self): + # Sample $update_content event for posts + update_content_post_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "post-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $post object + "$post" : { + "$subject" : "My new apartment!", + "$body" : "Moved into my new apartment yesterday.", + "$contact_email" : "alex_301@domain.com", + "$contact_address" : { + "$name" : "Bill Jones", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$locations" : [ + { + "$city" : "Seattle", + "$region" : "Washington", + "$country" : "US", + "$zipcode" : "98112" + } + ], + "$categories" : ["Personal"], + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "View from the window!" + } + ], + "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds + }, + # Send this information from an APP client. + "$app" : { + # Example for the iOS Calculator app. + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } + } + return self.client.track("$update_content", update_content_post_properties) + + def update_content_profile(self): + # Sample $update_content event for reviews + update_content_profile_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "profile-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $profile object + "$profile" : { + "$body" : "Hi! My name is Alex and I just moved to New London!", + "$contact_email" : "alex_301@domain.com", + "$contact_address" : { + "$name" : "Alex Smith", + "$phone" : "1-415-555-6041", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "Alex's picture" + } + ], + "$categories" : [ + "Friends", + "Long-term dating" + ] + }, + # ========================================= + # Send this information from an APP client. + "$app" : { + # Example for the iOS Calculator app. + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } + } + return self.client.track("$update_content", update_content_profile_properties) + + def update_content_review(self): + # Sample $update_content event for reviews + update_content_review_properties = { + # Required fields + "$user_id" : self.user_id, + "$content_id" : "review-23412", + + # Recommended fields + "$session_id" : "a234ksjfgn435sfg", + "$status" : "$active", + "$ip" : "255.255.255.0", + + # Required $review object + "$review" : { + "$subject" : "Amazing Tacos!", + "$body" : "I ate the tacos.", + "$contact_email" : "alex_301@domain.com", + "$locations" : [ + { + "$city" : "Seattle", + "$region" : "Washington", + "$country" : "US", + "$zipcode" : "98112" + } + ], + "$reviewed_content_id" : "listing-234234", + "$images" : [ + { + "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", + "$link" : "https://www.domain.com/file.png", + "$description" : "Calamari tacos." + } + ], + "$rating" : 4.5 + }, + # Send this information from an APP client. + "$app" : { + # Example for the iOS Calculator app. + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } + } + return self.client.track("$update_content", update_content_review_properties) + + def update_order(self): + # Sample $update_order event + update_order_properties = { + # Required Fields + "$user_id" : self.user_id, + # Supported Fields + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$order_id" : "ORDER-28168441", + "$user_email" : self.user_email, + "$verification_phone_number" : "+123456789012", + "$amount" : 115940000, # $115.94 + "$currency_code" : "USD", + "$billing_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$payment_methods" : [ + { + "$payment_type" : "$credit_card", + "$payment_gateway" : "$braintree", + "$card_bin" : "542486", + "$card_last4" : "4444" + } + ], + "$brand_name" : "sift", + "$site_domain" : "sift.com", + "$site_country" : "US", + "$ordered_from" : { + "$store_id" : "123", + "$store_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6040", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + } + }, + "$shipping_address" : { + "$name" : "Bill Jones", + "$phone" : "1-415-555-6041", + "$address_1" : "2100 Main Street", + "$address_2" : "Apt 3B", + "$city" : "New London", + "$region" : "New Hampshire", + "$country" : "US", + "$zipcode" : "03257" + }, + "$expedited_shipping" : True, + "$shipping_method" : "$physical", + "$shipping_carrier" : "UPS", + "$shipping_tracking_numbers": ["1Z204E380338943508", "1Z204E380338943509"], + "$items" : [ + { + "$item_id" : "12344321", + "$product_title" : "Microwavable Kettle Corn: Original Flavor", + "$price" : 4990000, # $4.99 + "$upc" : "097564307560", + "$sku" : "03586005", + "$brand" : "Peters Kettle Corn", + "$manufacturer" : "Peters Kettle Corn", + "$category" : "Food and Grocery", + "$tags" : ["Popcorn", "Snacks", "On Sale"], + "$quantity" : 4 + }, + { + "$item_id" : "B004834GQO", + "$product_title" : "The Slanket Blanket-Texas Tea", + "$price" : 39990000, # $39.99 + "$upc" : "6786211451001", + "$sku" : "004834GQ", + "$brand" : "Slanket", + "$manufacturer" : "Slanket", + "$category" : "Blankets & Throws", + "$tags" : ["Awesome", "Wintertime specials"], + "$color" : "Texas Tea", + "$quantity" : 2 + } + ], + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id" : "slinkys_emporium", + + "$promotions" : [ + { + "$promotion_id" : "FirstTimeBuyer", + "$status" : "$success", + "$description" : "$5 off", + "$discount" : { + "$amount" : 5000000, # $5.00 + "$currency_code" : "USD", + "$minimum_purchase_amount" : 25000000 # $25.00 + } + } + ], + + # Sample Custom Fields + "digital_wallet" : "apple_pay", # "google_wallet", etc. + "coupon_code" : "dollarMadness", + "shipping_choice" : "FedEx Ground Courier", + "is_first_time_buyer" : False, + # Send this information from an APP client. + "$app" : { + # Example for the iOS Calculator app. + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } + } + return self.client.track("$update_order", update_order_properties) + + def update_password(self): + # Sample $update_password event + update_password_properties = { + # Required Fields + "$user_id" : self.user_id, + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$status" : "$success", + "$reason" : "$forced_reset", + "$ip" : "128.148.1.135", # IP of the user that entered the new password after the old password was reset + # Send this information from an APP client. + "$app" : { + # Example for the iOS Calculator app. + "$os" : "iOS", + "$os_version" : "10.1.3", + "$device_manufacturer" : "Apple", + "$device_model" : "iPhone 4,2", + "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name" : "Calculator", + "$app_version" : "3.2.7", + "$client_language" : "en-US" + } + } + return self.client.track("$update_password", update_password_properties) + + def verification(self): + # Sample $verification event + verification_properties = { + # Required Fields + "$user_id" : self.user_id, + "$session_id" : "gigtleqddo84l8cm15qe4il", + "$status" : "$pending", + + # Optional fields if applicable + "$verified_event" : "$login", + "$reason" : "$automated_rule", + "$verification_type" : "$sms", + "$verified_value" : "14155551212" + } + return self.client.track("$verification", verification_properties) + \ No newline at end of file diff --git a/test_integration_app/globals.py b/test_integration_app/globals.py new file mode 100644 index 0000000..5cb65ab --- /dev/null +++ b/test_integration_app/globals.py @@ -0,0 +1,4 @@ +def initialize(): + global user_id, user_email + user_id = 'billy_jones_301' + user_email = 'billjones1@example.com' diff --git a/test_integration_app/main.py b/test_integration_app/main.py new file mode 100644 index 0000000..348de2d --- /dev/null +++ b/test_integration_app/main.py @@ -0,0 +1,101 @@ +from events_api import test_events_api +from decisions_api import test_decisions_api +from workflows_api import test_workflows_api +from score_api import test_score_api +from verifications_api import test_verification_api +from psp_merchant_api import test_psp_merchant_api + +class Utils: + def isOK(self, response): + if(hasattr(response, 'status')): + return ((response.status == 0) and ((response.http_status_code == 200) or (response.http_status_code == 201))) + else: + return ((response.http_status_code == 200) or (response.http_status_code == 201)) + +def runAllMethods(): + objUtils = Utils() + objEvents = test_events_api.EventsAPI() + objDecision = test_decisions_api.DecisionAPI() + objScore = test_score_api.ScoreAPI() + objWorkflow = test_workflows_api.WorkflowsAPI() + objVerification = test_verification_api.VerificationAPI() + objPSPMerchant = test_psp_merchant_api.PSPMerchantAPI() + + #Events APIs + + assert (objUtils.isOK(objEvents.add_item_to_cart()) == True) + assert (objUtils.isOK(objEvents.add_promotion()) == True) + assert (objUtils.isOK(objEvents.chargeback()) == True) + assert (objUtils.isOK(objEvents.content_status()) == True) + assert (objUtils.isOK(objEvents.create_account()) == True) + assert (objUtils.isOK(objEvents.create_content_comment()) == True) + assert (objUtils.isOK(objEvents.create_content_listing()) == True) + assert (objUtils.isOK(objEvents.create_content_message()) == True) + assert (objUtils.isOK(objEvents.create_content_post()) == True) + assert (objUtils.isOK(objEvents.create_content_profile()) == True) + assert (objUtils.isOK(objEvents.create_content_review()) == True) + assert (objUtils.isOK(objEvents.create_order()) == True) + assert (objUtils.isOK(objEvents.flag_content()) == True) + assert (objUtils.isOK(objEvents.link_session_to_user()) == True) + assert (objUtils.isOK(objEvents.login()) == True) + assert (objUtils.isOK(objEvents.logout()) == True) + assert (objUtils.isOK(objEvents.order_status()) == True) + assert (objUtils.isOK(objEvents.remove_item_from_cart()) == True) + assert (objUtils.isOK(objEvents.security_notification()) == True) + assert (objUtils.isOK(objEvents.transaction()) == True) + assert (objUtils.isOK(objEvents.update_account()) == True) + assert (objUtils.isOK(objEvents.update_content_comment()) == True) + assert (objUtils.isOK(objEvents.update_content_listing()) == True) + assert (objUtils.isOK(objEvents.update_content_message()) == True) + assert (objUtils.isOK(objEvents.update_content_post()) == True) + assert (objUtils.isOK(objEvents.update_content_profile()) == True) + assert (objUtils.isOK(objEvents.update_content_review()) == True) + assert (objUtils.isOK(objEvents.update_order()) == True) + assert (objUtils.isOK(objEvents.update_password()) == True) + assert (objUtils.isOK(objEvents.verification()) == True) + print("Events API Tested") + + # Decision APIs + + assert (objUtils.isOK(objDecision.apply_user_decision()) == False) + assert (objUtils.isOK(objDecision.apply_order_decision()) == False) + assert (objUtils.isOK(objDecision.apply_session_decision()) == False) + assert (objUtils.isOK(objDecision.apply_content_decision()) == False) + assert (objUtils.isOK(objDecision.get_user_decisions()) == True) + assert (objUtils.isOK(objDecision.get_order_decisions()) == True) + assert (objUtils.isOK(objDecision.get_content_decisions()) == True) + assert (objUtils.isOK(objDecision.get_session_decisions()) == True) + assert (objUtils.isOK(objDecision.get_decisions()) == True) + print("Decision API Tested") + + # Workflows APIs + + assert (objUtils.isOK(objWorkflow.synchronous_workflows()) == True) + print("Workflow API Tested") + + # Score APIs + + assert (objUtils.isOK(objScore.get_user_score()) == False) + print("Score API Tested") + + # Verification APIs + + assert (objUtils.isOK(objVerification.send()) == True) + assert (objUtils.isOK(objVerification.resend()) == True) + checkResponse = objVerification.check() + assert (objUtils.isOK(checkResponse) == True) + assert (checkResponse.body["status"] == 50) + print("Verification API Tested") + + # PSP Merchant APIs + + assert (objUtils.isOK(objPSPMerchant.create_merchant()) == True) + assert (objUtils.isOK(objPSPMerchant.edit_merchant()) == True) + assert (objUtils.isOK(objPSPMerchant.get_a_merchant_profile()) == True) + assert (objUtils.isOK(objPSPMerchant.get_merchant_profiles()) == True) + assert (objUtils.isOK(objPSPMerchant.get_merchant_profiles(batch_size=10, batch_token=None)) == True) + print("PSP Merchant API Tested") + + print("Execution completed") + +runAllMethods() diff --git a/test_integration_app/psp_merchant_api/__init__.py b/test_integration_app/psp_merchant_api/__init__.py new file mode 100644 index 0000000..46637e6 --- /dev/null +++ b/test_integration_app/psp_merchant_api/__init__.py @@ -0,0 +1 @@ +from psp_merchant_api import test_psp_merchant_api diff --git a/test_integration_app/psp_merchant_api/test_psp_merchant_api.py b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py new file mode 100644 index 0000000..c321f0d --- /dev/null +++ b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py @@ -0,0 +1,70 @@ +import sift +import string +import random # define the random module + +from os import environ as env + +class PSPMerchantAPI(): + # Get the value of API_KEY and ACCOUNT_ID from environment variable + api_key = env['API_KEY'] + account_id = env['ACCOUNT_ID'] + + client = sift.Client(api_key = api_key, account_id = account_id) + + def create_merchant(self): + merchant_id = ''.join(random.choices(string.digits, k = 7)) + merchantProperties={ + "id": 'merchant_id_' + merchant_id, + "name": "Wonderful Payments Inc.13", + "description": "Wonderful Payments payment provider.", + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320" + }, + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": { + "level": "low", + "score": 10 + } + } + return self.client.create_psp_merchant_profile(merchantProperties) + + def edit_merchant(self): + merchantProperties={ + "id": "merchant_id_01013", + "name": "Wonderful Payments Inc.13 edit", + "description": "Wonderful Payments payment provider. edit", + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320" + }, + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": { + "level": "low", + "score": 10 + } + } + return self.client.update_psp_merchant_profile("merchant_id_01013", merchantProperties) + + def get_a_merchant_profile(self): + return self.client.get_a_psp_merchant_profile("merchant_id_01013") + + def get_merchant_profiles(self, batch_token = None, batch_size = None): + return self.client.get_psp_merchant_profiles(batch_token, batch_size) + \ No newline at end of file diff --git a/test_integration_app/score_api/__init__.py b/test_integration_app/score_api/__init__.py new file mode 100644 index 0000000..71ccddf --- /dev/null +++ b/test_integration_app/score_api/__init__.py @@ -0,0 +1 @@ +from score_api import test_score_api diff --git a/test_integration_app/score_api/test_score_api.py b/test_integration_app/score_api/test_score_api.py new file mode 100644 index 0000000..5c61b22 --- /dev/null +++ b/test_integration_app/score_api/test_score_api.py @@ -0,0 +1,14 @@ +import sift +import globals + +from os import environ as env + +class ScoreAPI(): + # Get the value of API_KEY from environment variable + api_key = env['API_KEY'] + client = sift.Client(api_key = api_key) + globals.initialize() + user_id = globals.user_id + + def get_user_score(self): + return self.client.get_user_score(user_id = self.user_id, abuse_types=["payment_abuse", "promotion_abuse"]) diff --git a/test_integration_app/verifications_api/__init__.py b/test_integration_app/verifications_api/__init__.py new file mode 100644 index 0000000..710885a --- /dev/null +++ b/test_integration_app/verifications_api/__init__.py @@ -0,0 +1 @@ +from verifications_api import test_verification_api diff --git a/test_integration_app/verifications_api/test_verification_api.py b/test_integration_app/verifications_api/test_verification_api.py new file mode 100644 index 0000000..b35478d --- /dev/null +++ b/test_integration_app/verifications_api/test_verification_api.py @@ -0,0 +1,52 @@ +import sift +import globals + +from os import environ as env + +class VerificationAPI(): + # Get the value of API_KEY from environment variable + api_key = env['API_KEY'] + client = sift.Client(api_key = api_key) + globals.initialize() + user_id = globals.user_id + user_email = globals.user_email + + def send(self): + sendProperties = { + '$user_id': self.user_id, + '$send_to': self.user_email, + '$verification_type': '$email', + '$brand_name': 'MyTopBrand', + '$language': 'en', + '$site_country': 'IN', + '$event': { + '$session_id': 'SOME_SESSION_ID', + '$verified_event': '$login', + '$verified_entity_id': 'SOME_SESSION_ID', + '$reason': '$automated_rule', + '$ip': '192.168.1.1', + '$browser': { + '$user_agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36', + '$accept_language': 'en-US', + '$content_language': 'en-GB' + } + } + } + return self.client.verification_send(sendProperties) + + def resend(self): + resendProperties = { + '$user_id': self.user_id, + '$verified_event': '$login', + '$verified_entity_id': 'SOME_SESSION_ID' + } + return self.client.verification_resend(resendProperties) + + def check(self): + checkProperties = { + '$user_id': self.user_id, + '$code': '123456', + '$verified_event': '$login', + '$verified_entity_id': "SOME_SESSION_ID" + } + return self.client.verification_check(checkProperties) diff --git a/test_integration_app/workflows_api/__init__.py b/test_integration_app/workflows_api/__init__.py new file mode 100644 index 0000000..0b1a1fe --- /dev/null +++ b/test_integration_app/workflows_api/__init__.py @@ -0,0 +1 @@ +from workflows_api import test_workflows_api diff --git a/test_integration_app/workflows_api/test_workflows_api.py b/test_integration_app/workflows_api/test_workflows_api.py new file mode 100644 index 0000000..4dd26d6 --- /dev/null +++ b/test_integration_app/workflows_api/test_workflows_api.py @@ -0,0 +1,20 @@ +import sift +import globals +from os import environ as env + +class WorkflowsAPI(): + # Get the value of API_KEY from environment variable + api_key = env['API_KEY'] + client = sift.Client(api_key = api_key) + globals.initialize() + user_id = globals.user_id + user_email = globals.user_email + + def synchronous_workflows(self): + properties = { + '$user_id' : self.user_id, + '$user_email' : self.user_email + } + return self.client.track('$create_order', properties, return_workflow_status=True, + return_route_info=True, abuse_types=['promo_abuse', 'content_abuse', 'payment_abuse']) + \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index b7a6553..8438829 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -22,7 +22,7 @@ def valid_transaction_properties(): '$seller_user_id': '654321', '$amount': Decimal('1253200.0'), '$currency_code': 'USD', - '$time': int(datetime.datetime.now().strftime('%s')), + '$time': int(datetime.datetime.now().strftime('%S')), '$transaction_id': 'my_transaction_id', '$billing_name': 'Mike Snow', '$billing_bin': '411111', diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index a7af5b8..d1fa5a1 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -19,7 +19,7 @@ def valid_transaction_properties(): '$seller_user_id': '654321', '$amount': Decimal('1253200.0'), '$currency_code': 'USD', - '$time': int(datetime.datetime.now().strftime('%s')), + '$time': int(datetime.datetime.now().strftime('%S')), '$transaction_id': 'my_transaction_id', '$billing_name': 'Mike Snow', '$billing_bin': '411111', From b1809a06a748e95f779b926436329e177ad2fd01 Mon Sep 17 00:00:00 2001 From: Vitalii Iaskal <112494848+viaskal-sift@users.noreply.github.com> Date: Mon, 23 Oct 2023 12:08:27 +0300 Subject: [PATCH 080/112] [API-7364] - Add integration tests to CI (#105) [API-7364] - Tests fixes + CI step for the integration app --- .circleci/config.yml | 15 +++++++ README.md | 22 +++++++++++ .../decisions_api/test_decisions_api.py | 9 +++-- test_integration_app/globals.py | 3 +- test_integration_app/main.py | 39 +++++++++---------- .../psp_merchant_api/test_psp_merchant_api.py | 17 ++++---- 6 files changed, 70 insertions(+), 35 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4cbf05d..329c073 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,8 +39,23 @@ jobs: mock-version: 3.0.5 requests-version: 2.27.1 + run-integration-tests-python3: + docker: + - image: cimg/python:3.10.2 + steps: + - checkout + - run: + name: Install the lib and run tests + command: | + pip3 install ../project + python3 test_integration_app/main.py + workflows: build-and-test-wf: jobs: - build-and-test-python3 - build-and-test-python2 + - run-integration-tests-python3: + filters: + branches: + only: master diff --git a/README.md b/README.md index 6e928fa..7f48711 100644 --- a/README.md +++ b/README.md @@ -243,3 +243,25 @@ errors from the root dir of the repository: python -m unittest discover python3 -m unittest discover + +## Integration testing app + +For testing the app with real calls it is possible to run the integration testing app, +it makes calls to almost all our public endpoints to make sure the library integrates +well. At the moment, the app is run on every merge to master + +#### How to run it locally + +1. Add env variable `ACCOUNT_ID` with the valid account id +2. Add env variable `API_KEY` with the valid Api Key associated from the account +3. Run the following under the project root folder +``` +# uninstall the lib from the local env (if it was installed) +pip uninstall sift + +# install the lib from the local source code +pip install ../sift-python + +# run the app +python test_integration_app/main.py +``` diff --git a/test_integration_app/decisions_api/test_decisions_api.py b/test_integration_app/decisions_api/test_decisions_api.py index 1993f3d..55d2146 100644 --- a/test_integration_app/decisions_api/test_decisions_api.py +++ b/test_integration_app/decisions_api/test_decisions_api.py @@ -10,10 +10,11 @@ class DecisionAPI(): client = sift.Client(api_key = api_key, account_id = account_id) globals.initialize() user_id = globals.user_id + session_id = globals.session_id def apply_user_decision(self): applyDecisionRequest = { - "decision_id" : "block_user_payment_abuse", + "decision_id" : "integration_app_watch_account_abuse", "source" : "MANUAL_REVIEW", "analyst" : "analyst@example.com", "description" : "User linked to three other payment abusers and ordering high value items" @@ -32,17 +33,17 @@ def apply_order_decision(self): def apply_session_decision(self): applySessionDecisionRequest = { - "decision_id" : "session_looks_fraud_account_takover", + "decision_id" : "integration_app_watch_account_takeover", "source" : "MANUAL_REVIEW", "analyst" : "analyst@example.com", "description" : "compromised account reported to customer service" } - return self.client.apply_session_decision(self.user_id, "session_id", applySessionDecisionRequest) + return self.client.apply_session_decision(self.user_id, self.session_id, applySessionDecisionRequest) def apply_content_decision(self): applyContentDecisionRequest = { - "decision_id" : "content_looks_fraud_content_abuse", + "decision_id" : "integration_app_watch_content_abuse", "source" : "MANUAL_REVIEW", "analyst" : "analyst@example.com", "description" : "fraudulent listing" diff --git a/test_integration_app/globals.py b/test_integration_app/globals.py index 5cb65ab..030531e 100644 --- a/test_integration_app/globals.py +++ b/test_integration_app/globals.py @@ -1,4 +1,5 @@ def initialize(): - global user_id, user_email + global user_id, user_email, session_id user_id = 'billy_jones_301' user_email = 'billjones1@example.com' + session_id = 'gigtleqddo84l8cm15qe4il' diff --git a/test_integration_app/main.py b/test_integration_app/main.py index 348de2d..eca8726 100644 --- a/test_integration_app/main.py +++ b/test_integration_app/main.py @@ -1,3 +1,6 @@ +import string +import random + from events_api import test_events_api from decisions_api import test_decisions_api from workflows_api import test_workflows_api @@ -22,7 +25,6 @@ def runAllMethods(): objPSPMerchant = test_psp_merchant_api.PSPMerchantAPI() #Events APIs - assert (objUtils.isOK(objEvents.add_item_to_cart()) == True) assert (objUtils.isOK(objEvents.add_promotion()) == True) assert (objUtils.isOK(objEvents.chargeback()) == True) @@ -54,48 +56,43 @@ def runAllMethods(): assert (objUtils.isOK(objEvents.update_password()) == True) assert (objUtils.isOK(objEvents.verification()) == True) print("Events API Tested") - - # Decision APIs - assert (objUtils.isOK(objDecision.apply_user_decision()) == False) - assert (objUtils.isOK(objDecision.apply_order_decision()) == False) - assert (objUtils.isOK(objDecision.apply_session_decision()) == False) - assert (objUtils.isOK(objDecision.apply_content_decision()) == False) + # Decision APIs + assert (objUtils.isOK(objDecision.apply_user_decision()) == True) + assert (objUtils.isOK(objDecision.apply_order_decision()) == True) + assert (objUtils.isOK(objDecision.apply_session_decision()) == True) + assert (objUtils.isOK(objDecision.apply_content_decision()) == True) assert (objUtils.isOK(objDecision.get_user_decisions()) == True) assert (objUtils.isOK(objDecision.get_order_decisions()) == True) assert (objUtils.isOK(objDecision.get_content_decisions()) == True) assert (objUtils.isOK(objDecision.get_session_decisions()) == True) assert (objUtils.isOK(objDecision.get_decisions()) == True) print("Decision API Tested") - - # Workflows APIs + # Workflows APIs assert (objUtils.isOK(objWorkflow.synchronous_workflows()) == True) print("Workflow API Tested") - - # Score APIs - assert (objUtils.isOK(objScore.get_user_score()) == False) + # Score APIs + assert (objUtils.isOK(objScore.get_user_score()) == True) print("Score API Tested") - - # Verification APIs + # Verification APIs assert (objUtils.isOK(objVerification.send()) == True) assert (objUtils.isOK(objVerification.resend()) == True) checkResponse = objVerification.check() assert (objUtils.isOK(checkResponse) == True) assert (checkResponse.body["status"] == 50) print("Verification API Tested") - - # PSP Merchant APIs - assert (objUtils.isOK(objPSPMerchant.create_merchant()) == True) - assert (objUtils.isOK(objPSPMerchant.edit_merchant()) == True) - assert (objUtils.isOK(objPSPMerchant.get_a_merchant_profile()) == True) + # PSP Merchant APIs + merchant_id = 'merchant_id_test_app' + ''.join(random.choices(string.digits, k = 7)) + assert (objUtils.isOK(objPSPMerchant.create_merchant(merchant_id)) == True) + assert (objUtils.isOK(objPSPMerchant.edit_merchant(merchant_id)) == True) assert (objUtils.isOK(objPSPMerchant.get_merchant_profiles()) == True) assert (objUtils.isOK(objPSPMerchant.get_merchant_profiles(batch_size=10, batch_token=None)) == True) print("PSP Merchant API Tested") - - print("Execution completed") + + print("API Integration tests execution finished") runAllMethods() diff --git a/test_integration_app/psp_merchant_api/test_psp_merchant_api.py b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py index c321f0d..3d44114 100644 --- a/test_integration_app/psp_merchant_api/test_psp_merchant_api.py +++ b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py @@ -11,10 +11,9 @@ class PSPMerchantAPI(): client = sift.Client(api_key = api_key, account_id = account_id) - def create_merchant(self): - merchant_id = ''.join(random.choices(string.digits, k = 7)) + def create_merchant(self, merchant_id): merchantProperties={ - "id": 'merchant_id_' + merchant_id, + "id": merchant_id, "name": "Wonderful Payments Inc.13", "description": "Wonderful Payments payment provider.", "address": { @@ -37,9 +36,9 @@ def create_merchant(self): } return self.client.create_psp_merchant_profile(merchantProperties) - def edit_merchant(self): - merchantProperties={ - "id": "merchant_id_01013", + def edit_merchant(self, merchant_id): + merchantProperties = { + "id": merchant_id, "name": "Wonderful Payments Inc.13 edit", "description": "Wonderful Payments payment provider. edit", "address": { @@ -60,10 +59,10 @@ def edit_merchant(self): "score": 10 } } - return self.client.update_psp_merchant_profile("merchant_id_01013", merchantProperties) + return self.client.update_psp_merchant_profile(merchant_id, merchantProperties) - def get_a_merchant_profile(self): - return self.client.get_a_psp_merchant_profile("merchant_id_01013") + def get_a_merchant_profile(self, merchant_id): + return self.client.get_a_psp_merchant_profile(merchant_id) def get_merchant_profiles(self, batch_token = None, batch_size = None): return self.client.get_psp_merchant_profiles(batch_token, batch_size) From 77df02fb391854d76fd690ad2a9a9762c33f59bc Mon Sep 17 00:00:00 2001 From: Haneesh Vahab <135820493+haneeshv@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:38:26 +0530 Subject: [PATCH 081/112] Updating setup.py to work with python 3.12 (#106) --- setup.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 6d35a62..278864e 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,20 @@ -import imp +try: + from imp import load_source +except ImportError: + import importlib.util + import importlib.machinery + + def load_source(modname, filename): + loader = importlib.machinery.SourceFileLoader(modname, filename) + spec = importlib.util.spec_from_file_location(modname, filename, loader=loader) + module = importlib.util.module_from_spec(spec) + # The module is always executed and not cached in sys.modules. + # Uncomment the following line to cache the module. + # sys.modules[module.__name__] = module + loader.exec_module(module) + return module + + import os try: @@ -16,8 +32,8 @@ README = '' CHANGES = '' -# Use imp to avoid sift/__init__.py -version_mod = imp.load_source('__tmp', os.path.join(here, 'sift/version.py')) +# Use imp/importlib to avoid sift/__init__.py +version_mod = load_source('__tmp', os.path.join(here, 'sift/version.py')) setup( name='Sift', From 61d3dcc17c236653cafcf8b71e0599f43ebfd71d Mon Sep 17 00:00:00 2001 From: Vitalii Iaskal <112494848+viaskal-sift@users.noreply.github.com> Date: Thu, 22 Feb 2024 15:23:37 +0200 Subject: [PATCH 082/112] Release 5.5.1 (#107) --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index ba98b37..ac4444f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.5.1 2024-02-22 +- Support for Python 3.12 + 5.5.0 2023-10-03 - Score percentiles for Score API diff --git a/sift/version.py b/sift/version.py index e3f5976..9d6e80b 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.5.0' +VERSION = '5.5.1' API_VERSION = '205' From f12f35decef2700fa0786b8e52448dcde8b31484 Mon Sep 17 00:00:00 2001 From: Sam Salamakha <117682970+ssalamakha-sift@users.noreply.github.com> Date: Fri, 23 Feb 2024 13:35:47 +0100 Subject: [PATCH 083/112] Update publishing2PyPI.yml --- .github/workflows/publishing2PyPI.yml | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publishing2PyPI.yml b/.github/workflows/publishing2PyPI.yml index 871d813..b9b2c83 100644 --- a/.github/workflows/publishing2PyPI.yml +++ b/.github/workflows/publishing2PyPI.yml @@ -25,20 +25,13 @@ jobs: echo "Version $curr_version does not match tag $TAG" exit 1 fi - - name: Configure pypirc - run: | - cat << EOF > ~/.pypirc - [distutils] - index-servers = - pypi - [pypi] - username=${{ secrets.USER }} - password=${{ secrets.PASS }} - EOF - name: Create distribution files run: | python3 setup.py sdist - name: Upload distribution files + env: + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + TWINE_USER: ${{ secrets.USER }} run: | python3 -m pip install --user --upgrade twine - ls dist/ | xargs -I % python3 -m twine upload --repository pypi dist/% \ No newline at end of file + ls dist/ | xargs -I % python3 -m twine upload --repository pypi dist/% From 559eaefd7316471a8cfb11e6b3041487607747a0 Mon Sep 17 00:00:00 2001 From: Nicole Su Date: Thu, 18 Apr 2024 09:47:30 -0700 Subject: [PATCH 084/112] CI-15480 Create jenkins CI pipeline (#108) --- .jenkins/Jenkinsfile | 118 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .jenkins/Jenkinsfile diff --git a/.jenkins/Jenkinsfile b/.jenkins/Jenkinsfile new file mode 100644 index 0000000..70b63bf --- /dev/null +++ b/.jenkins/Jenkinsfile @@ -0,0 +1,118 @@ +// Load Jenkins shared library +jenkinsBranch = 'master' +sharedLib = library("shared-lib@${jenkinsBranch}") + +def siftPythonWorkflow = sharedLib.com.sift.ci.SiftPythonWorkflow.new() +def ciUtil = sharedLib.com.sift.ci.CIUtil.new() +def stackdriver = sharedLib.com.sift.ci.StackDriverMetrics.new() + +// Default GitHub status context for automatically triggered builds +def defaultStatusContext = 'Jenkins:auto' + +// Pod template file for Jenkins agent pod +// Pod template yaml file is defined in https://github.com/SiftScience/jenkins/tree/master/resources/jenkins-k8s-pod-templates +def python2PodTemplateFile = 'python-2-7-pod-template.yaml' +def python3PodTemplateFile = 'python-3-10-pod-template.yaml' +def python2PodLabel = "python2-${BUILD_TAG}" +def python3PodLabel = "python3-${BUILD_TAG}" + + +// GitHub repo name +def repoName = 'sift-python' + +pipeline { + agent none + options { + timestamps() + skipDefaultCheckout() + disableConcurrentBuilds() + disableRestartFromStage() + parallelsAlwaysFailFast() + buildDiscarder logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '30', numToKeepStr: '') + } + environment { + GIT_BRANCH = "${env.CHANGE_BRANCH != null? env.CHANGE_BRANCH : env.BRANCH_NAME}" + } + stages { + stage('Initialize') { + steps { + script { + statusContext = defaultStatusContext + // Get the commit sha for the build + commitSha = ciUtil.commitHashForBuild() + ciUtil.updateGithubCommitStatus(repoName, statusContext, 'Started', 'pending', commitSha) + } + } + } + stage ('Build and Test Workflows') { + steps { + script { + def workflows = [:] + def stage1 = 'Run Integration Tests - python3' + workflows[stage1] = { + stage(stage1) { + if (env.GIT_BRANCH.equals('master')) { + ciUtil.updateGithubCommitStatus(repoName, stage1, 'Started', 'pending', commitSha) + try { + siftPythonWorkflow.runSiftPythonIntegration(python3PodTemplateFile, python3PodLabel) + ciUtil.updateGithubCommitStatus(repoName, stage1, 'SUCCESS', 'success', commitSha) + } catch (Exception e) { + ciUtil.updateGithubCommitStatus(repoName, stage1, 'FAILURE', 'failure', commitSha) + print("${stage1} failed") + throw e + } + } + } + } + def stage2 = 'Build and Test - python2' + workflows[stage2] = { + stage(stage2) { + ciUtil.updateGithubCommitStatus(repoName, stage2, 'Started', 'pending', commitSha) + try { + siftPythonWorkflow.runSiftPythonBuildAndTest(python2PodTemplateFile, python2PodLabel, '3.0.5', '2.27.1') + ciUtil.updateGithubCommitStatus(repoName, stage2, 'SUCCESS', 'success', commitSha) + } catch (Exception e) { + ciUtil.updateGithubCommitStatus(repoName, stage2, 'FAILURE', 'failure', commitSha) + print("${stage2} failed") + throw e + } + } + } + def stage3 = 'Build and Test - python3' + workflows[stage3] = { + stage(stage3) { + ciUtil.updateGithubCommitStatus(repoName, stage3, 'Started', 'pending', commitSha) + try { + siftPythonWorkflow.runSiftPythonBuildAndTest(python3PodTemplateFile, python3PodLabel, '5.0.1', '2.28.2') + ciUtil.updateGithubCommitStatus(repoName, stage3, 'SUCCESS', 'success', commitSha) + } catch (Exception e) { + ciUtil.updateGithubCommitStatus(repoName, stage3, 'FAILURE', 'failure', commitSha) + print("${stage3} failed") + throw e + } + } + } + parallel workflows + } + } + } + } + post { + success { + script { + ciUtil.updateGithubCommitStatus(repoName, statusContext, currentBuild.currentResult, 'success', commitSha) + } + } + unsuccessful { + script { + ciUtil.updateGithubCommitStatus(repoName, statusContext, currentBuild.currentResult, 'failure', commitSha) + ciUtil.notifySlack(repoName, commitSha) + } + } + always { + script { + stackdriver.updatePipelineStatistics(this) + } + } + } +} From 8e21669a822e18c92c3fb8c978423133172aaa56 Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Mon, 22 Apr 2024 12:02:29 -0700 Subject: [PATCH 085/112] Update Jenkins CI pipeline to use version tag instead of master as jenkins repo branch --- .jenkins/Jenkinsfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.jenkins/Jenkinsfile b/.jenkins/Jenkinsfile index 70b63bf..b96b29e 100644 --- a/.jenkins/Jenkinsfile +++ b/.jenkins/Jenkinsfile @@ -1,5 +1,5 @@ // Load Jenkins shared library -jenkinsBranch = 'master' +jenkinsBranch = 'v0.1.0' sharedLib = library("shared-lib@${jenkinsBranch}") def siftPythonWorkflow = sharedLib.com.sift.ci.SiftPythonWorkflow.new() @@ -29,6 +29,7 @@ pipeline { disableRestartFromStage() parallelsAlwaysFailFast() buildDiscarder logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '30', numToKeepStr: '') + timeout(time: 1, unit: 'HOURS') } environment { GIT_BRANCH = "${env.CHANGE_BRANCH != null? env.CHANGE_BRANCH : env.BRANCH_NAME}" From 065ae94c06c8cfdbd5a2129a428dee61a3e5ec16 Mon Sep 17 00:00:00 2001 From: Ihor Prysiazhnyi <133665403+iprysiazhnyi-sift@users.noreply.github.com> Date: Fri, 31 May 2024 12:26:02 +0300 Subject: [PATCH 086/112] [API-7661] Adds support for warnings in Events API response (#110) --- README.md | 8 + sift/client.py | 25 ++- .../events_api/test_events_api.py | 185 +++++++++--------- test_integration_app/main.py | 19 +- tests/test_client.py | 38 ++++ 5 files changed, 182 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 7f48711..288a8df 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,14 @@ except sift.client.ApiException: # request failed pass +# To include `warnings` field to Events API response via calling `track()` method, set it by the `include_warnings` param: +try: + response = client.track("$transaction", properties, include_warnings=True) + # ... +except sift.client.ApiException: + # request failed + pass + # Request a score for the user with user_id 23056 try: response = client.score(user_id) diff --git a/sift/client.py b/sift/client.py index 37c9d6e..1d0f487 100644 --- a/sift/client.py +++ b/sift/client.py @@ -97,7 +97,8 @@ def track( abuse_types=None, timeout=None, version=None, - include_score_percentiles=False): + include_score_percentiles=False, + include_warnings=False): """Track an event and associated properties to the Sift Science client. This call is blocking. Check out https://siftscience.com/resources/references/events-api for more information on what types of events you can send and fields you can add to the @@ -137,6 +138,10 @@ def track( include_score_percentiles(optional) : Whether to add new parameter in the query parameter. if include_score_percentiles is true then add a new parameter called fields in the query parameter + include_warnings(optional) : Whether the API response should include `warnings` field. + if include_warnings is True `warnings` field returns the amount of validation warnings + along with their descriptions. They are not critical enough to reject the whole request, + but important enough to be fixed. Returns: A sift.client.Response object if the track call succeeded, otherwise raises an ApiException. @@ -179,8 +184,12 @@ def track( if force_workflow_run: params['force_workflow_run'] = 'true' - if include_score_percentiles: - params['fields'] = 'SCORE_PERCENTILES' + include_fields = Client._get_fields_param(include_score_percentiles, + include_warnings) + if include_fields: + params['fields'] = ",".join(include_fields) + + try: response = self.session.post( path, @@ -1120,6 +1129,16 @@ def _verification_resend_url(self): def _verification_check_url(self): return (API_URL_VERIFICATION + 'check') + @staticmethod + def _get_fields_param(include_score_percentiles, include_warnings): + return [ + field for include, field in [ + (include_score_percentiles, 'SCORE_PERCENTILES'), + (include_warnings, 'WARNINGS') + ] if include + ] + + class Response(object): HTTP_CODES_WITHOUT_BODY = [204, 304] diff --git a/test_integration_app/events_api/test_events_api.py b/test_integration_app/events_api/test_events_api.py index f123a73..04607e0 100644 --- a/test_integration_app/events_api/test_events_api.py +++ b/test_integration_app/events_api/test_events_api.py @@ -445,122 +445,131 @@ def create_content_review(self): def create_order(self): # Sample $create_order event + order_properties = self.build_create_order_event() + return self.client.track("$create_order", order_properties) + + def create_order_with_warnings(self): + # Sample $create_order event + order_properties = self.build_create_order_event() + return self.client.track("$create_order", order_properties, include_warnings=True) + + def build_create_order_event(self): order_properties = { # Required Fields - "$user_id" : self.user_id, + "$user_id": self.user_id, # Supported Fields - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$order_id" : "ORDER-28168441", - "$user_email" : self.user_email, - "$verification_phone_number" : "+123456789012", - "$amount" : 115940000, # $115.94 - "$currency_code" : "USD", - "$billing_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" + "$session_id": "gigtleqddo84l8cm15qe4il", + "$order_id": "ORDER-28168441", + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$amount": 115940000, # $115.94 + "$currency_code": "USD", + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257" }, - "$payment_methods" : [ + "$payment_methods": [ { - "$payment_type" : "$credit_card", - "$payment_gateway" : "$braintree", - "$card_bin" : "542486", - "$card_last4" : "4444" + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444" } ], - "$ordered_from" : { - "$store_id" : "123", - "$store_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6040", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" + "$ordered_from": { + "$store_id": "123", + "$store_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257" } }, - "$brand_name" : "sift", - "$site_domain" : "sift.com", - "$site_country" : "US", - "$shipping_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257" }, - "$expedited_shipping" : True, - "$shipping_method" : "$physical", - "$shipping_carrier" : "UPS", + "$expedited_shipping": True, + "$shipping_method": "$physical", + "$shipping_carrier": "UPS", "$shipping_tracking_numbers": ["1Z204E380338943508", "1Z204E380338943509"], - "$items" : [ + "$items": [ { - "$item_id" : "12344321", - "$product_title" : "Microwavable Kettle Corn: Original Flavor", - "$price" : 4990000, # $4.99 - "$upc" : "097564307560", - "$sku" : "03586005", - "$brand" : "Peters Kettle Corn", - "$manufacturer" : "Peters Kettle Corn", - "$category" : "Food and Grocery", - "$tags" : ["Popcorn", "Snacks", "On Sale"], - "$quantity" : 4 + "$item_id": "12344321", + "$product_title": "Microwavable Kettle Corn: Original Flavor", + "$price": 4990000, # $4.99 + "$upc": "097564307560", + "$sku": "03586005", + "$brand": "Peters Kettle Corn", + "$manufacturer": "Peters Kettle Corn", + "$category": "Food and Grocery", + "$tags": ["Popcorn", "Snacks", "On Sale"], + "$quantity": 4 }, { - "$item_id" : "B004834GQO", - "$product_title" : "The Slanket Blanket-Texas Tea", - "$price" : 39990000, # $39.99 - "$upc" : "6786211451001", - "$sku" : "004834GQ", - "$brand" : "Slanket", - "$manufacturer" : "Slanket", - "$category" : "Blankets & Throws", - "$tags" : ["Awesome", "Wintertime specials"], - "$color" : "Texas Tea", - "$quantity" : 2 + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + "$quantity": 2 } ], # For marketplaces, use $seller_user_id to identify the seller - "$seller_user_id" : "slinkys_emporium", + "$seller_user_id": "slinkys_emporium", - "$promotions" : [ + "$promotions": [ { - "$promotion_id" : "FirstTimeBuyer", - "$status" : "$success", - "$description" : "$5 off", - "$discount" : { - "$amount" : 5000000, # $5.00 - "$currency_code" : "USD", - "$minimum_purchase_amount" : 25000000 # $25.00 + "$promotion_id": "FirstTimeBuyer", + "$status": "$success", + "$description": "$5 off", + "$discount": { + "$amount": 5000000, # $5.00 + "$currency_code": "USD", + "$minimum_purchase_amount": 25000000 # $25.00 } } ], # Sample Custom Fields - "digital_wallet" : "apple_pay", # "google_wallet", etc. - "coupon_code" : "dollarMadness", - "shipping_choice" : "FedEx Ground Courier", - "is_first_time_buyer" : False, + "digital_wallet": "apple_pay", # "google_wallet", etc. + "coupon_code": "dollarMadness", + "shipping_choice": "FedEx Ground Courier", + "is_first_time_buyer": False, # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB" } } - return self.client.track("$create_order", order_properties) - + return order_properties + def flag_content(self): # Sample $flag_content event flag_content_properties = { diff --git a/test_integration_app/main.py b/test_integration_app/main.py index eca8726..63fd928 100644 --- a/test_integration_app/main.py +++ b/test_integration_app/main.py @@ -14,7 +14,17 @@ def isOK(self, response): return ((response.status == 0) and ((response.http_status_code == 200) or (response.http_status_code == 201))) else: return ((response.http_status_code == 200) or (response.http_status_code == 201)) - + + def is_ok_with_warnings(self, response): + return self.isOK(response) and \ + hasattr(response, 'body') and \ + len(response.body['warnings']) > 0 + + def is_ok_without_warnings(self, response): + return self.isOK(response) and \ + hasattr(response, 'body') and \ + 'warnings' not in response.body + def runAllMethods(): objUtils = Utils() objEvents = test_events_api.EventsAPI() @@ -24,7 +34,7 @@ def runAllMethods(): objVerification = test_verification_api.VerificationAPI() objPSPMerchant = test_psp_merchant_api.PSPMerchantAPI() - #Events APIs + # Events APIs assert (objUtils.isOK(objEvents.add_item_to_cart()) == True) assert (objUtils.isOK(objEvents.add_promotion()) == True) assert (objUtils.isOK(objEvents.chargeback()) == True) @@ -55,6 +65,11 @@ def runAllMethods(): assert (objUtils.isOK(objEvents.update_order()) == True) assert (objUtils.isOK(objEvents.update_password()) == True) assert (objUtils.isOK(objEvents.verification()) == True) + + # Testing include warnings query param + assert (objUtils.is_ok_without_warnings(objEvents.create_order()) == True) + assert (objUtils.is_ok_with_warnings(objEvents.create_order_with_warnings()) == True) + print("Events API Tested") # Decision APIs diff --git a/tests/test_client.py b/tests/test_client.py index 8438829..2a5e094 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1499,6 +1499,44 @@ def test_get_user_score_include_score_percentiles_ok(self): assert (response.body['scores']['payment_abuse']['score'] == 0.97) assert ('latest_decisions' in response.body) + def test_warnings_added_as_fields_param(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track(event, valid_transaction_properties(), + include_warnings=True) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={'fields': 'WARNINGS'}) + self.assertIsInstance(response, sift.client.Response) + + def test_warnings_and_score_percentiles_added_as_fields_param(self): + event = '$transaction' + mock_response = mock.Mock() + mock_response.content = '{"status": 0, "error_message": "OK"}' + mock_response.json.return_value = json.loads(mock_response.content) + mock_response.status_code = 200 + with mock.patch.object(self.sift_client.session, 'post') as mock_post: + mock_post.return_value = mock_response + response = self.sift_client.track(event, valid_transaction_properties(), + include_score_percentiles=True, + include_warnings=True) + mock_post.assert_called_with( + 'https://api.siftscience.com/v205/events', + data=mock.ANY, + headers=mock.ANY, + timeout=mock.ANY, + params={'fields': 'SCORE_PERCENTILES,WARNINGS'}) + self.assertIsInstance(response, sift.client.Response) + + def main(): unittest.main() From a714f15f5c65ca30a204c7209fd8a23c8383a4f7 Mon Sep 17 00:00:00 2001 From: Ihor Prysiazhnyi <133665403+iprysiazhnyi-sift@users.noreply.github.com> Date: Fri, 31 May 2024 16:46:38 +0300 Subject: [PATCH 087/112] Release 5.6.0 (#111) --- CHANGES.md | 3 +++ sift/version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index ac4444f..b58e5cc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +5.6.0 2024-05-31 +- Added support for a `warnings` value in the `fields` query parameter + 5.5.1 2024-02-22 - Support for Python 3.12 diff --git a/sift/version.py b/sift/version.py index 9d6e80b..1b357cd 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.5.1' +VERSION = '5.6.0' API_VERSION = '205' From 49520432c8b2af4da3e54a397001650db149cfb3 Mon Sep 17 00:00:00 2001 From: Phani Kumar Mallampati <85580100+pmallampati-sift@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:01:58 -0700 Subject: [PATCH 088/112] Jenkins CI to github actions (#112) * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions * Jenkins CI to github actions --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..26808d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI-WORKFLOW + +on: + push: + branches: + - master + pull_request: + branches: + - master + +permissions: + contents: read + +env: + mock_version_python3: "5.0.1" + requests_version_python3: "2.28.2" + ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} + API_KEY: ${{ secrets.API_KEY }} + +jobs: + build-and-test-python3: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10.14" + - name: Install test dependencies + run: | + pip install mock=="${{ env.mock_version_python3 }}" + pip install requests=="${{ env.requests_version_python3 }}" + - name: Run tests + run: | + python -m unittest discover + + run-integration-tests-python3: + runs-on: ubuntu-latest + if: ${{ github.ref == 'refs/heads/master' }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10.14" + - name: run-integration-tests-python3 + run: | + pip3 install . + python3 test_integration_app/main.py From 1cb9ac86ccc6573be470ad2631a71c5d83118e2a Mon Sep 17 00:00:00 2001 From: Jie Lin Date: Fri, 21 Jun 2024 15:21:21 -0700 Subject: [PATCH 089/112] Remove jenkins CI and circleci config (#113) --- .circleci/config.yml | 61 ---------------------- .jenkins/Jenkinsfile | 119 ------------------------------------------- 2 files changed, 180 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 .jenkins/Jenkinsfile diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 329c073..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,61 +0,0 @@ -version: 2.1 - -orbs: - python: circleci/python@2.1.1 - -commands: - install-dependencies-run-tests: - parameters: - mock-version: - type: string - requests-version: - type: string - steps: - - run: - name: Install test dependencies - command: | - pip install mock==<> - pip install requests==<> - - run: - name: Run tests - command: python -m unittest discover - -jobs: - build-and-test-python3: - docker: - - image: cimg/python:3.10.2 - steps: - - checkout - - install-dependencies-run-tests: - mock-version: 5.0.1 - requests-version: 2.28.2 - - build-and-test-python2: - docker: - - image: cimg/python:2.7.18 - steps: - - checkout - - install-dependencies-run-tests: - mock-version: 3.0.5 - requests-version: 2.27.1 - - run-integration-tests-python3: - docker: - - image: cimg/python:3.10.2 - steps: - - checkout - - run: - name: Install the lib and run tests - command: | - pip3 install ../project - python3 test_integration_app/main.py - -workflows: - build-and-test-wf: - jobs: - - build-and-test-python3 - - build-and-test-python2 - - run-integration-tests-python3: - filters: - branches: - only: master diff --git a/.jenkins/Jenkinsfile b/.jenkins/Jenkinsfile deleted file mode 100644 index b96b29e..0000000 --- a/.jenkins/Jenkinsfile +++ /dev/null @@ -1,119 +0,0 @@ -// Load Jenkins shared library -jenkinsBranch = 'v0.1.0' -sharedLib = library("shared-lib@${jenkinsBranch}") - -def siftPythonWorkflow = sharedLib.com.sift.ci.SiftPythonWorkflow.new() -def ciUtil = sharedLib.com.sift.ci.CIUtil.new() -def stackdriver = sharedLib.com.sift.ci.StackDriverMetrics.new() - -// Default GitHub status context for automatically triggered builds -def defaultStatusContext = 'Jenkins:auto' - -// Pod template file for Jenkins agent pod -// Pod template yaml file is defined in https://github.com/SiftScience/jenkins/tree/master/resources/jenkins-k8s-pod-templates -def python2PodTemplateFile = 'python-2-7-pod-template.yaml' -def python3PodTemplateFile = 'python-3-10-pod-template.yaml' -def python2PodLabel = "python2-${BUILD_TAG}" -def python3PodLabel = "python3-${BUILD_TAG}" - - -// GitHub repo name -def repoName = 'sift-python' - -pipeline { - agent none - options { - timestamps() - skipDefaultCheckout() - disableConcurrentBuilds() - disableRestartFromStage() - parallelsAlwaysFailFast() - buildDiscarder logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '30', numToKeepStr: '') - timeout(time: 1, unit: 'HOURS') - } - environment { - GIT_BRANCH = "${env.CHANGE_BRANCH != null? env.CHANGE_BRANCH : env.BRANCH_NAME}" - } - stages { - stage('Initialize') { - steps { - script { - statusContext = defaultStatusContext - // Get the commit sha for the build - commitSha = ciUtil.commitHashForBuild() - ciUtil.updateGithubCommitStatus(repoName, statusContext, 'Started', 'pending', commitSha) - } - } - } - stage ('Build and Test Workflows') { - steps { - script { - def workflows = [:] - def stage1 = 'Run Integration Tests - python3' - workflows[stage1] = { - stage(stage1) { - if (env.GIT_BRANCH.equals('master')) { - ciUtil.updateGithubCommitStatus(repoName, stage1, 'Started', 'pending', commitSha) - try { - siftPythonWorkflow.runSiftPythonIntegration(python3PodTemplateFile, python3PodLabel) - ciUtil.updateGithubCommitStatus(repoName, stage1, 'SUCCESS', 'success', commitSha) - } catch (Exception e) { - ciUtil.updateGithubCommitStatus(repoName, stage1, 'FAILURE', 'failure', commitSha) - print("${stage1} failed") - throw e - } - } - } - } - def stage2 = 'Build and Test - python2' - workflows[stage2] = { - stage(stage2) { - ciUtil.updateGithubCommitStatus(repoName, stage2, 'Started', 'pending', commitSha) - try { - siftPythonWorkflow.runSiftPythonBuildAndTest(python2PodTemplateFile, python2PodLabel, '3.0.5', '2.27.1') - ciUtil.updateGithubCommitStatus(repoName, stage2, 'SUCCESS', 'success', commitSha) - } catch (Exception e) { - ciUtil.updateGithubCommitStatus(repoName, stage2, 'FAILURE', 'failure', commitSha) - print("${stage2} failed") - throw e - } - } - } - def stage3 = 'Build and Test - python3' - workflows[stage3] = { - stage(stage3) { - ciUtil.updateGithubCommitStatus(repoName, stage3, 'Started', 'pending', commitSha) - try { - siftPythonWorkflow.runSiftPythonBuildAndTest(python3PodTemplateFile, python3PodLabel, '5.0.1', '2.28.2') - ciUtil.updateGithubCommitStatus(repoName, stage3, 'SUCCESS', 'success', commitSha) - } catch (Exception e) { - ciUtil.updateGithubCommitStatus(repoName, stage3, 'FAILURE', 'failure', commitSha) - print("${stage3} failed") - throw e - } - } - } - parallel workflows - } - } - } - } - post { - success { - script { - ciUtil.updateGithubCommitStatus(repoName, statusContext, currentBuild.currentResult, 'success', commitSha) - } - } - unsuccessful { - script { - ciUtil.updateGithubCommitStatus(repoName, statusContext, currentBuild.currentResult, 'failure', commitSha) - ciUtil.notifySlack(repoName, commitSha) - } - } - always { - script { - stackdriver.updatePipelineStatistics(this) - } - } - } -} From 7b02705b5b2bc27df6f533a0c9d5f104756d8a7e Mon Sep 17 00:00:00 2001 From: Eldar Singin Date: Mon, 7 Oct 2024 17:23:32 +0300 Subject: [PATCH 090/112] [API-7827]: sift-python: Stop using url parameters to authenticate Score API requests - updated client.get_user_score() --- sift/client.py | 6 ++++-- tests/test_client.py | 16 ++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/sift/client.py b/sift/client.py index 1d0f487..2c66bda 100644 --- a/sift/client.py +++ b/sift/client.py @@ -7,6 +7,7 @@ import requests import requests.auth import sys +from requests.auth import HTTPBasicAuth if sys.version_info[0] < 3: import six.moves.urllib as urllib @@ -284,7 +285,7 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None, include_score_ url = self._user_score_url(user_id, self.version) headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + params = {} if abuse_types: params['abuse_types'] = ','.join(abuse_types) @@ -296,7 +297,8 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None, include_score_ url, headers=headers, timeout=timeout, - params=params) + params=params, + auth=HTTPBasicAuth(self.api_key, '')) return Response(response) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) diff --git a/tests/test_client.py b/tests/test_client.py index 2a5e094..5efc582 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,7 @@ import unittest import warnings from decimal import Decimal +from requests.auth import HTTPBasicAuth import mock import requests.exceptions @@ -388,9 +389,10 @@ def test_get_user_score_ok(self): response = self.sift_client.get_user_score('12345', test_timeout) mock_get.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key}, + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") @@ -415,9 +417,10 @@ def test_get_user_score_with_abuse_types_ok(self): timeout=test_timeout) mock_get.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + params={'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") @@ -1488,9 +1491,10 @@ def test_get_user_score_include_score_percentiles_ok(self): response = self.sift_client.get_user_score(user_id='12345', timeout=test_timeout, include_score_percentiles=True) mock_get.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, 'fields': 'SCORE_PERCENTILES'}, + params={'fields': 'SCORE_PERCENTILES'}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") From 33e9add89132d6abdf4d8da91cd4ecacda2558af Mon Sep 17 00:00:00 2001 From: Eldar Singin Date: Mon, 7 Oct 2024 17:37:14 +0300 Subject: [PATCH 091/112] [API-7827]: sift-python: Stop using url parameters to authenticate Score API requests - updated client.score() to use Basic Authentication --- sift/client.py | 5 +++-- tests/test_client.py | 20 ++++++++++++-------- tests/test_client_v203.py | 16 ++++++++++------ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/sift/client.py b/sift/client.py index 2c66bda..d30566f 100644 --- a/sift/client.py +++ b/sift/client.py @@ -235,7 +235,7 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None, include_s version = self.version headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + params = {} if abuse_types: params['abuse_types'] = ','.join(abuse_types) @@ -249,7 +249,8 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None, include_s url, headers=headers, timeout=timeout, - params=params) + params=params, + auth=HTTPBasicAuth(self.api_key, '')) return Response(response) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) diff --git a/tests/test_client.py b/tests/test_client.py index 5efc582..7da507c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -343,9 +343,10 @@ def test_score_ok(self): response = self.sift_client.score('12345') mock_get.assert_called_with( 'https://api.siftscience.com/v205/score/12345', - params={'api_key': self.test_key}, + params={}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") @@ -365,9 +366,10 @@ def test_score_with_timeout_param_ok(self): response = self.sift_client.score('12345', test_timeout) mock_get.assert_called_with( 'https://api.siftscience.com/v205/score/12345', - params={'api_key': self.test_key}, + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") @@ -1058,9 +1060,10 @@ def test_score__with_special_user_id_chars_ok(self): response = self.sift_client.score(user_id, abuse_types=['legacy']) mock_get.assert_called_with( 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote(user_id), - params={'api_key': self.test_key, 'abuse_types': 'legacy'}, + params={'abuse_types': 'legacy'}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") @@ -1467,9 +1470,10 @@ def test_score_api_include_score_percentiles_ok(self): response = self.sift_client.score(user_id='12345', include_score_percentiles=True) mock_get.assert_called_with( 'https://api.siftscience.com/v205/score/12345', - params={'api_key': self.test_key, 'fields': 'SCORE_PERCENTILES'}, + params={'fields': 'SCORE_PERCENTILES'}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index d1fa5a1..88110cf 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -7,6 +7,7 @@ import unittest import sys import requests.exceptions +from requests.auth import HTTPBasicAuth if sys.version_info[0] < 3: import six.moves.urllib as urllib else: @@ -166,9 +167,10 @@ def test_score_ok(self): response = self.sift_client_v204.score('12345', version='203') mock_get.assert_called_with( 'https://api.siftscience.com/v203/score/12345', - params={'api_key': self.test_key}, + params={}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") @@ -186,9 +188,10 @@ def test_score_with_timeout_param_ok(self): response = self.sift_client.score('12345', test_timeout) mock_get.assert_called_with( 'https://api.siftscience.com/v203/score/12345', - params={'api_key': self.test_key}, + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") @@ -373,9 +376,10 @@ def test_score__with_special_user_id_chars_ok(self): response = self.sift_client.score(user_id) mock_get.assert_called_with( 'https://api.siftscience.com/v203/score/%s' % urllib.parse.quote(user_id), - params={'api_key': self.test_key}, + params={}, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) assert(response.api_error_message == "OK") From 93a2978206d5f4c0c6397a61581103238ef44509 Mon Sep 17 00:00:00 2001 From: Eldar Singin Date: Mon, 7 Oct 2024 17:42:26 +0300 Subject: [PATCH 092/112] [API-7827]: sift-python: Stop using url parameters to authenticate Score API requests - updated client.score() to use Basic Authentication --- sift/client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sift/client.py b/sift/client.py index d30566f..47adb41 100644 --- a/sift/client.py +++ b/sift/client.py @@ -7,7 +7,6 @@ import requests import requests.auth import sys -from requests.auth import HTTPBasicAuth if sys.version_info[0] < 3: import six.moves.urllib as urllib @@ -250,7 +249,7 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None, include_s headers=headers, timeout=timeout, params=params, - auth=HTTPBasicAuth(self.api_key, '')) + auth=requests.auth.HTTPBasicAuth(self.api_key, '')) return Response(response) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) @@ -299,7 +298,7 @@ def get_user_score(self, user_id, timeout=None, abuse_types=None, include_score_ headers=headers, timeout=timeout, params=params, - auth=HTTPBasicAuth(self.api_key, '')) + auth=requests.auth.HTTPBasicAuth(self.api_key, '')) return Response(response) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) From bfb677c4e3d8b9b8f41580a59a45e770f9e4cb85 Mon Sep 17 00:00:00 2001 From: Eldar Singin Date: Mon, 7 Oct 2024 18:03:57 +0300 Subject: [PATCH 093/112] [API-7827]: sift-python: Stop using url parameters to authenticate Score API requests - updated client.rescore_user() to use Basic Authentication --- sift/client.py | 5 +++-- tests/test_client.py | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/sift/client.py b/sift/client.py index 47adb41..e9722a6 100644 --- a/sift/client.py +++ b/sift/client.py @@ -328,7 +328,7 @@ def rescore_user(self, user_id, timeout=None, abuse_types=None): url = self._user_score_url(user_id, self.version) headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + params = {} if abuse_types: params['abuse_types'] = ','.join(abuse_types) @@ -337,7 +337,8 @@ def rescore_user(self, user_id, timeout=None, abuse_types=None): url, headers=headers, timeout=timeout, - params=params) + params=params, + auth=requests.auth.HTTPBasicAuth(self.api_key, '')) return Response(response) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) diff --git a/tests/test_client.py b/tests/test_client.py index 7da507c..a4eb962 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -445,9 +445,10 @@ def test_rescore_user_ok(self): response = self.sift_client.rescore_user('12345', test_timeout) mock_post.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key}, + params={}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") @@ -472,9 +473,10 @@ def test_rescore_user_with_abuse_types_ok(self): timeout=test_timeout) mock_post.assert_called_with( 'https://api.siftscience.com/v205/users/12345/score', - params={'api_key': self.test_key, 'abuse_types': 'payment_abuse,content_abuse'}, + params={'abuse_types': 'payment_abuse,content_abuse'}, headers=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) assert (response.api_error_message == "OK") From 61003376bbc0e481d94d480227489404a6e8fd28 Mon Sep 17 00:00:00 2001 From: Eldar Singin Date: Mon, 7 Oct 2024 18:15:51 +0300 Subject: [PATCH 094/112] [API-7827]: sift-python: Stop using url parameters to authenticate Score API requests - updated client.rescore_user() to use Basic Authentication --- sift/client.py | 5 +++-- tests/test_client.py | 6 ++++-- tests/test_client_v203.py | 6 ++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/sift/client.py b/sift/client.py index e9722a6..87469a7 100644 --- a/sift/client.py +++ b/sift/client.py @@ -404,7 +404,7 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): url = self._label_url(user_id, version) headers = {'User-Agent': self._user_agent()} - params = {'api_key': self.api_key} + params = {} if abuse_type: params['abuse_type'] = abuse_type @@ -413,7 +413,8 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): url, headers=headers, timeout=timeout, - params=params) + params=params, + auth=requests.auth.HTTPBasicAuth(self.api_key, '')) return Response(response) except requests.exceptions.RequestException as e: diff --git a/tests/test_client.py b/tests/test_client.py index a4eb962..cc6a6b4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -975,7 +975,8 @@ def test_unlabel_user_ok(self): 'https://api.siftscience.com/v205/users/%s/labels' % user_id, headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key, 'abuse_type': 'account_abuse'}) + params={'abuse_type': 'account_abuse'}, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) @@ -1015,7 +1016,8 @@ def test_unlabel_user_with_special_chars_ok(self): 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key}) + params={}, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert (response.is_ok()) diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index 88110cf..606d741 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -288,7 +288,8 @@ def test_unlabel_user_ok(self): 'https://api.siftscience.com/v203/users/%s/labels' % user_id, headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key}) + params={}, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) @@ -329,7 +330,8 @@ def test_unlabel_user_with_special_chars_ok(self): 'https://api.siftscience.com/v203/users/%s/labels' % urllib.parse.quote(user_id), headers=mock.ANY, timeout=mock.ANY, - params={'api_key': self.test_key}) + params={}, + auth=HTTPBasicAuth(self.test_key, '')) self.assertIsInstance(response, sift.client.Response) assert(response.is_ok()) From 5b3a75d1c43c3f90cd51d0e45be9d91224775d70 Mon Sep 17 00:00:00 2001 From: Eldar Singin Date: Tue, 8 Oct 2024 14:09:25 +0300 Subject: [PATCH 095/112] Release 5.6.1 (#115) --- CHANGES.md | 7 +++++++ sift/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index b58e5cc..0b4536c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,10 @@ +5.6.1 2024-10-08 +- Updated implementation to use Basic Authentication instead of passing `API_KEY` as a request parameter for the following calls: + - `client.score()` + - `client.get_user_score()` + - `client.rescore_user()` + - `client.unlabel()` + 5.6.0 2024-05-31 - Added support for a `warnings` value in the `fields` query parameter diff --git a/sift/version.py b/sift/version.py index 1b357cd..d3560c1 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.6.0' +VERSION = '5.6.1' API_VERSION = '205' From 24b1dba848c92fedd4e0fecdd3ade6631f58c096 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Mon, 24 Mar 2025 16:04:45 +0100 Subject: [PATCH 096/112] Add typing. Apply code style. Update documentation --- .flake8 | 5 + .gitignore | 1 + .pre-commit-config.yaml | 24 + .travis.yml | 21 +- CHANGES.md | 10 +- CONTRIBUTING.md | 0 pyproject.toml | 68 + requirements-dev.txt | 5 + setup.py | 71 - sift/__init__.py | 10 +- sift/client.py | 1962 ++++++++----- sift/constants.py | 7 + sift/exceptions.py | 24 + sift/utils.py | 20 + sift/version.py | 4 +- .../decisions_api/test_decisions_api.py | 107 +- .../events_api/test_events_api.py | 2556 ++++++++--------- test_integration_app/globals.py | 10 +- test_integration_app/main.py | 182 +- .../psp_merchant_api/test_psp_merchant_api.py | 124 +- .../score_api/test_score_api.py | 27 +- .../test_verification_api.py | 88 +- .../workflows_api/test_workflows_api.py | 34 +- tests/test_client.py | 1524 ++++++---- tests/test_client_v203.py | 523 ++-- tests/test_verification_apis.py | 102 +- 26 files changed, 4261 insertions(+), 3248 deletions(-) create mode 100644 .flake8 create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt delete mode 100644 setup.py create mode 100644 sift/constants.py create mode 100644 sift/exceptions.py create mode 100644 sift/utils.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..321faca --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +ignore = E501,W503 +per-file-ignores = __init__.py:F401 +max-line-length = 79 +disable-noqa = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 15fafde..ebc2184 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +__pycache__ *.py[cod] # C extensions diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..087e858 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/crate-ci/typos + rev: v1.30.2 + hooks: + - id: typos + args: [ --force-exclude ] + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + - repo: https://github.com/pycqa/flake8 + rev: 7.1.2 + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [ --install-types, --non-interactive ] + additional_dependencies: [ types-requests ] diff --git a/.travis.yml b/.travis.yml index 98c2bd6..ff6e263 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,23 @@ language: python python: - - "2.7" - - "3.4" + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" +before_install: + - python --version + - pip install -U pip # command to install dependencies install: - - pip install -e .[test] + - pip install -e . + - pip install -r requirements-dev.txt # command to run tests script: - - unit2 + - flake8 --count + - black --check . + - isort --check . + - mypy --install-types --non-interactive . + - typos + - python -m unittest discover diff --git a/CHANGES.md b/CHANGES.md index 0b4536c..349c7a0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +6.0.0 Unreleased +================ +- Support for Python 3.13 + +INCOMPATIBLE CHANGES INTRODUCED IN 6.0.0: + +- Removed support for Python < 3.8 + 5.6.1 2024-10-08 - Updated implementation to use Basic Authentication instead of passing `API_KEY` as a request parameter for the following calls: - `client.score()` @@ -127,7 +135,7 @@ INCOMPATIBLE CHANGES INTRODUCED IN API V205: 1.1.2.0 (2015-02-04) ==================== -- Added Unlabel functionaly +- Added Unlabel functionality - Minor bug fixes. 1.1.1.0 (2014-09-3) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ebb193 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "Sift" +version = "6.0.0" +authors = [ + {name = "Sift Science", email = "support@siftscience.com"}, +] +description = "Python bindings for Sift Science's API" +readme = "README.md" +license = {file = "LICENSE"} +classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "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", + "Operating System :: OS Independent", + "Typing :: Typed", + "License :: OSI Approved :: MIT License", +] +keywords = ["sift", "sift-python"] +requires-python = ">= 3.8" +dependencies = [ + "requests < 3.0.0", +] + +[project.urls] +Source = "https://github.com/SiftScience/sift-python" +Changelog = "https://github.com/SiftScience/sift-python/blob/master/CHANGES.md" + +[tool.setuptools] +packages = ["sift"] + +[tool.black] +line-length = 79 + +[tool.isort] +profile = "black" +combine_as_imports = true +remove_redundant_aliases = true +line_length = 79 +skip = [ + "build", +] + +[tool.mypy] +follow_imports_for_stubs = false +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unreachable = true +warn_return_any = true +warn_no_return = true +enable_error_code = "possibly-undefined,ignore-without-code" +exclude = [ + "build", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a78b232 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +black==24.8.0 +flake8==7.1.2 +isort==5.13.2 +mypy==1.14.1 +typos==1.30.2 diff --git a/setup.py b/setup.py deleted file mode 100644 index 278864e..0000000 --- a/setup.py +++ /dev/null @@ -1,71 +0,0 @@ -try: - from imp import load_source -except ImportError: - import importlib.util - import importlib.machinery - - def load_source(modname, filename): - loader = importlib.machinery.SourceFileLoader(modname, filename) - spec = importlib.util.spec_from_file_location(modname, filename, loader=loader) - module = importlib.util.module_from_spec(spec) - # The module is always executed and not cached in sys.modules. - # Uncomment the following line to cache the module. - # sys.modules[module.__name__] = module - loader.exec_module(module) - return module - - -import os - -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - - -here = os.path.abspath(os.path.dirname(__file__)) - -try: - README = open(os.path.join(here, 'README.md')).read() - CHANGES = open(os.path.join(here, 'CHANGES.md')).read() -except Exception: - README = '' - CHANGES = '' - -# Use imp/importlib to avoid sift/__init__.py -version_mod = load_source('__tmp', os.path.join(here, 'sift/version.py')) - -setup( - name='Sift', - description='Python bindings for Sift Science\'s API', - version=version_mod.VERSION, - url='https://siftscience.com', - python_requires=">=2.7", - - author='Sift Science', - author_email='support@siftscience.com', - long_description_content_type="text/markdown", - long_description=README + '\n\n' + CHANGES, - - packages=['sift'], - install_requires=[ - "requests >= 0.14.1", - "six >= 1.16.0", - ], - extras_require={ - 'test': [ - 'mock >= 1.0.1', - 'unittest2 >= 1, < 2', - ], - }, - - classifiers=[ - "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Topic :: Software Development :: Libraries :: Python Modules" - ] -) diff --git a/sift/__init__.py b/sift/__init__.py index c1b28a2..e9ad03f 100644 --- a/sift/__init__.py +++ b/sift/__init__.py @@ -1,3 +1,9 @@ -api_key = None -account_id = None +from __future__ import annotations + from .client import Client +from .version import VERSION + +__version__ = VERSION + +api_key: str | None = None +account_id: str | None = None diff --git a/sift/client.py b/sift/client.py index 87469a7..8b9c85b 100644 --- a/sift/client.py +++ b/sift/client.py @@ -1,231 +1,571 @@ """Python client for Sift Science's API. -See: https://siftscience.com/docs/references/events-api +See: https://developers.sift.com/docs/python/events-api """ -import decimal +from __future__ import annotations + import json -import requests -import requests.auth import sys +import typing as t +from collections.abc import Mapping, Sequence + +import requests +from requests.auth import HTTPBasicAuth + +import sift +from sift.constants import API_URL, DECISION_SOURCES +from sift.exceptions import ApiException +from sift.utils import DecimalEncoder, quote_path as _q +from sift.version import API_VERSION, VERSION + +AbuseType = t.Literal[ + "account_abuse", + "account_takeover", + "content_abuse", + "legacy", + "payment_abuse", + # TODO: Ask which of the following is supported (?) + "promo_abuse", + "promotion_abuse", +] + + +def _assert_non_empty_str( + val: object, + name: str, + error_cls: type[Exception] | None = None, +) -> None: + error = f"{name} must be a non-empty string" + + if not isinstance(val, str): + error_cls = error_cls or TypeError + raise error_cls(error) -if sys.version_info[0] < 3: - import six.moves.urllib as urllib + if not val: + error_cls = error_cls or ValueError + raise error_cls(error) - _UNICODE_STRING = str -else: - import urllib.parse - _UNICODE_STRING = str +def _assert_non_empty_dict(val: object, name: str) -> None: + error = f"{name} must be a non-empty dict" + + if not isinstance(val, dict): + raise TypeError(error) + + if not val: + raise ValueError(error) -import sift -import sift.version -API_URL = 'https://api.siftscience.com' -API3_URL = 'https://api3.siftscience.com' -API_URL_VERIFICATION = 'https://api.sift.com/v1/verification/' +class Response: + HTTP_CODES_WITHOUT_BODY = (204, 304) -DECISION_SOURCES = ['MANUAL_REVIEW', 'AUTOMATED_RULE', 'CHARGEBACK'] + def __init__(self, http_response: requests.Response) -> None: + """ + Raises ApiException on invalid JSON in Response body or non-2XX HTTP + status code. + """ + + self.url: str = http_response.url + self.http_status_code: int = http_response.status_code + self.api_status: int | None = None + self.api_error_message: str | None = None + self.body: dict[str, t.Any] | None = None + self.request: dict[str, t.Any] | None = None + + if ( + self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY + ) and http_response.text: + try: + self.body = http_response.json() + + if "status" in self.body: + self.api_status = self.body["status"] + if "error_message" in self.body: + self.api_error_message = self.body["error_message"] + + if isinstance(self.body.get("request"), str): + self.request = json.loads(self.body["request"]) + except ValueError: + raise ApiException( + f"Failed to parse json response from {self.url}", + url=self.url, + http_status_code=self.http_status_code, + body=self.body, + api_status=self.api_status, + api_error_message=self.api_error_message, + request=self.request, + ) + finally: + if not 200 <= self.http_status_code < 300: + raise ApiException( + f"{self.url} returned non-2XX http status code {self.http_status_code}", + url=self.url, + http_status_code=self.http_status_code, + body=self.body, + api_status=self.api_status, + api_error_message=self.api_error_message, + request=self.request, + ) -def _quote_path(s): - # by default, urllib.quote doesn't escape forward slash; pass the - # optional arg to override this - return urllib.parse.quote(s, '') + def __str__(self) -> str: + body = ( + f'"body": {json.dumps(self.body)}, ' + if self.body is not None + else "" + ) + return f'{body}"http_status_code": {self.http_status_code}' -class DecimalEncoder(json.JSONEncoder): - def default(self, o): - if isinstance(o, decimal.Decimal): - return (str(o),) - return super(DecimalEncoder, self).default(o) + def is_ok(self) -> bool: + return self.api_status == 0 or self.http_status_code in (200, 204) -class Client(object): +class Client: + api_key: str + account_id: str def __init__( - self, - api_key=None, - api_url=API_URL, - timeout=2.0, - account_id=None, - version=sift.version.API_VERSION, - session=None): + self, # TODO: Require to pass all arguments as a keyword arguments (?) + api_key: str | None = None, + api_url: str = API_URL, + timeout: ( + int + | float + | tuple[int | float, int | float] + | tuple[int | float, int | float] + ) = 2, + account_id: str | None = None, # TODO: Move as a second argument (?) + version: str = API_VERSION, + session: requests.Session | None = None, + ) -> None: """Initialize the client. Args: - api_key: Your Sift Science API key associated with your customer - account. You can obtain this from - https://siftscience.com/console/developer/api-keys . + api_key: + The Sift Science API key associated with your account. You can + obtain it from https://console.sift.com/developer/api-keys - api_url: Base URL, including scheme and host, for sending events. - Defaults to 'https://api.siftscience.com'. + api_url(optional): + Base URL, including scheme and host, for sending events. + Defaults to 'https://api.sift.com'. - timeout: Number of seconds to wait before failing request. Defaults - to 2 seconds. + timeout(optional): + Number of seconds to wait before failing a request. + Defaults to 2 seconds. - account_id: The ID of your Sift Science account. You can obtain - this from https://siftscience.com/console/account/profile . + account_id(optional): + The ID of your Sift Science account. You can obtain + it from https://developers.sift.com/console/account/profile - version: The version of the Sift Science API to call. Defaults to - the latest version ('205'). + version{optional}: + The version of the Sift Science API to call. + Defaults to the latest version. + session(optional): + requests.Session object + https://requests.readthedocs.io/en/latest/user/advanced/#session-objects """ - _assert_non_empty_unicode(api_url, 'api_url') + _assert_non_empty_str(api_url, "api_url") if api_key is None: api_key = sift.api_key - _assert_non_empty_unicode(api_key, 'api_key') + _assert_non_empty_str(api_key, "api_key") self.session = session or requests.Session() - self.api_key = api_key + self.api_key = t.cast(str, api_key) self.url = api_url self.timeout = timeout - self.account_id = account_id or sift.account_id + self.account_id = t.cast(str, account_id or sift.account_id) self.version = version - def track( - self, - event, - properties, - path=None, - return_score=False, - return_action=False, - return_workflow_status=False, - return_route_info=False, - force_workflow_run=False, - abuse_types=None, - timeout=None, - version=None, - include_score_percentiles=False, - include_warnings=False): - """Track an event and associated properties to the Sift Science client. - This call is blocking. Check out https://siftscience.com/resources/references/events-api - for more information on what types of events you can send and fields you can add to the - properties parameter. + @staticmethod + def _get_fields_param( + include_score_percentiles: bool, + include_warnings: bool, + ) -> list[str]: + return [ + field + for include, field in ( + (include_score_percentiles, "SCORE_PERCENTILES"), + (include_warnings, "WARNINGS"), + ) + if include + ] - Args: - event: The name of the event to send. This can either be a reserved - event name such as "$transaction" or "$create_order" or a custom event - name (that does not start with a $). + def _auth(self) -> HTTPBasicAuth: + return HTTPBasicAuth(self.api_key, "") + + def _api_url(self, version: str, endpoint: str) -> str: + return f"{self.url}/{version}{endpoint}" + + def _versioned_api(self, version: str, endpoint: str) -> str: + return self._api_url(f"v{version}", endpoint) + + def _v1_api(self, endpoint: str) -> str: + return self._api_url("v1", endpoint) + + def _v3_api(self, endpoint: str) -> str: + return self._api_url("v3", endpoint) + + def _user_agent(self, version: str | None = None) -> str: + return ( + f"SiftScience/v{version or self.version} " + f"sift-python/{VERSION} " + f"Python/{sys.version.split(' ')[0]}" + ) + + def _headers(self, version: str | None = None) -> dict[str, str]: + return { + "User-Agent": self._user_agent(version), + } + + def _post_headers(self, version: str | None = None) -> dict[str, str]: + return { + "Content-type": "application/json", + "Accept": "*/*", + "User-Agent": self._user_agent(version), + } + + def _events_url(self, version: str) -> str: + return self._versioned_api(version, "/events") - properties: A dict of additional event-specific attributes to track. + def _score_url(self, user_id: str, version: str) -> str: + return self._versioned_api(version, f"/score/{_q(user_id)}") - return_score: Whether the API response should include a score for this - user (the score will be calculated using this event). + def _user_score_url(self, user_id: str, version: str) -> str: + return self._versioned_api(version, f"/users/{_q(user_id)}/score") - return_action: Whether the API response should include actions in the response. For - more information on how this works, please visit the tutorial at: - https://siftscience.com/resources/tutorials/formulas . + def _labels_url(self, user_id: str, version: str) -> str: + return self._versioned_api(version, f"/users/{_q(user_id)}/labels") - return_workflow_status: Whether the API response should - include the status of any workflow run as a result of - the tracked event. + def _workflow_status_url(self, account_id: str, run_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/workflows/runs/{_q(run_id)}" + ) - return_route_info: Whether to get the route information from the Workflow Decision. - This parameter must be used with the return_workflow_status query parameter. + def _decisions_url(self, account_id: str) -> str: + return self._v3_api(f"/accounts/{_q(account_id)}/decisions") - force_workflow_run: TODO:(rlong) Add after Rishabh adds documentation. + def _order_decisions_url(self, account_id: str, order_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/orders/{_q(order_id)}/decisions" + ) - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + def _user_decisions_url(self, account_id: str, user_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/decisions" + ) - timeout(optional): Use a custom timeout (in seconds) for this call. + def _session_decisions_url( + self, account_id: str, user_id: str, session_id: str + ) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/sessions/{_q(session_id)}/decisions" + ) - version(optional): Use a different version of the Sift Science API for this call. + def _content_decisions_url( + self, account_id: str, user_id: str, content_id: str + ) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/content/{_q(content_id)}/decisions" + ) - include_score_percentiles(optional) : Whether to add new parameter in the query parameter. - if include_score_percentiles is true then add a new parameter called fields in the query parameter + def _order_apply_decisions_url( + self, account_id: str, user_id: str, order_id: str + ) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/users/{_q(user_id)}/orders/{_q(order_id)}/decisions" + ) - include_warnings(optional) : Whether the API response should include `warnings` field. - if include_warnings is True `warnings` field returns the amount of validation warnings - along with their descriptions. They are not critical enough to reject the whole request, + def _psp_merchant_url(self, account_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/psp_management/merchants" + ) + + def _psp_merchant_id_url(self, account_id: str, merchant_id: str) -> str: + return self._v3_api( + f"/accounts/{_q(account_id)}/psp_management/merchants/{_q(merchant_id)}" + ) + + def _verification_send_url(self) -> str: + return self._v1_api("/verification/send") + + def _verification_resend_url(self) -> str: + return self._v1_api("/verification/resend") + + def _verification_check_url(self) -> str: + return self._v1_api("/verification/check") + + def _validate_send_request(self, properties: Mapping[str, t.Any]) -> None: + """This method is used to validate arguments passed to the send method.""" + + _assert_non_empty_dict(properties, "properties") + + user_id = properties.get("$user_id") + _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) + + send_to = properties.get("$send_to") + _assert_non_empty_str(send_to, "send_to", error_cls=ValueError) + + verification_type = properties.get("$verification_type") + _assert_non_empty_str( + verification_type, "verification_type", error_cls=ValueError + ) + + event = properties.get("$event") + if not isinstance(event, dict): + raise TypeError("$event must be a dict") + elif not event: + raise ValueError("$event dictionary may not be empty") + + session_id = event.get("$session_id") + _assert_non_empty_str(session_id, "session_id", error_cls=ValueError) + + def _validate_resend_request( + self, + properties: Mapping[str, t.Any], + ) -> None: + """This method is used to validate arguments passed to the send method.""" + + _assert_non_empty_dict(properties, "properties") + + user_id = properties.get("$user_id") + _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) + + def _validate_check_request(self, properties: Mapping[str, t.Any]) -> None: + """This method is used to validate arguments passed to the check method.""" + + _assert_non_empty_dict(properties, "properties") + + user_id = properties.get("$user_id") + _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) + + otp_code = properties.get("$code") + if otp_code is None: + raise ValueError("code is required") + + def _validate_apply_decision_request( + self, + properties: Mapping[str, t.Any], + user_id: str, + ) -> None: + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_dict(properties, "properties") + + source = properties.get("source") + + _assert_non_empty_str(source, "source", error_cls=ValueError) + + if source not in DECISION_SOURCES: + raise ValueError( + f"decision 'source' must be one of {list(DECISION_SOURCES)}" + ) + + if source == "MANUAL_REVIEW" and not properties.get("analyst"): + raise ValueError( + "must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'" + ) + + def track( + self, + event: str, + properties: Mapping[str, t.Any], + path: str | None = None, + return_score: bool = False, + return_action: bool = False, + return_workflow_status: bool = False, + return_route_info: bool = False, + force_workflow_run: bool = False, + abuse_types: Sequence[AbuseType] | None = None, + timeout: int | float | tuple[int | float, int | float] | None = None, + version: str | None = None, + include_score_percentiles: bool = False, + include_warnings: bool = False, + ) -> Response: + """ + Track an event and associated properties to the Sift Science client. + + This call is blocking. + + Visit https://siftscience.com/resources/references/events-api + for more information on what types of events you can send and fields + you can add to the properties parameter. + + Args: + event: + The name of the event to send. This can either be a reserved + event name such as "$transaction" or "$create_order" or + a custom event name (that does not start with a $). + + properties: + A dict of additional event-specific attributes to track. + + path: + An API endpoint to make a request to. + Defaults to Events API Endpoint + + return_score (optional): + Whether the API response should include a score for + this user (the score will be calculated using this event). + + return_action (optional): + Whether the API response should include actions in the + response. For more information on how this works, please + visit the tutorial at: + https://developers.sift.com/tutorials/formulas . + + return_workflow_status (optional): + Whether the API response should include the status of any + workflow run as a result of the tracked event. + + return_route_info (optional): + Whether to get the route information from the Workflow + Decision. This parameter must be used with the + `return_workflow_status` query parameter. + + force_workflow_run (optional): + Set to True to run the Workflow Asynchronously if your Workflow + is set to only run on API Request. If a Workflow is not running + on the event you send this with, there will be no error or + score response, and no workflow will run. + + abuse_types (optional): + A Sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. + + timeout (optional): + Use a custom timeout (in seconds) for this call. + + version (optional): + Use a different version of the Sift Science API for this call. + + include_score_percentiles (optional): + Whether to add new parameter in the query parameter. if + `include_score_percentiles` is True then add a new parameter + called fields in the query parameter + + include_warnings (optional): + Whether the API response should include `warnings` field. + If `include_warnings` is True `warnings` field returns the + amount of validation warnings along with their descriptions. + They are not critical enough to reject the whole request, but important enough to be fixed. + Returns: - A sift.client.Response object if the track call succeeded, otherwise - raises an ApiException. + A sift.client.Response object if the call succeeded + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(event, 'event') - _assert_non_empty_dict(properties, 'properties') - - headers = {'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()} + _assert_non_empty_str(event, "event") + _assert_non_empty_dict(properties, "properties") if version is None: version = self.version if path is None: - path = self._event_url(version) + path = self._events_url(version) if timeout is None: timeout = self.timeout - properties.update({'$api_key': self.api_key, '$type': event}) - params = {} + _properties = { + **properties, + "$api_key": self.api_key, + "$type": event, + } + + params: dict[str, t.Any] = {} if return_score: - params['return_score'] = 'true' + params["return_score"] = "true" if return_action: - params['return_action'] = 'true' + params["return_action"] = "true" if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) if return_workflow_status: - params['return_workflow_status'] = 'true' + params["return_workflow_status"] = "true" if return_route_info: - params['return_route_info'] = 'true' + params["return_route_info"] = "true" if force_workflow_run: - params['force_workflow_run'] = 'true' + params["force_workflow_run"] = "true" - include_fields = Client._get_fields_param(include_score_percentiles, - include_warnings) - if include_fields: - params['fields'] = ",".join(include_fields) + include_fields = self._get_fields_param( + include_score_percentiles, include_warnings + ) + if include_fields: + params["fields"] = ",".join(include_fields) try: response = self.session.post( path, - data=json.dumps(properties, cls=DecimalEncoder), - headers=headers, + data=json.dumps(_properties, cls=DecimalEncoder), + headers=self._post_headers(version), timeout=timeout, - params=params) - return Response(response) + params=params, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), path) - def score(self, user_id, timeout=None, abuse_types=None, version=None, include_score_percentiles=False): - """Retrieves a user's fraud score from the Sift Science API. - This call is blocking. Check out https://siftscience.com/resources/references/score_api.html - for more information on our Score response structure. + return Response(response) + + def score( + self, + user_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + abuse_types: Sequence[AbuseType] | None = None, + version: str | None = None, + include_score_percentiles: bool = False, + ) -> Response: + """ + Retrieves a user's fraud score from the Sift Science API. + + This call is blocking. + + Visit https://developers.sift.com/docs/python/score-api + for more details on our Score response structure. Args: - user_id: A user's id. This id should be the same as the user_id used in - event calls. + user_id: + A user's id. This id should be the same as the `user_id` + used in event calls. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + Use a custom timeout (in seconds) for this call. - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + abuse_types (optional): + A Sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. - version(optional): Use a different version of the Sift Science API for this call. + version (optional): + Use a different version of the Sift Science API for this call. - include_score_percentiles(optional) : Whether to add new parameter in the query parameter. - if include_score_percentiles is true then add a new parameter called fields in the query parameter + include_score_percentiles (optional): + Whether to add new parameter in the query parameter. + if `include_score_percentiles` is True then add a new + parameter called `fields` in the query parameter Returns: - A sift.client.Response object if the score call succeeded, or raises - an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -233,168 +573,248 @@ def score(self, user_id, timeout=None, abuse_types=None, version=None, include_s if version is None: version = self.version - headers = {'User-Agent': self._user_agent()} - params = {} + params: dict[str, t.Any] = {} + if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) if include_score_percentiles: - params['fields'] = 'SCORE_PERCENTILES' + params["fields"] = "SCORE_PERCENTILES" url = self._score_url(user_id, version) try: response = self.session.get( url, - headers=headers, - timeout=timeout, params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, '')) - return Response(response) + auth=self._auth(), + headers=self._headers(version), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_user_score(self, user_id, timeout=None, abuse_types=None, include_score_percentiles=False): - """Fetches the latest score(s) computed for the specified user and abuse types from the Sift Science API. - As opposed to client.score() and client.rescore_user(), this *does not* compute a new score for the user; it - simply fetches the latest score(s) which have computed. These scores may be arbitrarily old. + return Response(response) + + def get_user_score( + self, + user_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + abuse_types: Sequence[AbuseType] | None = None, + include_score_percentiles: bool = False, + ) -> Response: + """ + Fetches the latest score(s) computed for the specified user and + abuse types from the Sift Science API. As opposed to client.score() + and client.rescore_user(), this *does not* compute a new score for + the user; it simply fetches the latest score(s) which have computed. + These scores may be arbitrarily old. + + This call is blocking. - This call is blocking. See https://siftscience.com/developers/docs/python/score-api/get-score for more details. + Visit https://developers.sift.com/docs/python/score-api/get-score + for more details. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + Use a custom timeout (in seconds) for this call. - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + abuse_types (optional): + A Sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. - include_score_percentiles(optional) : Whether to add new parameter in the query parameter. - if include_score_percentiles is true then add a new parameter called fields in the query parameter + include_score_percentiles (optional): + Whether to add new parameter in the query parameter. + if include_score_percentiles is True then add a new parameter + called fields in the query parameter Returns: - A sift.client.Response object if the score call succeeded, or raises - an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout url = self._user_score_url(user_id, self.version) - headers = {'User-Agent': self._user_agent()} - params = {} + params: dict[str, t.Any] = {} + if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) if include_score_percentiles: - params['fields'] = 'SCORE_PERCENTILES' + params["fields"] = "SCORE_PERCENTILES" try: response = self.session.get( url, - headers=headers, - timeout=timeout, params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, '')) - return Response(response) + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def rescore_user(self, user_id, timeout=None, abuse_types=None): - """Rescores the specified user for the specified abuse types and returns the resulting score(s). - This call is blocking. See https://siftscience.com/developers/docs/python/score-api/rescore for more details. + return Response(response) + + def rescore_user( + self, + user_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + abuse_types: Sequence[AbuseType] | None = None, + ) -> Response: + """ + Rescores the specified user for the specified abuse types and returns + the resulting score(s). + + This call is blocking. + + Visit https://developers.sift.com/docs/python/score-api/rescore/overview + for more details. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + Use a custom timeout (in seconds) for this call. - abuse_types(optional): List of abuse types, specifying for which abuse types a score - should be returned (if scores were requested). If not specified, a score will - be returned for every abuse_type to which you are subscribed. + abuse_types (optional): + A Sequence of abuse types, specifying for which abuse types + a score should be returned (if scores were requested). If not + specified, a score will be returned for every abuse_type + to which you are subscribed. Returns: - A sift.client.Response object if the score call succeeded, or raises - an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout url = self._user_score_url(user_id, self.version) - headers = {'User-Agent': self._user_agent()} - params = {} + params: dict[str, t.Any] = {} + if abuse_types: - params['abuse_types'] = ','.join(abuse_types) + params["abuse_types"] = ",".join(abuse_types) try: response = self.session.post( url, - headers=headers, - timeout=timeout, params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, '')) - return Response(response) + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def label(self, user_id, properties, timeout=None, version=None): - """Labels a user as either good or bad through the Sift Science API. - This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html - for more information on what fields to send in properties. + return Response(response) + + def label( + self, + user_id: str, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + version: str | None = None, + ) -> Response: + """ + Labels a user as either good or bad through the Sift Science API. + + This call is blocking. + + Visit https://developers.sift.com/docs/python/labels-api + for more details on what fields to send in properties. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - properties: A dict of additional event-specific attributes to track. + properties: + A dict of additional event-specific attributes to track. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + Use a custom timeout (in seconds) for this call. - version(optional): Use a different version of the Sift Science API for this call. + version (optional): + Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the label call succeeded, otherwise - raises an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if version is None: version = self.version return self.track( - '$label', + "$label", properties, - path=self._label_url(user_id, version), + path=self._labels_url(user_id, version), timeout=timeout, - version=version) + version=version, + ) + + def unlabel( + self, + user_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + abuse_type: AbuseType | None = None, + version: str | None = None, + ) -> Response: + """ + Unlabels a user through the Sift Science API. - def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): - """unlabels a user through the Sift Science API. - This call is blocking. Check out https://siftscience.com/resources/references/labels_api.html - for more information. + This call is blocking. + + Visit https://developers.sift.com/docs/python/labels-api + for more details. Args: - user_id: A user's id. This id should be the same as the user_id used in + user_id: + A user's id. This id should be the same as the user_id used in event calls. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + Use a custom timeout (in seconds) for this call. - abuse_type(optional): The abuse type for which the user should be unlabeled. + abuse_type (optional): + The abuse type for which the user should be unlabeled. If omitted, the user is unlabeled for all abuse types. - version(optional): Use a different version of the Sift Science API for this call. + version (optional): + Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the unlabel call succeeded, otherwise - raises an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(user_id, 'user_id') + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -402,196 +822,276 @@ def unlabel(self, user_id, timeout=None, abuse_type=None, version=None): if version is None: version = self.version - url = self._label_url(user_id, version) - headers = {'User-Agent': self._user_agent()} - params = {} + url = self._labels_url(user_id, version) + params: dict[str, t.Any] = {} + if abuse_type: - params['abuse_type'] = abuse_type + params["abuse_type"] = abuse_type try: response = self.session.delete( url, - headers=headers, - timeout=timeout, params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, '')) - return Response(response) - + auth=self._auth(), + headers=self._headers(version), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_workflow_status(self, run_id, timeout=None): + return Response(response) + + def get_workflow_status( + self, + run_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Gets the status of a workflow run. Args: - run_id: The ID of a workflow run. + run_id: + The workflow run unique identifier. + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call succeeded + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(run_id, 'run_id') + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(run_id, "run_id") url = self._workflow_status_url(self.account_id, run_id) + if timeout is None: timeout = self.timeout try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_decisions(self, entity_type, limit=None, start_from=None, abuse_types=None, timeout=None): - """Get decisions available to customer + return Response(response) + + def get_decisions( + self, + entity_type: t.Literal["user", "order", "session", "content"], + limit: int | None = None, + start_from: int | None = None, + abuse_types: ( + str | None + ) = None, # TODO: Ask if here should be a Sequence[AbuseType] instead of str + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: + """Get decisions available to the customer Args: - entity_type: only return decisions applicable to entity type {USER|ORDER|SESSION|CONTENT} - limit: number of query results (decisions) to return [optional, default: 100] - start_from: result set offset for use in pagination [optional, default: 0] - abuse_types: comma-separated list of abuse_types used to filter returned decisions (optional) + entity_type: + Return decisions applicable to entity type + One of: "user", "order", "session", "content" + + limit (optional): + Number of query results (decisions) to return [default: 100] + + start_from (optional): + Result set offset for use in pagination [default: 0] + + abuse_types (optional): + comma-separated list of abuse_types used to filter returned + decisions + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object containing array of decisions if call succeeded - Otherwise raises an ApiException - """ + A sift.client.Response object if the call succeeded - if timeout is None: - timeout = self.timeout + Raises: + ApiException: + if the call not succeeded + """ - params = {} + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(entity_type, "entity_type") - _assert_non_empty_unicode(entity_type, 'entity_type') - if entity_type.lower() not in ['user', 'order', 'session', 'content']: - raise ValueError("entity_type must be one of {user, order, session, content}") + if entity_type.lower() not in ["user", "order", "session", "content"]: + raise ValueError( + "entity_type must be one of {user, order, session, content}" + ) - params['entity_type'] = entity_type + params: dict[str, t.Any] = { + "entity_type": entity_type, + } if limit: - params['limit'] = limit + params["limit"] = limit if start_from: - params['from'] = start_from + params["from"] = start_from if abuse_types: - params['abuse_types'] = abuse_types + params["abuse_types"] = abuse_types - url = self._get_decisions_url(self.account_id) + if timeout is None: + timeout = self.timeout - try: - return Response(self.session.get(url, params=params, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, timeout=timeout)) + url = self._decisions_url(self.account_id) + try: + response = self.session.get( + url, + params=params, + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_user_decision(self, user_id, properties, timeout=None): - """Apply decision to user + return Response(response) + + def apply_user_decision( + self, + user_id: str, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: + """Apply decision to a user Args: - user_id: id of user + user_id: id of a user + properties: - decision_id: decision to apply to user + decision_id: decision to apply to a user source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' time: in millis when decision was applied - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException + + timeout (optional): + Use a custom timeout (in seconds) for this call. + + Returns: + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ + _assert_non_empty_str(self.account_id, "account_id") + + self._validate_apply_decision_request(properties, user_id) if timeout is None: timeout = self.timeout - self._validate_apply_decision_request(properties, user_id) url = self._user_decisions_url(self.account_id, user_id) + try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_order_decision(self, user_id, order_id, properties, timeout=None): + return Response(response) + + def apply_order_decision( + self, + user_id: str, + order_id: str, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Apply decision to order Args: - user_id: id of user - order_id: id of order + user_id: + ID of a user. + + order_id: + The ID for the order. + properties: decision_id: decision to apply to order source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' description: free form text (optional) time: in millis when decision was applied (optional) - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException + + timeout (optional): + Use a custom timeout (in seconds) for this call. + + Returns: + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(order_id, "order_id") + + self._validate_apply_decision_request(properties, user_id) + if timeout is None: timeout = self.timeout - _assert_non_empty_unicode(user_id, 'user_id') - _assert_non_empty_unicode(order_id, 'order_id') - - self._validate_apply_decision_request(properties, user_id) + url = self._order_apply_decisions_url( + self.account_id, user_id, order_id + ) - url = self._order_apply_decisions_url(self.account_id, user_id, order_id) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def _validate_apply_decision_request(self, properties, user_id): - _assert_non_empty_unicode(user_id, 'user_id') - - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - source = properties.get('source') - - _assert_non_empty_unicode(source, 'source', error_cls=ValueError) - if source not in DECISION_SOURCES: - raise ValueError("decision 'source' must be one of [{0}]".format(", ".join(DECISION_SOURCES))) - - properties.update({'source': source.upper()}) - - if source == 'MANUAL_REVIEW' and not properties.get('analyst', None): - raise ValueError("must provide 'analyst' for decision 'source': 'MANUAL_REVIEW'") + return Response(response) - def get_user_decisions(self, user_id, timeout=None): + def get_user_decisions( + self, + user_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Gets the decisions for a user. Args: - user_id: The ID of a user. + user_id: + The ID of a user. + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call succeeded + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(user_id, 'user_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -599,27 +1099,41 @@ def get_user_decisions(self, user_id, timeout=None): url = self._user_decisions_url(self.account_id, user_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_order_decisions(self, order_id, timeout=None): + return Response(response) + + def get_order_decisions( + self, + order_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Gets the decisions for an order. Args: - order_id: The ID of an order. + order_id: + The ID for the order. + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call succeeded + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(order_id, 'order_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(order_id, "order_id") if timeout is None: timeout = self.timeout @@ -627,29 +1141,46 @@ def get_order_decisions(self, order_id, timeout=None): url = self._order_decisions_url(self.account_id, order_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_content_decisions(self, user_id, content_id, timeout=None): + return Response(response) + + def get_content_decisions( + self, + user_id: str, + content_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Gets the decisions for a piece of content. Args: - user_id: The ID of the owner of the content. - content_id: The ID of a piece of content. + user_id: + The ID of the owner of the content. + + content_id: + The ID for the content. + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call succeeded + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(content_id, 'content_id') - _assert_non_empty_unicode(user_id, 'user_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(content_id, "content_id") + _assert_non_empty_str(user_id, "user_id") if timeout is None: timeout = self.timeout @@ -657,29 +1188,46 @@ def get_content_decisions(self, user_id, content_id, timeout=None): url = self._content_decisions_url(self.account_id, user_id, content_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_session_decisions(self, user_id, session_id, timeout=None): + return Response(response) + + def get_session_decisions( + self, + user_id: str, + session_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Gets the decisions for a user's session. Args: - user_id: The ID of a user. - session_id: The ID of a session. + user_id: + The ID for the user. + + session_id: + The ID for the session. + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call succeeded + Raises: + ApiException: + if the call not succeeded """ - _assert_non_empty_unicode(user_id, 'user_id') - _assert_non_empty_unicode(session_id, 'session_id') + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(session_id, "session_id") if timeout is None: timeout = self.timeout @@ -687,552 +1235,528 @@ def get_session_decisions(self, user_id, session_id, timeout=None): url = self._session_decisions_url(self.account_id, user_id, session_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_session_decision(self, user_id, session_id, properties, timeout=None): - """Apply decision to session + return Response(response) + + def apply_session_decision( + self, + user_id: str, + session_id: str, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: + """Apply decision to a session. Args: - user_id: id of user - session_id: id of session + user_id: + The ID for the user. + + session_id: + The ID for the session. + properties: decision_id: decision to apply to session source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' description: free form text (optional) time: in millis when decision was applied (optional) - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException - """ - if timeout is None: - timeout = self.timeout + timeout (optional): + Use a custom timeout (in seconds) for this call. + + Returns: + A sift.client.Response object if the call succeeded - _assert_non_empty_unicode(session_id, 'session_id') + Raises: + ApiException: + if the call not succeeded + """ + + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(session_id, "session_id") self._validate_apply_decision_request(properties, user_id) - url = self._session_apply_decisions_url(self.account_id, user_id, session_id) + if timeout is None: + timeout = self.timeout + + url = self._session_decisions_url(self.account_id, user_id, session_id) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def apply_content_decision(self, user_id, content_id, properties, timeout=None): - """Apply decision to content + return Response(response) + + def apply_content_decision( + self, + user_id: str, + content_id: str, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: + """Apply decision to a piece of content. Args: - user_id: id of user - content_id: id of content + user_id: + The ID for the user. + + content_id: + The ID for the content. + properties: decision_id: decision to apply to session source: {one of MANUAL_REVIEW | AUTOMATED_RULE | CHARGEBACK} analyst: id or email, required if 'source: MANUAL_REVIEW' description: free form text (optional) time: in millis when decision was applied (optional) - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException - """ - if timeout is None: - timeout = self.timeout + timeout (optional): + Use a custom timeout (in seconds) for this call. - _assert_non_empty_unicode(content_id, 'content_id') + Returns: + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded + """ + _assert_non_empty_str(self.account_id, "account_id") + _assert_non_empty_str(user_id, "user_id") + _assert_non_empty_str(content_id, "content_id") self._validate_apply_decision_request(properties, user_id) - url = self._content_apply_decisions_url(self.account_id, user_id, content_id) + if timeout is None: + timeout = self.timeout + + url = self._content_decisions_url(self.account_id, user_id, content_id) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def create_psp_merchant_profile(self, properties, timeout=None): + return Response(response) + + def create_psp_merchant_profile( + self, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Create a new PSP Merchant profile + Args: - properties: A dict of merchant profile data. - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException + properties: + A dict of merchant profile data. + + timeout (optional): + Use a custom timeout (in seconds) for this call. + + Returns: + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ + _assert_non_empty_str(self.account_id, "account_id") + if timeout is None: timeout = self.timeout url = self._psp_merchant_url(self.account_id) try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def update_psp_merchant_profile(self, merchant_id, properties, timeout=None): + return Response(response) + + def update_psp_merchant_profile( + self, + merchant_id: str, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: """Update already existing PSP Merchant profile + Args: - merchant_id: id of merchant - properties: A dict of merchant profile data. - Returns - A sift.client.Response object if the call succeeded, else raises an ApiException + merchant_id: + The internal identifier for the merchant or seller providing + the good or service. + + properties: + A dict of merchant profile data. + + timeout (optional): + Use a custom timeout (in seconds) for this call. + + Returns: + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ + _assert_non_empty_str(self.account_id, "account_id") + if timeout is None: timeout = self.timeout url = self._psp_merchant_id_url(self.account_id, merchant_id) + try: - return Response(self.session.put( + response = self.session.put( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_psp_merchant_profiles(self, batch_token=None, batch_size=None, timeout=None): - """Gets all PSP merchant profiles. + return Response(response) + + def get_psp_merchant_profiles( + self, + batch_token: str | None = None, + batch_size: int | None = None, + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: + """Gets all PSP merchant profiles (paginated). + + Args: + batch_token (optional): + Batch or page position of the paginated sequence. + + batch_size: (optional): + Batch or page size of the paginated sequence. + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ + _assert_non_empty_str(self.account_id, "account_id") + if timeout is None: timeout = self.timeout url = self._psp_merchant_url(self.account_id) - params = {} + + params: dict[str, t.Any] = {} if batch_size: - params['batch_size'] = batch_size + params["batch_size"] = batch_size if batch_token: - params['batch_token'] = batch_token + params["batch_token"] = batch_token + try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, + auth=self._auth(), + headers=self._headers(), params=params, - timeout=timeout)) - + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def get_a_psp_merchant_profile(self, merchant_id, timeout=None): - """Gets a PSP merchant profile using merchant id. + return Response(response) + + def get_a_psp_merchant_profile( + self, + merchant_id: str, + timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> Response: + """Gets a PSP merchant profile by merchant id. + + Args: + merchant_id: + The internal identifier for the merchant or seller providing + the good or service. + + timeout (optional): + Use a custom timeout (in seconds) for this call. Returns: - A sift.client.Response object if the call succeeded. - Otherwise, raises an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ + _assert_non_empty_str(self.account_id, "account_id") + if timeout is None: timeout = self.timeout url = self._psp_merchant_id_url(self.account_id, merchant_id) try: - return Response(self.session.get( + response = self.session.get( url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'User-Agent': self._user_agent()}, - timeout=timeout)) + auth=self._auth(), + headers=self._headers(), + timeout=timeout, + ) except requests.exceptions.RequestException as e: raise ApiException(str(e), url) - def verification_send(self, properties, timeout=None, version=None): - """The send call triggers the generation of a OTP code that is stored by Sift and email/sms the code to the user. - This call is blocking. Check out https://sift.com/developers/docs/python/verification-api/send - for more information on our send response structure. + return Response(response) + + def verification_send( + self, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + version: str | None = None, + ) -> Response: + """ + The send call triggers the generation of an OTP code that is stored + by Sift and email/sms the code to the user. + + This call is blocking. + + Visit https://developers.sift.com/docs/python/verification-api/send + for more details on our send response structure. Args: + properties: - properties: - - $user_id: User ID of user being verified, e.g. johndoe123. - $send_to: The phone / email to send the OTP to. - $verification_type: The channel used for verification. Should be either $email or $sms. - $brand_name(optional): Name of the brand of product or service the user interacts with. - $language(optional): Language of the content of the web site. - $site_country(optional): Country of the content of the site. - $event: - $session_id: The session being verified. See $verification in the Sift Events API documentation. - $verified_event: The type of the reserved event being verified. - $reason(optional): The trigger for the verification. See $verification in the Sift Events API documentation. - $ip(optional): The user's IP address. + $user_id: + User ID of user being verified, e.g. johndoe123. + $send_to: + The phone / email to send the OTP to. + $verification_type: + The channel used for verification. Should be either $email + or $sms. + $brand_name (optional): + Name of the brand of product or service the user interacts + with. + $language (optional): + Language of the content of the web site. + $site_country (optional): + Country of the content of the site. + $event: + $session_id: + The session being verified. See $verification in the + Sift Events API documentation. + $verified_event: + The type of the reserved event being verified. + $reason (optional): + The trigger for the verification. See $verification + in the Sift Events API documentation. + $ip (optional): + The user's IP address. $browser: - $user_agent: The user agent of the browser that is verifying. Represented by the $browser object. - Use this field if the client is a browser. - + $user_agent: + The user agent of the browser that is verifying. + Represented by the $browser object. + Use this field if the client is a browser. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + Use a custom timeout (in seconds) for this call. - version(optional): Use a different version of the Sift Science API for this call. + version (optional): + Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the send call succeeded, or raises an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ if timeout is None: timeout = self.timeout + if version is None: + version = self.version + self._validate_send_request(properties) url = self._verification_send_url() try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(version), + timeout=timeout, + ) except requests.exceptions.RequestException as e: - raise ApiException(str(e), url) - - def _validate_send_request(self, properties): - """ This method is used to validate arguments passed to the send method. """ - - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - user_id = properties.get('$user_id') - _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) + raise ApiException(str(e), url) - send_to = properties.get('$send_to') - _assert_non_empty_unicode(send_to, 'send_to', error_cls=ValueError) + return Response(response) - verification_type = properties.get('$verification_type') - _assert_non_empty_unicode( - verification_type, 'verification_type', error_cls=ValueError) + def verification_resend( + self, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + version: str | None = None, + ) -> Response: + """ + A user can ask for a new OTP (one-time password) if they haven't + received the previous one, or in case the previous OTP expired. - event = properties.get('$event') - if not isinstance(event, dict): - raise TypeError("$event must be a dict") - elif not event: - raise ValueError("$event dictionary may not be empty") + This call is blocking. - session_id = event.get('$session_id') - _assert_non_empty_unicode( - session_id, 'session_id', error_cls=ValueError) - - def verification_resend(self, properties, timeout=None, version=None): - """A user can ask for a new OTP (one-time password) if they haven't received the previous one, - or in case the previous OTP expired. - This call is blocking. Check out https://sift.com/developers/docs/python/verification-api/resend + Visit https://developers.sift.com/docs/python/verification-api/resend for more information on our send response structure. Args: - properties: + properties: - $user_id: User ID of user being verified, e.g. johndoe123. - $verified_event(optional): This will be the event type that triggered the verification. - $verified_entity_id(optional): The ID of the entity impacted by the event being verified. + $user_id: + User ID of user being verified, e.g. johndoe123. + $verified_event (optional): + This will be the event type that triggered the verification. + $verified_entity_id (optional): + The ID of the entity impacted by the event being verified. - timeout(optional): Use a custom timeout (in seconds) for this call. + timeout (optional): + Use a custom timeout (in seconds) for this call. - version(optional): Use a different version of the Sift Science API for this call. + version (optional): + Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the send call succeeded, or raises an ApiException. + A sift.client.Response object if the call succeeded + + Raises: + ApiException: + if the call not succeeded """ if timeout is None: timeout = self.timeout + if version is None: + version = self.version + self._validate_resend_request(properties) url = self._verification_resend_url() try: - return Response(self.session.post( + response = self.session.post( url, data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - + auth=self._auth(), + headers=self._post_headers(version), + timeout=timeout, + ) except requests.exceptions.RequestException as e: - raise ApiException(str(e), url) - - def _validate_resend_request(self, properties): - """ This method is used to validate arguments passed to the send method. """ - - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - user_id = properties.get('$user_id') - _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) - - def verification_check(self, properties, timeout=None, version=None): - """The verification_check call is used for checking the OTP provided by the end user to Sift. - Sift then compares the OTP, checks rate limits and responds with a decision whether the user should be able to proceed or not. - This call is blocking. Check out https://sift.com/developers/docs/python/verification-api/check - for more information on our check response structure. - - Args: - - properties: - $user_id: User ID of user being verified, e.g. johndoe123. - $code: The code the user sent to the customer for validation.. - $verified_event(optional): This will be the event type that triggered the verification. - $verified_entity_id(optional): The ID of the entity impacted by the event being verified. - - timeout(optional): Use a custom timeout (in seconds) for this call. - version(optional): Use a different version of the Sift Science API for this call. - - Returns: - A sift.client.Response object if the check call succeeded, or raises - an ApiException. - """ - if timeout is None: - timeout = self.timeout - - self._validate_check_request(properties) - - url = self._verification_check_url() - - try: - return Response(self.session.post( - url, - data=json.dumps(properties), - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - headers={'Content-type': 'application/json', - 'Accept': '*/*', - 'User-Agent': self._user_agent()}, - timeout=timeout)) - - except requests.exceptions.RequestException as e: - raise ApiException(str(e), url) - - def _validate_check_request(self, properties): - """ This method is used to validate arguments passed to the check method. """ - - if not isinstance(properties, dict): - raise TypeError("properties must be a dict") - elif not properties: - raise ValueError("properties dictionary may not be empty") - - user_id = properties.get('$user_id') - _assert_non_empty_unicode(user_id, 'user_id', error_cls=ValueError) - - otp_code = properties.get('$code') - if otp_code is None: - raise ValueError("code is required") - - def _user_agent(self): - return 'SiftScience/v%s sift-python/%s' % (sift.version.API_VERSION, sift.version.VERSION) - - def _event_url(self, version): - return self.url + '/v%s/events' % version - - def _score_url(self, user_id, version): - return self.url + '/v%s/score/%s' % (version, _quote_path(user_id)) - - def _user_score_url(self, user_id, version): - return self.url + '/v%s/users/%s/score' % (version, urllib.parse.quote(user_id)) - - def _label_url(self, user_id, version): - return self.url + '/v%s/users/%s/labels' % (version, _quote_path(user_id)) - - def _workflow_status_url(self, account_id, run_id): - return (API3_URL + '/v3/accounts/%s/workflows/runs/%s' % - (_quote_path(account_id), _quote_path(run_id))) - - def _get_decisions_url(self, account_id): - return API3_URL + '/v3/accounts/%s/decisions' % (_quote_path(account_id),) - - def _user_decisions_url(self, account_id, user_id): - return (API3_URL + '/v3/accounts/%s/users/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id))) - - def _order_decisions_url(self, account_id, order_id): - return (API3_URL + '/v3/accounts/%s/orders/%s/decisions' % - (_quote_path(account_id), _quote_path(order_id))) - - def _session_decisions_url(self, account_id, user_id, session_id): - return (API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(session_id))) + raise ApiException(str(e), url) - def _content_decisions_url(self, account_id, user_id, content_id): - return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) + return Response(response) - def _order_apply_decisions_url(self, account_id, user_id, order_id): - return (API3_URL + '/v3/accounts/%s/users/%s/orders/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(order_id))) + def verification_check( + self, + properties: Mapping[str, t.Any], + timeout: int | float | tuple[int | float, int | float] | None = None, + version: str | None = None, + ) -> Response: + """ + The verification_check call is used for checking the OTP provided by + the end user to Sift. Sift then compares the OTP, checks rate limits + and responds with a decision whether the user should be able to + proceed or not. - def _session_apply_decisions_url(self, account_id, user_id, session_id): - return (API3_URL + '/v3/accounts/%s/users/%s/sessions/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(session_id))) + This call is blocking. - def _content_apply_decisions_url(self, account_id, user_id, content_id): - return (API3_URL + '/v3/accounts/%s/users/%s/content/%s/decisions' % - (_quote_path(account_id), _quote_path(user_id), _quote_path(content_id))) + Visit https://developers.sift.com/docs/python/verification-api/check + for more information on our check response structure. - def _psp_merchant_url(self, account_id): - return (self.url + '/v3/accounts/%s/psp_management/merchants' % - (_quote_path(account_id))) + Args: - def _psp_merchant_id_url(self, account_id, merchant_id): - return (self.url + '/v3/accounts/%s/psp_management/merchants/%s' % - (_quote_path(account_id), _quote_path(merchant_id))) + properties: - def _verification_send_url(self): - return (API_URL_VERIFICATION + 'send') - - def _verification_resend_url(self): - return (API_URL_VERIFICATION + 'resend') - - def _verification_check_url(self): - return (API_URL_VERIFICATION + 'check') + $user_id: + User ID of user being verified, e.g. johndoe123. + $code: + The code the user sent to the customer for validation. + $verified_event (optional): + This will be the event type that triggered the verification. + $verified_entity_id (optional): + The ID of the entity impacted by the event being verified. - @staticmethod - def _get_fields_param(include_score_percentiles, include_warnings): - return [ - field for include, field in [ - (include_score_percentiles, 'SCORE_PERCENTILES'), - (include_warnings, 'WARNINGS') - ] if include - ] + timeout (optional): + Use a custom timeout (in seconds) for this call. + version (optional): + Use a different version of the Sift Science API for this call. -class Response(object): - HTTP_CODES_WITHOUT_BODY = [204, 304] + Returns: + A sift.client.Response object if the call succeeded - def __init__(self, http_response): + Raises: + ApiException: + if the call not succeeded """ - Raises ApiException on invalid JSON in Response body or non-2XX HTTP - status code. - """ - # Set defaults. - self.body = None - self.request = None - self.api_status = None - self.api_error_message = None - self.http_status_code = http_response.status_code - self.url = http_response.url - - if (self.http_status_code not in self.HTTP_CODES_WITHOUT_BODY) and http_response.text: - try: - self.body = http_response.json() - if 'status' in self.body: - self.api_status = self.body['status'] - if 'error_message' in self.body: - self.api_error_message = self.body['error_message'] - if 'request' in list(self.body.keys()) and isinstance(self.body['request'], str): - self.request = json.loads(self.body['request']) - except ValueError: - raise ApiException( - 'Failed to parse json response from {0}'.format(self.url), - url=self.url, - http_status_code=self.http_status_code, - body=self.body, - api_status=self.api_status, - api_error_message=self.api_error_message, - request=self.request) - finally: - if int(self.http_status_code) < 200 or int(self.http_status_code) >= 300: - raise ApiException( - '{0} returned non-2XX http status code {1}'.format(self.url, self.http_status_code), - url=self.url, - http_status_code=self.http_status_code, - body=self.body, - api_status=self.api_status, - api_error_message=self.api_error_message, - request=self.request) - - def __str__(self): - return ('{%s "http_status_code": %s}' % - ('' if self.body is None else '"body": ' + - json.dumps(self.body) + ',', str(self.http_status_code))) - - def is_ok(self): - - if self.http_status_code in self.HTTP_CODES_WITHOUT_BODY: - return 204 == self.http_status_code - - # NOTE: Responses from /v3/... endpoints do not contain an API status. - if self.api_status: - return self.api_status == 0 - - return self.http_status_code == 200 - - -class ApiException(Exception): - def __init__(self, message, url, http_status_code=None, body=None, api_status=None, - api_error_message=None, request=None): - Exception.__init__(self, message) - - self.url = url - self.http_status_code = http_status_code - self.body = body - self.api_status = api_status - self.api_error_message = api_error_message - self.request = request + if timeout is None: + timeout = self.timeout + if version is None: + version = self.version -def _assert_non_empty_unicode(val, name, error_cls=None): - error = False - if not isinstance(val, _UNICODE_STRING): - error_cls = error_cls or TypeError - error = True - elif not val: - error_cls = error_cls or ValueError - error = True + self._validate_check_request(properties) - if error: - raise error_cls('{0} must be a non-empty string'.format(name)) + url = self._verification_check_url() + try: + response = self.session.post( + url, + data=json.dumps(properties), + auth=self._auth(), + headers=self._post_headers(version), + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise ApiException(str(e), url) -def _assert_non_empty_dict(val, name): - if not isinstance(val, dict): - raise TypeError('{0} must be a non-empty dict'.format(name)) - elif not val: - raise ValueError('{0} must be a non-empty dict'.format(name)) + return Response(response) diff --git a/sift/constants.py b/sift/constants.py new file mode 100644 index 0000000..bceecc8 --- /dev/null +++ b/sift/constants.py @@ -0,0 +1,7 @@ +API_URL = "https://api.sift.com" + +DECISION_SOURCES = ( + "MANUAL_REVIEW", + "AUTOMATED_RULE", + "CHARGEBACK", +) diff --git a/sift/exceptions.py b/sift/exceptions.py new file mode 100644 index 0000000..aad1432 --- /dev/null +++ b/sift/exceptions.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import typing as t + + +class ApiException(Exception): + def __init__( + self, + message: str, + url: str, + http_status_code: int | None = None, + body: dict[str, t.Any] | None = None, + api_status: int | None = None, + api_error_message: str | None = None, + request: dict[str, t.Any] | None = None, + ) -> None: + Exception.__init__(self, message) + + self.url = url + self.http_status_code = http_status_code + self.body = body + self.api_status = api_status + self.api_error_message = api_error_message + self.request = request diff --git a/sift/utils.py b/sift/utils.py new file mode 100644 index 0000000..40865e3 --- /dev/null +++ b/sift/utils.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import json +import typing as t +import urllib.parse +from decimal import Decimal + + +def quote_path(s: str) -> str: + # by default, urllib.quote doesn't escape forward slash; pass the + # optional arg to override this + return urllib.parse.quote(s, safe="") + + +class DecimalEncoder(json.JSONEncoder): + def default(self, o: object) -> tuple[str] | t.Any: + if isinstance(o, Decimal): + return (str(o),) + + return super().default(o) diff --git a/sift/version.py b/sift/version.py index d3560c1..e85c97b 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = '5.6.1' -API_VERSION = '205' +VERSION = "5.6.1" +API_VERSION = "205" diff --git a/test_integration_app/decisions_api/test_decisions_api.py b/test_integration_app/decisions_api/test_decisions_api.py index 55d2146..0a16364 100644 --- a/test_integration_app/decisions_api/test_decisions_api.py +++ b/test_integration_app/decisions_api/test_decisions_api.py @@ -1,67 +1,80 @@ -import sift +from os import environ as env + import globals -from os import environ as env +import sift + -class DecisionAPI(): +class DecisionAPI: # Get the value of API_KEY from environment variable - api_key = env['API_KEY'] - account_id = env['ACCOUNT_ID'] - client = sift.Client(api_key = api_key, account_id = account_id) + api_key = env["API_KEY"] + account_id = env["ACCOUNT_ID"] + client = sift.Client(api_key=api_key, account_id=account_id) globals.initialize() user_id = globals.user_id session_id = globals.session_id - def apply_user_decision(self): - applyDecisionRequest = { - "decision_id" : "integration_app_watch_account_abuse", - "source" : "MANUAL_REVIEW", - "analyst" : "analyst@example.com", - "description" : "User linked to three other payment abusers and ordering high value items" + def apply_user_decision(self) -> sift.client.Response: + properties = { + "decision_id": "integration_app_watch_account_abuse", + "source": "MANUAL_REVIEW", + "analyst": "analyst@example.com", + "description": "User linked to three other payment abusers and ordering high value items", } - - return self.client.apply_user_decision(self.user_id, applyDecisionRequest) - - def apply_order_decision(self): - applyOrderDecisionRequest = { - "decision_id" : "block_order_payment_abuse", - "source" : "AUTOMATED_RULE", - "description" : "Auto block pending order as score exceeded risk threshold of 90" + + return self.client.apply_user_decision(self.user_id, properties) + + def apply_order_decision(self) -> sift.client.Response: + properties = { + "decision_id": "block_order_payment_abuse", + "source": "AUTOMATED_RULE", + "description": "Auto block pending order as score exceeded risk threshold of 90", } - - return self.client.apply_order_decision(self.user_id, "ORDER-1234567", applyOrderDecisionRequest) - - def apply_session_decision(self): - applySessionDecisionRequest = { - "decision_id" : "integration_app_watch_account_takeover", - "source" : "MANUAL_REVIEW", - "analyst" : "analyst@example.com", - "description" : "compromised account reported to customer service" + + return self.client.apply_order_decision( + self.user_id, "ORDER-1234567", properties + ) + + def apply_session_decision(self) -> sift.client.Response: + properties = { + "decision_id": "integration_app_watch_account_takeover", + "source": "MANUAL_REVIEW", + "analyst": "analyst@example.com", + "description": "compromised account reported to customer service", } - - return self.client.apply_session_decision(self.user_id, self.session_id, applySessionDecisionRequest) - - def apply_content_decision(self): - applyContentDecisionRequest = { - "decision_id" : "integration_app_watch_content_abuse", - "source" : "MANUAL_REVIEW", - "analyst" : "analyst@example.com", - "description" : "fraudulent listing" + + return self.client.apply_session_decision( + self.user_id, self.session_id, properties + ) + + def apply_content_decision(self) -> sift.client.Response: + properties = { + "decision_id": "integration_app_watch_content_abuse", + "source": "MANUAL_REVIEW", + "analyst": "analyst@example.com", + "description": "fraudulent listing", } - - return self.client.apply_content_decision(self.user_id, "content_id", applyContentDecisionRequest) - def get_user_decisions(self): + return self.client.apply_content_decision( + self.user_id, "content_id", properties + ) + + def get_user_decisions(self) -> sift.client.Response: return self.client.get_user_decisions(self.user_id) - def get_order_decisions(self): + def get_order_decisions(self) -> sift.client.Response: return self.client.get_order_decisions("ORDER-1234567") - def get_content_decisions(self): + def get_content_decisions(self) -> sift.client.Response: return self.client.get_content_decisions(self.user_id, "CONTENT_ID") - def get_session_decisions(self): + def get_session_decisions(self) -> sift.client.Response: return self.client.get_session_decisions(self.user_id, "SESSION_ID") - - def get_decisions(self): - return self.client.get_decisions(entity_type='user', limit=10, start_from=5, abuse_types='legacy,payment_abuse') + + def get_decisions(self) -> sift.client.Response: + return self.client.get_decisions( + entity_type="user", + limit=10, + start_from=5, + abuse_types="legacy,payment_abuse", + ) diff --git a/test_integration_app/events_api/test_events_api.py b/test_integration_app/events_api/test_events_api.py index 04607e0..3a065f5 100644 --- a/test_integration_app/events_api/test_events_api.py +++ b/test_integration_app/events_api/test_events_api.py @@ -1,1316 +1,1288 @@ -import sift -import globals - -from os import environ as env +from __future__ import annotations -class EventsAPI(): - # Get the value of API_KEY from environment variable - api_key = env['API_KEY'] - client = sift.Client(api_key = api_key) - globals.initialize() - user_id = globals.user_id - user_email = globals.user_email - - def add_item_to_cart(self): - add_item_to_cart_properties = { - # Required Fields - "$user_id" : self.user_id, - # Supported Fields - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$item" : { - "$item_id" : "B004834GQO", - "$product_title" : "The Slanket Blanket-Texas Tea", - "$price" : 39990000, # $39.99 - "$currency_code" : "USD", - "$upc" : "6786211451001", - "$sku" : "004834GQ", - "$brand" : "Slanket", - "$manufacturer" : "Slanket", - "$category" : "Blankets & Throws", - "$tags" : ["Awesome", "Wintertime specials"], - "$color" : "Texas Tea", - "$quantity" : 16 - }, - "$brand_name" : "sift", - "$site_domain" : "sift.com", - "$site_country" : "US", - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$add_item_to_cart", add_item_to_cart_properties) - +import os +import typing as t - def add_promotion(self): - add_promotion_properties = { - # Required fields. - "$user_id" : self.user_id, - # Supported fields. - "$promotions" : [ - # Example of a promotion for monetary discounts off good or services - { - "$promotion_id" : "NewRideDiscountMay2016", - "$status" : "$success", - "$description" : "$5 off your first 5 rides", - "$referrer_user_id" : "elon-m93903", - "$discount" : { - "$amount" : 5000000, # $5 - "$currency_code" : "USD" - } - } - ], - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$add_promotion", add_promotion_properties) - - def chargeback(self): - # Sample $chargeback event - chargeback_properties = { - # Required Fields - "$order_id" : "ORDER-123124124", - "$transaction_id" : "719637215", - # Recommended Fields - "$user_id" : self.user_id, - "$chargeback_state" : "$lost", - "$chargeback_reason" : "$duplicate" - } - return self.client.track("$chargeback", chargeback_properties) +import globals - def content_status(self): - # Sample $content_status event - content_status_properties = { - # Required Fields - "$user_id" : self.user_id, - "$content_id" : "9671500641", - "$status" : "$paused", +import sift - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$content_status", content_status_properties) - def create_account(self): - # Sample $create_account event - create_account_properties = { - # Required Fields - "$user_id" : self.user_id, - # Supported Fields - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$user_email" : self.user_email, - "$verification_phone_number" : "+123456789012", - "$name" : "Bill Jones", - "$phone" : "1-415-555-6040", - "$referrer_user_id" : "janejane101", - "$payment_methods" : [ - { - "$payment_type" : "$credit_card", - "$card_bin" : "542486", - "$card_last4" : "4444" +class EventsAPI: + # Get the value of API_KEY from environment variable + api_key = os.environ["API_KEY"] + client = sift.Client(api_key=api_key) + globals.initialize() + user_id = globals.user_id + user_email = globals.user_email + + def add_item_to_cart(self) -> sift.client.Response: + add_item_to_cart_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$item": { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$currency_code": "USD", + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + "$quantity": 16, + }, + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - "$billing_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6040", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$shipping_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$promotions" : [ - { - "$promotion_id" : "FriendReferral", - "$status" : "$success", - "$referrer_user_id" : "janejane102", - "$credit_point" : { - "$amount" : 100, - "$credit_point_type" : "account karma" - } + return self.client.track( + "$add_item_to_cart", add_item_to_cart_properties + ) + + def add_promotion(self) -> sift.client.Response: + add_promotion_properties = { + # Required fields. + "$user_id": self.user_id, + # Supported fields. + "$promotions": [ + # Example of a promotion for monetary discounts off good or services + { + "$promotion_id": "NewRideDiscountMay2016", + "$status": "$success", + "$description": "$5 off your first 5 rides", + "$referrer_user_id": "elon-m93903", + "$discount": { + "$amount": 5000000, + "$currency_code": "USD", + }, # $5 + } + ], + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - "$social_sign_on_type" : "$twitter", - "$account_types" : ["merchant", "premium"], - - # Suggested Custom Fields - "twitter_handle" : "billyjones", - "work_phone" : "1-347-555-5921", - "location" : "New London, NH", - "referral_code" : "MIKEFRIENDS", - "email_confirmed_status" : "$pending", - "phone_confirmed_status" : "$pending", - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$create_account", create_account_properties) - - def create_content_comment(self): - # Sample $create_content event for comments - comment_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "comment-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $comment object - "$comment" : { - "$body" : "Congrats on the new role!", - "$contact_email" : "alex_301@domain.com", - "$parent_comment_id" : "comment-23407", - "$root_content_id" : "listing-12923213", - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "An old picture" - } - ] - }, - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$create_content", comment_properties) - - def create_content_listing(self): - # Sample $create_content event for listings - listing_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "listing-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $listing object - "$listing" : { - "$subject" : "2 Bedroom Apartment for Rent", - "$body" : "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", - "$contact_email" : "alex_301@domain.com", - "$contact_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$locations" : [ - { - "$city" : "Seattle", - "$region" : "Washington", - "$country" : "US", - "$zipcode" : "98112" - } - ], - "$listed_items" : [ - { - "$price" : 2950000000, # $2950.00 - "$currency_code" : "USD", - "$tags" : ["heat", "washer/dryer"] - } - ], - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "Billy's picture" - } - ], - "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds - }, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$create_content", listing_properties) - - def create_content_message(self): - # Sample $create_content event for messages - message_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "message-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $message object - "$message" : { - "$body" : "Let’s meet at 5pm", - "$contact_email" : "alex_301@domain.com", - "$recipient_user_ids" : ["fy9h989sjphh71"], - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "My hike today!" - } - ] - }, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" + return self.client.track("$add_promotion", add_promotion_properties) + + def chargeback(self) -> sift.client.Response: + # Sample $chargeback event + chargeback_properties = { + # Required Fields + "$order_id": "ORDER-123124124", + "$transaction_id": "719637215", + # Recommended Fields + "$user_id": self.user_id, + "$chargeback_state": "$lost", + "$chargeback_reason": "$duplicate", } - } - return self.client.track("$create_content", message_properties) - - def create_content_post(self): - # Sample $create_content event for posts - post_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "post-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $post object - "$post" : { - "$subject" : "My new apartment!", - "$body" : "Moved into my new apartment yesterday.", - "$contact_email" : "alex_301@domain.com", - "$contact_address" : { - "$name" : "Bill Jones", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$locations" : [ - { - "$city" : "Seattle", - "$region" : "Washington", - "$country" : "US", - "$zipcode" : "98112" - } - ], - "$categories" : ["Personal"], - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "View from the window!" - } - ], - "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds - }, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$create_content", post_properties) - - def create_content_profile(self): - # Sample $create_content event for reviews - profile_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "profile-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $profile object - "$profile" : { - "$body" : "Hi! My name is Alex and I just moved to New London!", - "$contact_email" : "alex_301@domain.com", - "$contact_address" : { - "$name" : "Alex Smith", - "$phone" : "1-415-555-6041", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "Alex's picture" - } - ], - "$categories" : [ - "Friends", - "Long-term dating" - ] - }, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$create_content", profile_properties) - - def create_content_review(self): - # Sample $create_content event for reviews - review_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "review-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $review object - "$review" : { - "$subject" : "Amazing Tacos!", - "$body" : "I ate the tacos.", - "$contact_email" : "alex_301@domain.com", - "$locations" : [ - { - "$city" : "Seattle", - "$region" : "Washington", - "$country" : "US", - "$zipcode" : "98112" - } - ], - "$reviewed_content_id" : "listing-234234", - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "Calamari tacos." - } - ], - "$rating" : 4.5 - }, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$create_content", review_properties) - - def create_order(self): - # Sample $create_order event - order_properties = self.build_create_order_event() - return self.client.track("$create_order", order_properties) - - def create_order_with_warnings(self): - # Sample $create_order event - order_properties = self.build_create_order_event() - return self.client.track("$create_order", order_properties, include_warnings=True) - - def build_create_order_event(self): - order_properties = { - # Required Fields - "$user_id": self.user_id, - # Supported Fields - "$session_id": "gigtleqddo84l8cm15qe4il", - "$order_id": "ORDER-28168441", - "$user_email": self.user_email, - "$verification_phone_number": "+123456789012", - "$amount": 115940000, # $115.94 - "$currency_code": "USD", - "$billing_address": { - "$name": "Bill Jones", - "$phone": "1-415-555-6041", - "$address_1": "2100 Main Street", - "$address_2": "Apt 3B", - "$city": "New London", - "$region": "New Hampshire", - "$country": "US", - "$zipcode": "03257" - }, - "$payment_methods": [ - { - "$payment_type": "$credit_card", - "$payment_gateway": "$braintree", - "$card_bin": "542486", - "$card_last4": "4444" + return self.client.track("$chargeback", chargeback_properties) + + def content_status(self) -> sift.client.Response: + # Sample $content_status event + content_status_properties = { + # Required Fields + "$user_id": self.user_id, + "$content_id": "9671500641", + "$status": "$paused", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - "$ordered_from": { - "$store_id": "123", - "$store_address": { - "$name": "Bill Jones", - "$phone": "1-415-555-6040", - "$address_1": "2100 Main Street", - "$address_2": "Apt 3B", - "$city": "New London", - "$region": "New Hampshire", - "$country": "US", - "$zipcode": "03257" + return self.client.track("$content_status", content_status_properties) + + def create_account(self) -> sift.client.Response: + # Sample $create_account event + create_account_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$referrer_user_id": "janejane101", + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$promotions": [ + { + "$promotion_id": "FriendReferral", + "$status": "$success", + "$referrer_user_id": "janejane102", + "$credit_point": { + "$amount": 100, + "$credit_point_type": "account karma", + }, + } + ], + "$social_sign_on_type": "$twitter", + "$account_types": ["merchant", "premium"], + # Suggested Custom Fields + "twitter_handle": "billyjones", + "work_phone": "1-347-555-5921", + "location": "New London, NH", + "referral_code": "MIKEFRIENDS", + "email_confirmed_status": "$pending", + "phone_confirmed_status": "$pending", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - }, - "$brand_name": "sift", - "$site_domain": "sift.com", - "$site_country": "US", - "$shipping_address": { - "$name": "Bill Jones", - "$phone": "1-415-555-6041", - "$address_1": "2100 Main Street", - "$address_2": "Apt 3B", - "$city": "New London", - "$region": "New Hampshire", - "$country": "US", - "$zipcode": "03257" - }, - "$expedited_shipping": True, - "$shipping_method": "$physical", - "$shipping_carrier": "UPS", - "$shipping_tracking_numbers": ["1Z204E380338943508", "1Z204E380338943509"], - "$items": [ - { - "$item_id": "12344321", - "$product_title": "Microwavable Kettle Corn: Original Flavor", - "$price": 4990000, # $4.99 - "$upc": "097564307560", - "$sku": "03586005", - "$brand": "Peters Kettle Corn", - "$manufacturer": "Peters Kettle Corn", - "$category": "Food and Grocery", - "$tags": ["Popcorn", "Snacks", "On Sale"], - "$quantity": 4 - }, - { - "$item_id": "B004834GQO", - "$product_title": "The Slanket Blanket-Texas Tea", - "$price": 39990000, # $39.99 - "$upc": "6786211451001", - "$sku": "004834GQ", - "$brand": "Slanket", - "$manufacturer": "Slanket", - "$category": "Blankets & Throws", - "$tags": ["Awesome", "Wintertime specials"], - "$color": "Texas Tea", - "$quantity": 2 + return self.client.track("$create_account", create_account_properties) + + def create_content_comment(self) -> sift.client.Response: + # Sample $create_content event for comments + comment_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "comment-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $comment object + "$comment": { + "$body": "Congrats on the new role!", + "$contact_email": "alex_301@domain.com", + "$parent_comment_id": "comment-23407", + "$root_content_id": "listing-12923213", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "An old picture", + } + ], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - # For marketplaces, use $seller_user_id to identify the seller - "$seller_user_id": "slinkys_emporium", - - "$promotions": [ - { - "$promotion_id": "FirstTimeBuyer", - "$status": "$success", - "$description": "$5 off", - "$discount": { - "$amount": 5000000, # $5.00 + return self.client.track("$create_content", comment_properties) + + def create_content_listing(self) -> sift.client.Response: + # Sample $create_content event for listings + listing_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "listing-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $listing object + "$listing": { + "$subject": "2 Bedroom Apartment for Rent", + "$body": "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$listed_items": [ + { + "$price": 2950000000, # $2950.00 + "$currency_code": "USD", + "$tags": ["heat", "washer/dryer"], + } + ], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Billy's picture", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", listing_properties) + + def create_content_message(self) -> sift.client.Response: + # Sample $create_content event for messages + message_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "message-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $message object + "$message": { + "$body": "Let’s meet at 5pm", + "$contact_email": "alex_301@domain.com", + "$recipient_user_ids": ["fy9h989sjphh71"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "My hike today!", + } + ], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", message_properties) + + def create_content_post(self) -> sift.client.Response: + # Sample $create_content event for posts + post_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "post-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $post object + "$post": { + "$subject": "My new apartment!", + "$body": "Moved into my new apartment yesterday.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$categories": ["Personal"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "View from the window!", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", post_properties) + + def create_content_profile(self) -> sift.client.Response: + # Sample $create_content event for reviews + profile_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "profile-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $profile object + "$profile": { + "$body": "Hi! My name is Alex and I just moved to New London!", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Alex Smith", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Alex's picture", + } + ], + "$categories": ["Friends", "Long-term dating"], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", profile_properties) + + def create_content_review(self) -> sift.client.Response: + # Sample $create_content event for reviews + review_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "review-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $review object + "$review": { + "$subject": "Amazing Tacos!", + "$body": "I ate the tacos.", + "$contact_email": "alex_301@domain.com", + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$reviewed_content_id": "listing-234234", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Calamari tacos.", + } + ], + "$rating": 4.5, + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$create_content", review_properties) + + def create_order(self) -> sift.client.Response: + # Sample $create_order event + order_properties = self.build_create_order_event() + return self.client.track("$create_order", order_properties) + + def create_order_with_warnings(self) -> sift.client.Response: + # Sample $create_order event + order_properties = self.build_create_order_event() + return self.client.track( + "$create_order", order_properties, include_warnings=True + ) + + def build_create_order_event(self) -> dict[str, t.Any]: + order_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$order_id": "ORDER-28168441", + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$amount": 115940000, # $115.94 "$currency_code": "USD", - "$minimum_purchase_amount": 25000000 # $25.00 - } + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$ordered_from": { + "$store_id": "123", + "$store_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + }, + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$expedited_shipping": True, + "$shipping_method": "$physical", + "$shipping_carrier": "UPS", + "$shipping_tracking_numbers": [ + "1Z204E380338943508", + "1Z204E380338943509", + ], + "$items": [ + { + "$item_id": "12344321", + "$product_title": "Microwavable Kettle Corn: Original Flavor", + "$price": 4990000, # $4.99 + "$upc": "097564307560", + "$sku": "03586005", + "$brand": "Peters Kettle Corn", + "$manufacturer": "Peters Kettle Corn", + "$category": "Food and Grocery", + "$tags": ["Popcorn", "Snacks", "On Sale"], + "$quantity": 4, + }, + { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + "$quantity": 2, + }, + ], + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id": "slinkys_emporium", + "$promotions": [ + { + "$promotion_id": "FirstTimeBuyer", + "$status": "$success", + "$description": "$5 off", + "$discount": { + "$amount": 5000000, # $5.00 + "$currency_code": "USD", + "$minimum_purchase_amount": 25000000, # $25.00 + }, + } + ], + # Sample Custom Fields + "digital_wallet": "apple_pay", # "google_wallet", etc. + "coupon_code": "dollarMadness", + "shipping_choice": "FedEx Ground Courier", + "is_first_time_buyer": False, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - - # Sample Custom Fields - "digital_wallet": "apple_pay", # "google_wallet", etc. - "coupon_code": "dollarMadness", - "shipping_choice": "FedEx Ground Courier", - "is_first_time_buyer": False, - - # Send this information from a BROWSER client. - "$browser": { - "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language": "en-US", - "$content_language": "en-GB" - } - } - return order_properties - - def flag_content(self): - # Sample $flag_content event - flag_content_properties = { - # Required Fields - "$user_id" : self.user_id, # content creator - "$content_id" : "9671500641", - - # Supported Fields - "$flagged_by" : "jamieli89" - } - return self.client.track("$flag_content", flag_content_properties) - - def link_session_to_user(self): - # Sample $link_session_to_user event - link_session_to_user_properties = { - # Required Fields - "$user_id" : self.user_id, - "$session_id" : "gigtleqddo84l8cm15qe4il" - } - return self.client.track("$link_session_to_user", link_session_to_user_properties) - - def login(self): - # Sample $login event - login_properties = { - # Required Fields - "$user_id" : self.user_id, - "$login_status" : "$failure", - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$ip" : "128.148.1.135", - - # Optional Fields - "$user_email" : self.user_email, - "$verification_phone_number" : "+123456789012", - "$failure_reason" : "$wrong_password", - "$username" : "billjones1@example.com", - "$account_types" : ["merchant", "premium"], - "$social_sign_on_type" : "$linkedin", - "$brand_name" : "sift", - "$site_domain" : "sift.com", - "$site_country" : "US", - - # Send this information with a login from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$login", login_properties) - - def logout(self): - # Sample $logout event - logout_properties = { - # Required Fields - "$user_id" : self.user_id, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$logout", logout_properties) - - def order_status(self): - # Sample $order_status event - order_properties = { - # Required Fields - "$user_id" : self.user_id, - "$order_id" : "ORDER-28168441", - "$order_status" : "$canceled", - - # Optional Fields - "$reason" : "$payment_risk", - "$source" : "$manual_review", - "$analyst" : "someone@your-site.com", - "$webhook_id" : "3ff1082a4aea8d0c58e3643ddb7a5bb87ffffeb2492dca33", - "$description" : "Canceling because multiple fraudulent users on device", - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$order_status", order_properties) - - def remove_item_from_cart(self): - # Sample $remove_item_from_cart event - remove_item_from_cart_properties = { - # Required Fields - "$user_id" : self.user_id, - - # Supported Fields - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$item" : { - "$item_id" : "B004834GQO", - "$product_title" : "The Slanket Blanket-Texas Tea", - "$price" : 39990000, # $39.99 - "$currency_code" : "USD", - "$quantity" : 2, - "$upc" : "6786211451001", - "$sku" : "004834GQ", - "$brand" : "Slanket", - "$manufacturer" : "Slanket", - "$category" : "Blankets & Throws", - "$tags" : ["Awesome", "Wintertime specials"], - "$color" : "Texas Tea" - }, - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - - return self.client.track("$remove_item_from_cart", remove_item_from_cart_properties) - - def security_notification(self): - # Sample $security_notification event - security_notification_properties = { - # Required Fields - "$user_id" : self.user_id, - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$notification_status" : "$sent", - # Optional fields if applicable - "$notification_type" : "$email", - "$notified_value" : "billy123@domain.com", - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - - return self.client.track("$security_notification", security_notification_properties) - - def transaction(self): - # Sample $transaction event - transaction_properties = { - # Required Fields - "$user_id" : self.user_id, - "$amount" : 506790000, # $506.79 - "$currency_code" : "USD", - # Supported Fields - "$user_email" : self.user_email, - "$verification_phone_number" : "+123456789012", - "$transaction_type" : "$sale", - "$transaction_status" : "$failure", - "$decline_category" : "$bank_decline", - "$order_id" : "ORDER-123124124", - "$transaction_id" : "719637215", - "$billing_address" : { # or "$sent_address" # or "$received_address" - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$brand_name" : "sift", - "$site_domain" : "sift.com", - "$site_country" : "US", - "$ordered_from" : { - "$store_id" : "123", - "$store_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6040", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" + return order_properties + + def flag_content(self) -> sift.client.Response: + # Sample $flag_content event + flag_content_properties = { + # Required Fields + "$user_id": self.user_id, # content creator + "$content_id": "9671500641", + # Supported Fields + "$flagged_by": "jamieli89", } - }, - # Credit card example - "$payment_method" : { - "$payment_type" : "$credit_card", - "$payment_gateway" : "$braintree", - "$card_bin" : "542486", - "$card_last4" : "4444" - }, - - # Supported fields for 3DS - "$status_3ds" : "$attempted", - "$triggered_3ds" : "$processor", - "$merchant_initiated_transaction" : False, - - # Supported Fields - "$shipping_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$session_id" : "gigtleqddo84l8cm15qe4il", - - # For marketplaces, use $seller_user_id to identify the seller - "$seller_user_id" : "slinkys_emporium", - - # Sample Custom Fields - "digital_wallet" : "apple_pay", # "google_wallet", etc. - "coupon_code" : "dollarMadness", - "shipping_choice" : "FedEx Ground Courier", - "is_first_time_buyer" : False, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - return self.client.track("$transaction", transaction_properties) - - def update_account(self): - # Sample $update_account event - update_account_properties = { - # Required Fields - "$user_id" : self.user_id, - - # Supported Fields - "$changed_password" : True, - "$user_email" : self.user_email, - "$verification_phone_number" : "+123456789012", - "$name" : "Bill Jones", - "$phone" : "1-415-555-6040", - "$referrer_user_id" : "janejane102", - "$payment_methods" : [ - { - "$payment_type" : "$credit_card", - "$card_bin" : "542486", - "$card_last4" : "4444" + return self.client.track("$flag_content", flag_content_properties) + + def link_session_to_user(self) -> sift.client.Response: + # Sample $link_session_to_user event + link_session_to_user_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", } - ], - "$billing_address" : - { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$shipping_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - - "$social_sign_on_type" : "$twitter", - "$account_types" : ["merchant", "premium"], - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - - return self.client.track("$update_account", update_account_properties) - - def update_content_comment(self): - # Sample $update_content event for comments - update_content_comment_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "comment-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $comment object - "$comment" : { - "$body" : "Congrats on the new role!", - "$contact_email" : "alex_301@domain.com", - "$parent_comment_id" : "comment-23407", - "$root_content_id" : "listing-12923213", - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "An old picture" - } - ] - }, - # Send this information from an APP client. - "$app" : { - # Example for the iOS Calculator app. - "$os" : "iOS", - "$os_version" : "10.1.3", - "$device_manufacturer" : "Apple", - "$device_model" : "iPhone 4,2", - "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", - "$app_name" : "Calculator", - "$app_version" : "3.2.7", - "$client_language" : "en-US" - } - } - return self.client.track("$update_content", update_content_comment_properties) - - def update_content_listing(self): - # Sample $update_content event for listings - update_content_listing_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "listing-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $listing object - "$listing" : { - "$subject" : "2 Bedroom Apartment for Rent", - "$body" : "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", - "$contact_email" : "alex_301@domain.com", - "$contact_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$locations" : [ - { - "$city" : "Seattle", - "$region" : "Washington", - "$country" : "US", - "$zipcode" : "98112" - } - ], - "$listed_items" : [ - { - "$price" : 2950000000, # $2950.00 - "$currency_code" : "USD", - "$tags" : ["heat", "washer/dryer"] - } - ], - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "Billy's picture" - } - ], - "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds - }, - # Send this information from an APP client. - "$app" : { - # Example for the iOS Calculator app. - "$os" : "iOS", - "$os_version" : "10.1.3", - "$device_manufacturer" : "Apple", - "$device_model" : "iPhone 4,2", - "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", - "$app_name" : "Calculator", - "$app_version" : "3.2.7", - "$client_language" : "en-US" - } - } - return self.client.track("$update_content", update_content_listing_properties) - - def update_content_message(self): - # Sample $update_content event for messages - update_content_message_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "message-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $message object - "$message" : { - "$body" : "Lets meet at 5pm", - "$contact_email" : "alex_301@domain.com", - "$recipient_user_ids" : ["fy9h989sjphh71"], - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "My hike today!" - } - ] - }, - - # Send this information from a BROWSER client. - "$browser" : { - "$user_agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", - "$accept_language" : "en-US", - "$content_language" : "en-GB" - } - } - - return self.client.track("$update_content", update_content_message_properties) - - def update_content_post(self): - # Sample $update_content event for posts - update_content_post_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "post-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $post object - "$post" : { - "$subject" : "My new apartment!", - "$body" : "Moved into my new apartment yesterday.", - "$contact_email" : "alex_301@domain.com", - "$contact_address" : { - "$name" : "Bill Jones", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$locations" : [ - { - "$city" : "Seattle", - "$region" : "Washington", - "$country" : "US", - "$zipcode" : "98112" - } - ], - "$categories" : ["Personal"], - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "View from the window!" - } - ], - "$expiration_time" : 1549063157000 # UNIX timestamp in milliseconds - }, - # Send this information from an APP client. - "$app" : { - # Example for the iOS Calculator app. - "$os" : "iOS", - "$os_version" : "10.1.3", - "$device_manufacturer" : "Apple", - "$device_model" : "iPhone 4,2", - "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", - "$app_name" : "Calculator", - "$app_version" : "3.2.7", - "$client_language" : "en-US" - } - } - return self.client.track("$update_content", update_content_post_properties) - - def update_content_profile(self): - # Sample $update_content event for reviews - update_content_profile_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "profile-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $profile object - "$profile" : { - "$body" : "Hi! My name is Alex and I just moved to New London!", - "$contact_email" : "alex_301@domain.com", - "$contact_address" : { - "$name" : "Alex Smith", - "$phone" : "1-415-555-6041", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "Alex's picture" - } - ], - "$categories" : [ - "Friends", - "Long-term dating" - ] - }, - # ========================================= - # Send this information from an APP client. - "$app" : { - # Example for the iOS Calculator app. - "$os" : "iOS", - "$os_version" : "10.1.3", - "$device_manufacturer" : "Apple", - "$device_model" : "iPhone 4,2", - "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", - "$app_name" : "Calculator", - "$app_version" : "3.2.7", - "$client_language" : "en-US" - } - } - return self.client.track("$update_content", update_content_profile_properties) - - def update_content_review(self): - # Sample $update_content event for reviews - update_content_review_properties = { - # Required fields - "$user_id" : self.user_id, - "$content_id" : "review-23412", - - # Recommended fields - "$session_id" : "a234ksjfgn435sfg", - "$status" : "$active", - "$ip" : "255.255.255.0", - - # Required $review object - "$review" : { - "$subject" : "Amazing Tacos!", - "$body" : "I ate the tacos.", - "$contact_email" : "alex_301@domain.com", - "$locations" : [ - { - "$city" : "Seattle", - "$region" : "Washington", - "$country" : "US", - "$zipcode" : "98112" - } - ], - "$reviewed_content_id" : "listing-234234", - "$images" : [ - { - "$md5_hash" : "0cc175b9c0f1b6a831c399e269772661", - "$link" : "https://www.domain.com/file.png", - "$description" : "Calamari tacos." - } - ], - "$rating" : 4.5 - }, - # Send this information from an APP client. - "$app" : { - # Example for the iOS Calculator app. - "$os" : "iOS", - "$os_version" : "10.1.3", - "$device_manufacturer" : "Apple", - "$device_model" : "iPhone 4,2", - "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", - "$app_name" : "Calculator", - "$app_version" : "3.2.7", - "$client_language" : "en-US" - } - } - return self.client.track("$update_content", update_content_review_properties) - - def update_order(self): - # Sample $update_order event - update_order_properties = { - # Required Fields - "$user_id" : self.user_id, - # Supported Fields - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$order_id" : "ORDER-28168441", - "$user_email" : self.user_email, - "$verification_phone_number" : "+123456789012", - "$amount" : 115940000, # $115.94 - "$currency_code" : "USD", - "$billing_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$payment_methods" : [ - { - "$payment_type" : "$credit_card", - "$payment_gateway" : "$braintree", - "$card_bin" : "542486", - "$card_last4" : "4444" + return self.client.track( + "$link_session_to_user", link_session_to_user_properties + ) + + def login(self) -> sift.client.Response: + # Sample $login event + login_properties = { + # Required Fields + "$user_id": self.user_id, + "$login_status": "$failure", + "$session_id": "gigtleqddo84l8cm15qe4il", + "$ip": "128.148.1.135", + # Optional Fields + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$failure_reason": "$wrong_password", + "$username": "billjones1@example.com", + "$account_types": ["merchant", "premium"], + "$social_sign_on_type": "$linkedin", + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + # Send this information with a login from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - "$brand_name" : "sift", - "$site_domain" : "sift.com", - "$site_country" : "US", - "$ordered_from" : { - "$store_id" : "123", - "$store_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6040", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" + return self.client.track("$login", login_properties) + + def logout(self) -> sift.client.Response: + # Sample $logout event + logout_properties = { + # Required Fields + "$user_id": self.user_id, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - }, - "$shipping_address" : { - "$name" : "Bill Jones", - "$phone" : "1-415-555-6041", - "$address_1" : "2100 Main Street", - "$address_2" : "Apt 3B", - "$city" : "New London", - "$region" : "New Hampshire", - "$country" : "US", - "$zipcode" : "03257" - }, - "$expedited_shipping" : True, - "$shipping_method" : "$physical", - "$shipping_carrier" : "UPS", - "$shipping_tracking_numbers": ["1Z204E380338943508", "1Z204E380338943509"], - "$items" : [ - { - "$item_id" : "12344321", - "$product_title" : "Microwavable Kettle Corn: Original Flavor", - "$price" : 4990000, # $4.99 - "$upc" : "097564307560", - "$sku" : "03586005", - "$brand" : "Peters Kettle Corn", - "$manufacturer" : "Peters Kettle Corn", - "$category" : "Food and Grocery", - "$tags" : ["Popcorn", "Snacks", "On Sale"], - "$quantity" : 4 - }, - { - "$item_id" : "B004834GQO", - "$product_title" : "The Slanket Blanket-Texas Tea", - "$price" : 39990000, # $39.99 - "$upc" : "6786211451001", - "$sku" : "004834GQ", - "$brand" : "Slanket", - "$manufacturer" : "Slanket", - "$category" : "Blankets & Throws", - "$tags" : ["Awesome", "Wintertime specials"], - "$color" : "Texas Tea", - "$quantity" : 2 + return self.client.track("$logout", logout_properties) + + def order_status(self) -> sift.client.Response: + # Sample $order_status event + order_properties = { + # Required Fields + "$user_id": self.user_id, + "$order_id": "ORDER-28168441", + "$order_status": "$canceled", + # Optional Fields + "$reason": "$payment_risk", + "$source": "$manual_review", + "$analyst": "someone@your-site.com", + "$webhook_id": "3ff1082a4aea8d0c58e3643ddb7a5bb87ffffeb2492dca33", + "$description": "Canceling because multiple fraudulent users on device", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$order_status", order_properties) + + def remove_item_from_cart(self) -> sift.client.Response: + # Sample $remove_item_from_cart event + remove_item_from_cart_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$item": { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$currency_code": "USD", + "$quantity": 2, + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - # For marketplaces, use $seller_user_id to identify the seller - "$seller_user_id" : "slinkys_emporium", - "$promotions" : [ - { - "$promotion_id" : "FirstTimeBuyer", - "$status" : "$success", - "$description" : "$5 off", - "$discount" : { - "$amount" : 5000000, # $5.00 - "$currency_code" : "USD", - "$minimum_purchase_amount" : 25000000 # $25.00 - } + return self.client.track( + "$remove_item_from_cart", remove_item_from_cart_properties + ) + + def security_notification(self) -> sift.client.Response: + # Sample $security_notification event + security_notification_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", + "$notification_status": "$sent", + # Optional fields if applicable + "$notification_type": "$email", + "$notified_value": "billy123@domain.com", + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, } - ], - # Sample Custom Fields - "digital_wallet" : "apple_pay", # "google_wallet", etc. - "coupon_code" : "dollarMadness", - "shipping_choice" : "FedEx Ground Courier", - "is_first_time_buyer" : False, - # Send this information from an APP client. - "$app" : { - # Example for the iOS Calculator app. - "$os" : "iOS", - "$os_version" : "10.1.3", - "$device_manufacturer" : "Apple", - "$device_model" : "iPhone 4,2", - "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", - "$app_name" : "Calculator", - "$app_version" : "3.2.7", - "$client_language" : "en-US" - } - } - return self.client.track("$update_order", update_order_properties) + return self.client.track( + "$security_notification", security_notification_properties + ) - def update_password(self): - # Sample $update_password event - update_password_properties = { - # Required Fields - "$user_id" : self.user_id, - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$status" : "$success", - "$reason" : "$forced_reset", - "$ip" : "128.148.1.135", # IP of the user that entered the new password after the old password was reset - # Send this information from an APP client. - "$app" : { - # Example for the iOS Calculator app. - "$os" : "iOS", - "$os_version" : "10.1.3", - "$device_manufacturer" : "Apple", - "$device_model" : "iPhone 4,2", - "$device_unique_id" : "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", - "$app_name" : "Calculator", - "$app_version" : "3.2.7", - "$client_language" : "en-US" - } - } - return self.client.track("$update_password", update_password_properties) + def transaction(self) -> sift.client.Response: + # Sample $transaction event + transaction_properties = { + # Required Fields + "$user_id": self.user_id, + "$amount": 506790000, # $506.79 + "$currency_code": "USD", + # Supported Fields + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$transaction_type": "$sale", + "$transaction_status": "$failure", + "$decline_category": "$bank_decline", + "$order_id": "ORDER-123124124", + "$transaction_id": "719637215", + "$billing_address": { # or "$sent_address" # or "$received_address" + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + "$ordered_from": { + "$store_id": "123", + "$store_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + }, + # Credit card example + "$payment_method": { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444", + }, + # Supported fields for 3DS + "$status_3ds": "$attempted", + "$triggered_3ds": "$processor", + "$merchant_initiated_transaction": False, + # Supported Fields + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$session_id": "gigtleqddo84l8cm15qe4il", + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id": "slinkys_emporium", + # Sample Custom Fields + "digital_wallet": "apple_pay", # "google_wallet", etc. + "coupon_code": "dollarMadness", + "shipping_choice": "FedEx Ground Courier", + "is_first_time_buyer": False, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } + return self.client.track("$transaction", transaction_properties) + + def update_account(self) -> sift.client.Response: + # Sample $update_account event + update_account_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$changed_password": True, + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$referrer_user_id": "janejane102", + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$social_sign_on_type": "$twitter", + "$account_types": ["merchant", "premium"], + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } - def verification(self): - # Sample $verification event - verification_properties = { - # Required Fields - "$user_id" : self.user_id, - "$session_id" : "gigtleqddo84l8cm15qe4il", - "$status" : "$pending", + return self.client.track("$update_account", update_account_properties) + + def update_content_comment(self) -> sift.client.Response: + # Sample $update_content event for comments + update_content_comment_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "comment-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $comment object + "$comment": { + "$body": "Congrats on the new role!", + "$contact_email": "alex_301@domain.com", + "$parent_comment_id": "comment-23407", + "$root_content_id": "listing-12923213", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "An old picture", + } + ], + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_comment_properties + ) + + def update_content_listing(self) -> sift.client.Response: + # Sample $update_content event for listings + update_content_listing_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "listing-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $listing object + "$listing": { + "$subject": "2 Bedroom Apartment for Rent", + "$body": "Capitol Hill Seattle brand new condo. 2 bedrooms and 1 full bath.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$listed_items": [ + { + "$price": 2950000000, # $2950.00 + "$currency_code": "USD", + "$tags": ["heat", "washer/dryer"], + } + ], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Billy's picture", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_listing_properties + ) + + def update_content_message(self) -> sift.client.Response: + # Sample $update_content event for messages + update_content_message_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "message-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $message object + "$message": { + "$body": "Lets meet at 5pm", + "$contact_email": "alex_301@domain.com", + "$recipient_user_ids": ["fy9h989sjphh71"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "My hike today!", + } + ], + }, + # Send this information from a BROWSER client. + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + } - # Optional fields if applicable - "$verified_event" : "$login", - "$reason" : "$automated_rule", - "$verification_type" : "$sms", - "$verified_value" : "14155551212" - } - return self.client.track("$verification", verification_properties) - \ No newline at end of file + return self.client.track( + "$update_content", update_content_message_properties + ) + + def update_content_post(self) -> sift.client.Response: + # Sample $update_content event for posts + update_content_post_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "post-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $post object + "$post": { + "$subject": "My new apartment!", + "$body": "Moved into my new apartment yesterday.", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Bill Jones", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$categories": ["Personal"], + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "View from the window!", + } + ], + "$expiration_time": 1549063157000, # UNIX timestamp in milliseconds + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_post_properties + ) + + def update_content_profile(self) -> sift.client.Response: + # Sample $update_content event for reviews + update_content_profile_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "profile-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $profile object + "$profile": { + "$body": "Hi! My name is Alex and I just moved to New London!", + "$contact_email": "alex_301@domain.com", + "$contact_address": { + "$name": "Alex Smith", + "$phone": "1-415-555-6041", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Alex's picture", + } + ], + "$categories": ["Friends", "Long-term dating"], + }, + # ========================================= + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_profile_properties + ) + + def update_content_review(self) -> sift.client.Response: + # Sample $update_content event for reviews + update_content_review_properties = { + # Required fields + "$user_id": self.user_id, + "$content_id": "review-23412", + # Recommended fields + "$session_id": "a234ksjfgn435sfg", + "$status": "$active", + "$ip": "255.255.255.0", + # Required $review object + "$review": { + "$subject": "Amazing Tacos!", + "$body": "I ate the tacos.", + "$contact_email": "alex_301@domain.com", + "$locations": [ + { + "$city": "Seattle", + "$region": "Washington", + "$country": "US", + "$zipcode": "98112", + } + ], + "$reviewed_content_id": "listing-234234", + "$images": [ + { + "$md5_hash": "0cc175b9c0f1b6a831c399e269772661", + "$link": "https://www.domain.com/file.png", + "$description": "Calamari tacos.", + } + ], + "$rating": 4.5, + }, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_content", update_content_review_properties + ) + + def update_order(self) -> sift.client.Response: + # Sample $update_order event + update_order_properties = { + # Required Fields + "$user_id": self.user_id, + # Supported Fields + "$session_id": "gigtleqddo84l8cm15qe4il", + "$order_id": "ORDER-28168441", + "$user_email": self.user_email, + "$verification_phone_number": "+123456789012", + "$amount": 115940000, # $115.94 + "$currency_code": "USD", + "$billing_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$payment_methods": [ + { + "$payment_type": "$credit_card", + "$payment_gateway": "$braintree", + "$card_bin": "542486", + "$card_last4": "4444", + } + ], + "$brand_name": "sift", + "$site_domain": "sift.com", + "$site_country": "US", + "$ordered_from": { + "$store_id": "123", + "$store_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6040", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + }, + "$shipping_address": { + "$name": "Bill Jones", + "$phone": "1-415-555-6041", + "$address_1": "2100 Main Street", + "$address_2": "Apt 3B", + "$city": "New London", + "$region": "New Hampshire", + "$country": "US", + "$zipcode": "03257", + }, + "$expedited_shipping": True, + "$shipping_method": "$physical", + "$shipping_carrier": "UPS", + "$shipping_tracking_numbers": [ + "1Z204E380338943508", + "1Z204E380338943509", + ], + "$items": [ + { + "$item_id": "12344321", + "$product_title": "Microwavable Kettle Corn: Original Flavor", + "$price": 4990000, # $4.99 + "$upc": "097564307560", + "$sku": "03586005", + "$brand": "Peters Kettle Corn", + "$manufacturer": "Peters Kettle Corn", + "$category": "Food and Grocery", + "$tags": ["Popcorn", "Snacks", "On Sale"], + "$quantity": 4, + }, + { + "$item_id": "B004834GQO", + "$product_title": "The Slanket Blanket-Texas Tea", + "$price": 39990000, # $39.99 + "$upc": "6786211451001", + "$sku": "004834GQ", + "$brand": "Slanket", + "$manufacturer": "Slanket", + "$category": "Blankets & Throws", + "$tags": ["Awesome", "Wintertime specials"], + "$color": "Texas Tea", + "$quantity": 2, + }, + ], + # For marketplaces, use $seller_user_id to identify the seller + "$seller_user_id": "slinkys_emporium", + "$promotions": [ + { + "$promotion_id": "FirstTimeBuyer", + "$status": "$success", + "$description": "$5 off", + "$discount": { + "$amount": 5000000, # $5.00 + "$currency_code": "USD", + "$minimum_purchase_amount": 25000000, # $25.00 + }, + } + ], + # Sample Custom Fields + "digital_wallet": "apple_pay", # "google_wallet", etc. + "coupon_code": "dollarMadness", + "shipping_choice": "FedEx Ground Courier", + "is_first_time_buyer": False, + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track("$update_order", update_order_properties) + + def update_password(self) -> sift.client.Response: + # Sample $update_password event + update_password_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", + "$status": "$success", + "$reason": "$forced_reset", + "$ip": "128.148.1.135", # IP of the user that entered the new password after the old password was reset + # Send this information from an APP client. + "$app": { + # Example for the iOS Calculator app. + "$os": "iOS", + "$os_version": "10.1.3", + "$device_manufacturer": "Apple", + "$device_model": "iPhone 4,2", + "$device_unique_id": "A3D261E4-DE0A-470B-9E4A-720F3D3D22E6", + "$app_name": "Calculator", + "$app_version": "3.2.7", + "$client_language": "en-US", + }, + } + return self.client.track( + "$update_password", update_password_properties + ) + + def verification(self) -> sift.client.Response: + # Sample $verification event + verification_properties = { + # Required Fields + "$user_id": self.user_id, + "$session_id": "gigtleqddo84l8cm15qe4il", + "$status": "$pending", + # Optional fields if applicable + "$verified_event": "$login", + "$reason": "$automated_rule", + "$verification_type": "$sms", + "$verified_value": "14155551212", + } + return self.client.track("$verification", verification_properties) diff --git a/test_integration_app/globals.py b/test_integration_app/globals.py index 030531e..cc7959e 100644 --- a/test_integration_app/globals.py +++ b/test_integration_app/globals.py @@ -1,5 +1,7 @@ -def initialize(): +user_id = "billy_jones_301" +user_email = "billjones1@example.com" +session_id = "gigtleqddo84l8cm15qe4il" + + +def initialize() -> None: global user_id, user_email, session_id - user_id = 'billy_jones_301' - user_email = 'billjones1@example.com' - session_id = 'gigtleqddo84l8cm15qe4il' diff --git a/test_integration_app/main.py b/test_integration_app/main.py index 63fd928..6ac0fb8 100644 --- a/test_integration_app/main.py +++ b/test_integration_app/main.py @@ -1,113 +1,129 @@ -import string import random +import string -from events_api import test_events_api from decisions_api import test_decisions_api -from workflows_api import test_workflows_api +from events_api import test_events_api +from psp_merchant_api import test_psp_merchant_api from score_api import test_score_api from verifications_api import test_verification_api -from psp_merchant_api import test_psp_merchant_api +from workflows_api import test_workflows_api + +from sift.client import Response -class Utils: - def isOK(self, response): - if(hasattr(response, 'status')): - return ((response.status == 0) and ((response.http_status_code == 200) or (response.http_status_code == 201))) - else: - return ((response.http_status_code == 200) or (response.http_status_code == 201)) - - def is_ok_with_warnings(self, response): - return self.isOK(response) and \ - hasattr(response, 'body') and \ - len(response.body['warnings']) > 0 - - def is_ok_without_warnings(self, response): - return self.isOK(response) and \ - hasattr(response, 'body') and \ - 'warnings' not in response.body - -def runAllMethods(): - objUtils = Utils() - objEvents = test_events_api.EventsAPI() - objDecision = test_decisions_api.DecisionAPI() - objScore = test_score_api.ScoreAPI() - objWorkflow = test_workflows_api.WorkflowsAPI() - objVerification = test_verification_api.VerificationAPI() - objPSPMerchant = test_psp_merchant_api.PSPMerchantAPI() + +def is_ok(response: Response) -> bool: + if hasattr(response, "status"): + return response.status == 0 and response.http_status_code in (200, 201) + + return response.http_status_code in (200, 201) + + +def is_ok_with_warnings(response: Response) -> bool: + return ( + is_ok(response) + and hasattr(response, "body") + and isinstance(response.body, dict) + and bool(response.body["warnings"]) + ) + + +def is_ok_without_warnings(response: Response) -> bool: + return ( + is_ok(response) + and hasattr(response, "body") + and isinstance(response.body, dict) + and "warnings" not in response.body + ) + + +def run_all_methods() -> None: + obj_events = test_events_api.EventsAPI() + obj_decisions = test_decisions_api.DecisionAPI() + obj_score = test_score_api.ScoreAPI() + obj_workflow = test_workflows_api.WorkflowsAPI() + obj_verification = test_verification_api.VerificationAPI() + obj_psp_merchant = test_psp_merchant_api.PSPMerchantAPI() # Events APIs - assert (objUtils.isOK(objEvents.add_item_to_cart()) == True) - assert (objUtils.isOK(objEvents.add_promotion()) == True) - assert (objUtils.isOK(objEvents.chargeback()) == True) - assert (objUtils.isOK(objEvents.content_status()) == True) - assert (objUtils.isOK(objEvents.create_account()) == True) - assert (objUtils.isOK(objEvents.create_content_comment()) == True) - assert (objUtils.isOK(objEvents.create_content_listing()) == True) - assert (objUtils.isOK(objEvents.create_content_message()) == True) - assert (objUtils.isOK(objEvents.create_content_post()) == True) - assert (objUtils.isOK(objEvents.create_content_profile()) == True) - assert (objUtils.isOK(objEvents.create_content_review()) == True) - assert (objUtils.isOK(objEvents.create_order()) == True) - assert (objUtils.isOK(objEvents.flag_content()) == True) - assert (objUtils.isOK(objEvents.link_session_to_user()) == True) - assert (objUtils.isOK(objEvents.login()) == True) - assert (objUtils.isOK(objEvents.logout()) == True) - assert (objUtils.isOK(objEvents.order_status()) == True) - assert (objUtils.isOK(objEvents.remove_item_from_cart()) == True) - assert (objUtils.isOK(objEvents.security_notification()) == True) - assert (objUtils.isOK(objEvents.transaction()) == True) - assert (objUtils.isOK(objEvents.update_account()) == True) - assert (objUtils.isOK(objEvents.update_content_comment()) == True) - assert (objUtils.isOK(objEvents.update_content_listing()) == True) - assert (objUtils.isOK(objEvents.update_content_message()) == True) - assert (objUtils.isOK(objEvents.update_content_post()) == True) - assert (objUtils.isOK(objEvents.update_content_profile()) == True) - assert (objUtils.isOK(objEvents.update_content_review()) == True) - assert (objUtils.isOK(objEvents.update_order()) == True) - assert (objUtils.isOK(objEvents.update_password()) == True) - assert (objUtils.isOK(objEvents.verification()) == True) + assert is_ok(obj_events.add_item_to_cart()) + assert is_ok(obj_events.add_promotion()) + assert is_ok(obj_events.chargeback()) + assert is_ok(obj_events.content_status()) + assert is_ok(obj_events.create_account()) + assert is_ok(obj_events.create_content_comment()) + assert is_ok(obj_events.create_content_listing()) + assert is_ok(obj_events.create_content_message()) + assert is_ok(obj_events.create_content_post()) + assert is_ok(obj_events.create_content_profile()) + assert is_ok(obj_events.create_content_review()) + assert is_ok(obj_events.create_order()) + assert is_ok(obj_events.flag_content()) + assert is_ok(obj_events.link_session_to_user()) + assert is_ok(obj_events.login()) + assert is_ok(obj_events.logout()) + assert is_ok(obj_events.order_status()) + assert is_ok(obj_events.remove_item_from_cart()) + assert is_ok(obj_events.security_notification()) + assert is_ok(obj_events.transaction()) + assert is_ok(obj_events.update_account()) + assert is_ok(obj_events.update_content_comment()) + assert is_ok(obj_events.update_content_listing()) + assert is_ok(obj_events.update_content_message()) + assert is_ok(obj_events.update_content_post()) + assert is_ok(obj_events.update_content_profile()) + assert is_ok(obj_events.update_content_review()) + assert is_ok(obj_events.update_order()) + assert is_ok(obj_events.update_password()) + assert is_ok(obj_events.verification()) # Testing include warnings query param - assert (objUtils.is_ok_without_warnings(objEvents.create_order()) == True) - assert (objUtils.is_ok_with_warnings(objEvents.create_order_with_warnings()) == True) + assert is_ok_without_warnings(obj_events.create_order()) + assert is_ok_with_warnings(obj_events.create_order_with_warnings()) print("Events API Tested") # Decision APIs - assert (objUtils.isOK(objDecision.apply_user_decision()) == True) - assert (objUtils.isOK(objDecision.apply_order_decision()) == True) - assert (objUtils.isOK(objDecision.apply_session_decision()) == True) - assert (objUtils.isOK(objDecision.apply_content_decision()) == True) - assert (objUtils.isOK(objDecision.get_user_decisions()) == True) - assert (objUtils.isOK(objDecision.get_order_decisions()) == True) - assert (objUtils.isOK(objDecision.get_content_decisions()) == True) - assert (objUtils.isOK(objDecision.get_session_decisions()) == True) - assert (objUtils.isOK(objDecision.get_decisions()) == True) + assert is_ok(obj_decisions.apply_user_decision()) + assert is_ok(obj_decisions.apply_order_decision()) + assert is_ok(obj_decisions.apply_session_decision()) + assert is_ok(obj_decisions.apply_content_decision()) + assert is_ok(obj_decisions.get_user_decisions()) + assert is_ok(obj_decisions.get_order_decisions()) + assert is_ok(obj_decisions.get_content_decisions()) + assert is_ok(obj_decisions.get_session_decisions()) + assert is_ok(obj_decisions.get_decisions()) print("Decision API Tested") # Workflows APIs - assert (objUtils.isOK(objWorkflow.synchronous_workflows()) == True) + assert is_ok(obj_workflow.synchronous_workflows()) print("Workflow API Tested") # Score APIs - assert (objUtils.isOK(objScore.get_user_score()) == True) + assert is_ok(obj_score.get_user_score()) print("Score API Tested") # Verification APIs - assert (objUtils.isOK(objVerification.send()) == True) - assert (objUtils.isOK(objVerification.resend()) == True) - checkResponse = objVerification.check() - assert (objUtils.isOK(checkResponse) == True) - assert (checkResponse.body["status"] == 50) + assert is_ok(obj_verification.send()) + assert is_ok(obj_verification.resend()) + checkResponse = obj_verification.check() + assert is_ok(checkResponse) + assert isinstance(checkResponse.body, dict) + assert checkResponse.body["status"] == 50 print("Verification API Tested") # PSP Merchant APIs - merchant_id = 'merchant_id_test_app' + ''.join(random.choices(string.digits, k = 7)) - assert (objUtils.isOK(objPSPMerchant.create_merchant(merchant_id)) == True) - assert (objUtils.isOK(objPSPMerchant.edit_merchant(merchant_id)) == True) - assert (objUtils.isOK(objPSPMerchant.get_merchant_profiles()) == True) - assert (objUtils.isOK(objPSPMerchant.get_merchant_profiles(batch_size=10, batch_token=None)) == True) + merchant_id = "merchant_id_test_app" + "".join( + random.choices(string.digits, k=7) + ) + assert is_ok(obj_psp_merchant.create_merchant(merchant_id)) + assert is_ok(obj_psp_merchant.edit_merchant(merchant_id)) + assert is_ok(obj_psp_merchant.get_merchant_profiles()) + assert is_ok( + obj_psp_merchant.get_merchant_profiles(batch_size=10, batch_token=None) + ) print("PSP Merchant API Tested") print("API Integration tests execution finished") -runAllMethods() + +run_all_methods() diff --git a/test_integration_app/psp_merchant_api/test_psp_merchant_api.py b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py index 3d44114..6a023d2 100644 --- a/test_integration_app/psp_merchant_api/test_psp_merchant_api.py +++ b/test_integration_app/psp_merchant_api/test_psp_merchant_api.py @@ -1,69 +1,67 @@ -import sift -import string -import random # define the random module +from __future__ import annotations from os import environ as env -class PSPMerchantAPI(): - # Get the value of API_KEY and ACCOUNT_ID from environment variable - api_key = env['API_KEY'] - account_id = env['ACCOUNT_ID'] +import sift + + +class PSPMerchantAPI: + # Get the value of API_KEY and ACCOUNT_ID from environment variable + api_key = env["API_KEY"] + account_id = env["ACCOUNT_ID"] + + client = sift.Client(api_key=api_key, account_id=account_id) + + def create_merchant(self, merchant_id: str) -> sift.client.Response: + properties = { + "id": merchant_id, + "name": "Wonderful Payments Inc.13", + "description": "Wonderful Payments payment provider.", + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320", + }, + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": {"level": "low", "score": 10}, + } + return self.client.create_psp_merchant_profile(properties) - client = sift.Client(api_key = api_key, account_id = account_id) - - def create_merchant(self, merchant_id): - merchantProperties={ - "id": merchant_id, - "name": "Wonderful Payments Inc.13", - "description": "Wonderful Payments payment provider.", - "address": { - "name": "Alany", - "address_1": "Big Payment blvd, 22", - "address_2": "apt, 8", - "city": "New Orleans", - "region": "NA", - "country": "US", - "zipcode": "76830", - "phone": "0394888320" - }, - "category": "1002", - "service_level": "Platinum", - "status": "active", - "risk_profile": { - "level": "low", - "score": 10 - } - } - return self.client.create_psp_merchant_profile(merchantProperties) - - def edit_merchant(self, merchant_id): - merchantProperties = { - "id": merchant_id, - "name": "Wonderful Payments Inc.13 edit", - "description": "Wonderful Payments payment provider. edit", - "address": { - "name": "Alany", - "address_1": "Big Payment blvd, 22", - "address_2": "apt, 8", - "city": "New Orleans", - "region": "NA", - "country": "US", - "zipcode": "76830", - "phone": "0394888320" - }, - "category": "1002", - "service_level": "Platinum", - "status": "active", - "risk_profile": { - "level": "low", - "score": 10 - } - } - return self.client.update_psp_merchant_profile(merchant_id, merchantProperties) + def edit_merchant(self, merchant_id: str) -> sift.client.Response: + properties = { + "id": merchant_id, + "name": "Wonderful Payments Inc.13 edit", + "description": "Wonderful Payments payment provider. edit", + "address": { + "name": "Alany", + "address_1": "Big Payment blvd, 22", + "address_2": "apt, 8", + "city": "New Orleans", + "region": "NA", + "country": "US", + "zipcode": "76830", + "phone": "0394888320", + }, + "category": "1002", + "service_level": "Platinum", + "status": "active", + "risk_profile": {"level": "low", "score": 10}, + } + return self.client.update_psp_merchant_profile(merchant_id, properties) - def get_a_merchant_profile(self, merchant_id): - return self.client.get_a_psp_merchant_profile(merchant_id) + def get_a_merchant_profile(self, merchant_id: str) -> sift.client.Response: + return self.client.get_a_psp_merchant_profile(merchant_id) - def get_merchant_profiles(self, batch_token = None, batch_size = None): - return self.client.get_psp_merchant_profiles(batch_token, batch_size) - \ No newline at end of file + def get_merchant_profiles( + self, + batch_token: str | None = None, + batch_size: int | None = None, + ) -> sift.client.Response: + return self.client.get_psp_merchant_profiles(batch_token, batch_size) diff --git a/test_integration_app/score_api/test_score_api.py b/test_integration_app/score_api/test_score_api.py index 5c61b22..844fdaa 100644 --- a/test_integration_app/score_api/test_score_api.py +++ b/test_integration_app/score_api/test_score_api.py @@ -1,14 +1,19 @@ -import sift +from os import environ as env + import globals -from os import environ as env +import sift + + +class ScoreAPI: + # Get the value of API_KEY from environment variable + api_key = env["API_KEY"] + client = sift.Client(api_key=api_key) + globals.initialize() + user_id = globals.user_id -class ScoreAPI(): - # Get the value of API_KEY from environment variable - api_key = env['API_KEY'] - client = sift.Client(api_key = api_key) - globals.initialize() - user_id = globals.user_id - - def get_user_score(self): - return self.client.get_user_score(user_id = self.user_id, abuse_types=["payment_abuse", "promotion_abuse"]) + def get_user_score(self) -> sift.client.Response: + return self.client.get_user_score( + user_id=self.user_id, + abuse_types=["payment_abuse", "promotion_abuse"], + ) diff --git a/test_integration_app/verifications_api/test_verification_api.py b/test_integration_app/verifications_api/test_verification_api.py index b35478d..899df21 100644 --- a/test_integration_app/verifications_api/test_verification_api.py +++ b/test_integration_app/verifications_api/test_verification_api.py @@ -1,52 +1,54 @@ -import sift +from os import environ as env + import globals -from os import environ as env +import sift -class VerificationAPI(): + +class VerificationAPI: # Get the value of API_KEY from environment variable - api_key = env['API_KEY'] - client = sift.Client(api_key = api_key) + api_key = env["API_KEY"] + client = sift.Client(api_key=api_key) globals.initialize() user_id = globals.user_id user_email = globals.user_email - - def send(self): - sendProperties = { - '$user_id': self.user_id, - '$send_to': self.user_email, - '$verification_type': '$email', - '$brand_name': 'MyTopBrand', - '$language': 'en', - '$site_country': 'IN', - '$event': { - '$session_id': 'SOME_SESSION_ID', - '$verified_event': '$login', - '$verified_entity_id': 'SOME_SESSION_ID', - '$reason': '$automated_rule', - '$ip': '192.168.1.1', - '$browser': { - '$user_agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36', - '$accept_language': 'en-US', - '$content_language': 'en-GB' - } - } - } - return self.client.verification_send(sendProperties) - - def resend(self): - resendProperties = { - '$user_id': self.user_id, - '$verified_event': '$login', - '$verified_entity_id': 'SOME_SESSION_ID' - } - return self.client.verification_resend(resendProperties) - def check(self): - checkProperties = { - '$user_id': self.user_id, - '$code': '123456', - '$verified_event': '$login', - '$verified_entity_id': "SOME_SESSION_ID" + def send(self) -> sift.client.Response: + properties = { + "$user_id": self.user_id, + "$send_to": self.user_email, + "$verification_type": "$email", + "$brand_name": "MyTopBrand", + "$language": "en", + "$site_country": "IN", + "$event": { + "$session_id": "SOME_SESSION_ID", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + "$reason": "$automated_rule", + "$ip": "192.168.1.1", + "$browser": { + "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", + "$accept_language": "en-US", + "$content_language": "en-GB", + }, + }, + } + return self.client.verification_send(properties) + + def resend(self) -> sift.client.Response: + properties = { + "$user_id": self.user_id, + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", + } + return self.client.verification_resend(properties) + + def check(self) -> sift.client.Response: + properties = { + "$user_id": self.user_id, + "$code": "123456", + "$verified_event": "$login", + "$verified_entity_id": "SOME_SESSION_ID", } - return self.client.verification_check(checkProperties) + return self.client.verification_check(properties) diff --git a/test_integration_app/workflows_api/test_workflows_api.py b/test_integration_app/workflows_api/test_workflows_api.py index 4dd26d6..afb55f5 100644 --- a/test_integration_app/workflows_api/test_workflows_api.py +++ b/test_integration_app/workflows_api/test_workflows_api.py @@ -1,20 +1,28 @@ -import sift -import globals from os import environ as env -class WorkflowsAPI(): +import globals + +import sift + + +class WorkflowsAPI: # Get the value of API_KEY from environment variable - api_key = env['API_KEY'] - client = sift.Client(api_key = api_key) + api_key = env["API_KEY"] + client = sift.Client(api_key=api_key) globals.initialize() user_id = globals.user_id user_email = globals.user_email - - def synchronous_workflows(self): + + def synchronous_workflows(self) -> sift.client.Response: properties = { - '$user_id' : self.user_id, - '$user_email' : self.user_email - } - return self.client.track('$create_order', properties, return_workflow_status=True, - return_route_info=True, abuse_types=['promo_abuse', 'content_abuse', 'payment_abuse']) - \ No newline at end of file + "$user_id": self.user_id, + "$user_email": self.user_email, + } + + return self.client.track( + "$create_order", + properties, + return_workflow_status=True, + return_route_info=True, + abuse_types=["promo_abuse", "content_abuse", "payment_abuse"], + ) diff --git a/tests/test_client.py b/tests/test_client.py index cc6a6b4..fb38a53 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,53 +1,50 @@ +from __future__ import annotations + import datetime import json -import sys -import unittest +import typing as t import warnings from decimal import Decimal -from requests.auth import HTTPBasicAuth +from unittest import TestCase, mock -import mock -import requests.exceptions +from requests.auth import HTTPBasicAuth +from requests.exceptions import RequestException import sift - -if sys.version_info[0] < 3: - import six.moves.urllib as urllib -else: - import urllib.parse +from sift.utils import quote_path as _q -def valid_transaction_properties(): +def valid_transaction_properties() -> dict[str, t.Any]: return { - '$buyer_user_id': '123456', - '$seller_user_id': '654321', - '$amount': Decimal('1253200.0'), - '$currency_code': 'USD', - '$time': int(datetime.datetime.now().strftime('%S')), - '$transaction_id': 'my_transaction_id', - '$billing_name': 'Mike Snow', - '$billing_bin': '411111', - '$billing_last4': '1111', - '$billing_address1': '123 Main St.', - '$billing_city': 'San Francisco', - '$billing_region': 'CA', - '$billing_country': 'US', - '$billing_zip': '94131', - '$user_email': 'mike@example.com' + "$buyer_user_id": "123456", + "$seller_user_id": "654321", + "$amount": Decimal("1253200.0"), + "$currency_code": "USD", + "$time": int(datetime.datetime.now().strftime("%S")), + "$transaction_id": "my_transaction_id", + "$billing_name": "Mike Snow", + "$billing_bin": "411111", + "$billing_last4": "1111", + "$billing_address1": "123 Main St.", + "$billing_city": "San Francisco", + "$billing_region": "CA", + "$billing_country": "US", + "$billing_zip": "94131", + "$user_email": "mike@example.com", } -def valid_label_properties(): +def valid_label_properties() -> dict[str, t.Any]: return { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", } -def valid_psp_merchant_properties(): +def valid_psp_merchant_properties() -> dict[str, t.Any]: return { "$id": "api-key-1", "$name": "Wonderful Payments Inc.", @@ -65,14 +62,11 @@ def valid_psp_merchant_properties(): "$category": "1002", "$service_level": "Platinum", "$status": "active", - "$risk_profile": { - "$level": "low", - "$score": 10 - } + "$risk_profile": {"$level": "low", "$score": 10}, } -def valid_psp_merchant_properties_response(): +def valid_psp_merchant_properties_response() -> str: return """{ "id":"api-key-1", "name": "Wonderful Payments Inc.", @@ -97,7 +91,7 @@ def valid_psp_merchant_properties_response(): }""" -def score_response_json(): +def score_response_json() -> str: return """{ "status": 0, "error_message": "OK", @@ -128,7 +122,7 @@ def score_response_json(): }""" -def workflow_statuses_json(): +def workflow_statuses_json() -> str: return """{ "route" : { "name" : "my route" @@ -182,7 +176,7 @@ def workflow_statuses_json(): }""" -def action_response_json(): +def action_response_json() -> str: return """{ "actions": [ { @@ -229,20 +223,21 @@ def action_response_json(): }""" -def response_with_data_header(): - return { - 'content-type': 'application/json; charset=UTF-8' - } +def response_with_data_header() -> dict[str, t.Any]: + return {"content-type": "application/json; charset=UTF-8"} -class TestSiftPythonClient(unittest.TestCase): +class TestSiftPythonClient(TestCase): - def setUp(self): - self.test_key = 'a_fake_test_api_key' - self.account_id = 'ACCT' - self.sift_client = sift.Client(api_key=self.test_key, account_id=self.account_id) + def setUp(self) -> None: + self.test_key = "a_fake_test_api_key" + self.account_id = "ACCT" + self.sift_client = sift.Client( + api_key=self.test_key, + account_id=self.account_id, + ) - def test_global_api_key(self): + def test_global_api_key(self) -> None: # test for error if global key is undefined self.assertRaises(TypeError, sift.Client) sift.api_key = "a_test_global_api_key" @@ -252,133 +247,156 @@ def test_global_api_key(self): client2 = sift.Client(local_api_key) # test that global api key is assigned - assert (client1.api_key == sift.api_key) + assert client1.api_key == sift.api_key # test that local api key is assigned - assert (client2.api_key == local_api_key) + assert client2.api_key == local_api_key client2 = sift.Client() # test that client2 is assigned a new object with global api_key - assert (client2.api_key == sift.api_key) + assert client2.api_key == sift.api_key - def test_constructor_requires_valid_api_key(self): + def test_constructor_requires_valid_api_key(self) -> None: self.assertRaises(TypeError, sift.Client, None) - self.assertRaises(ValueError, sift.Client, '') + self.assertRaises(ValueError, sift.Client, "") - def test_constructor_invalid_api_url(self): + def test_constructor_invalid_api_url(self) -> None: self.assertRaises(TypeError, sift.Client, self.test_key, None) - self.assertRaises(ValueError, sift.Client, self.test_key, '') + self.assertRaises(ValueError, sift.Client, self.test_key, "") - def test_constructor_api_key(self): + def test_constructor_api_key(self) -> None: client = sift.Client(self.test_key) self.assertEqual(client.api_key, self.test_key) - def test_track_requires_valid_event(self): + def test_track_requires_valid_event(self) -> None: self.assertRaises(TypeError, self.sift_client.track, None, {}) - self.assertRaises(ValueError, self.sift_client.track, '', {}) + self.assertRaises(ValueError, self.sift_client.track, "", {}) self.assertRaises(TypeError, self.sift_client.track, 42, {}) - def test_track_requires_properties(self): - event = 'custom_event' + def test_track_requires_properties(self) -> None: + event = "custom_event" self.assertRaises(TypeError, self.sift_client.track, event, None) self.assertRaises(TypeError, self.sift_client.track, event, 42) self.assertRaises(ValueError, self.sift_client.track, event, {}) - def test_score_requires_user_id(self): + def test_score_requires_user_id(self) -> None: self.assertRaises(TypeError, self.sift_client.score, None) - self.assertRaises(ValueError, self.sift_client.score, '') + self.assertRaises(ValueError, self.sift_client.score, "") self.assertRaises(TypeError, self.sift_client.score, 42) - def test_event_ok(self): - event = '$transaction' + def test_event_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties()) + + response = self.sift_client.track( + event, valid_transaction_properties() + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_event_with_timeout_param_ok(self): - event = '$transaction' + def test_event_with_timeout_param_ok(self) -> None: + event = "$transaction" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.track( - event, valid_transaction_properties(), timeout=test_timeout) + event, valid_transaction_properties(), timeout=test_timeout + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=test_timeout, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score_ok(self): + def test_score_ok(self) -> None: mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score('12345') + + response = self.sift_client.score("12345") + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/12345', + "https://api.sift.com/v205/score/12345", params={}, headers=mock.ANY, timeout=mock.ANY, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['score'] == 0.85) - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - - def test_score_with_timeout_param_ok(self): + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_score_with_timeout_param_ok(self) -> None: test_timeout = 5 mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score('12345', test_timeout) + + response = self.sift_client.score("12345", test_timeout) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/12345', + "https://api.sift.com/v205/score/12345", params={}, headers=mock.ANY, timeout=test_timeout, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['score'] == 0.85) - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - - def test_get_user_score_ok(self): - """Test the GET /{version}/users/{userId}/score API, i.e. client.get_user_score() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_get_user_score_ok(self) -> None: + """ + Test the GET /{version}/users/{userId}/score API, + i.e. client.get_user_score() """ test_timeout = 5 mock_response = mock.Mock() @@ -386,25 +404,32 @@ def test_get_user_score_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_user_score('12345', test_timeout) + + response = self.sift_client.get_user_score("12345", test_timeout) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', + "https://api.sift.com/v205/users/12345/score", params={}, headers=mock.ANY, timeout=test_timeout, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['entity_id'] == '12345') - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - assert ('latest_decisions' in response.body) - - def test_get_user_score_with_abuse_types_ok(self): - """Test the GET /{version}/users/{userId}/score?abuse_types=... API, i.e. client.get_user_score() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_get_user_score_with_abuse_types_ok(self) -> None: + """ + Test the GET /{version}/users/{userId}/score?abuse_types=... API, + i.e. client.get_user_score() """ test_timeout = 5 mock_response = mock.Mock() @@ -412,27 +437,36 @@ def test_get_user_score_with_abuse_types_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_user_score('12345', - abuse_types=['payment_abuse', 'content_abuse'], - timeout=test_timeout) + + response = self.sift_client.get_user_score( + "12345", + abuse_types=["payment_abuse", "content_abuse"], + timeout=test_timeout, + ) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', - params={'abuse_types': 'payment_abuse,content_abuse'}, + "https://api.sift.com/v205/users/12345/score", + params={"abuse_types": "payment_abuse,content_abuse"}, headers=mock.ANY, timeout=test_timeout, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['entity_id'] == '12345') - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - assert ('latest_decisions' in response.body) - - def test_rescore_user_ok(self): - """Test the POST /{version}/users/{userId}/score API, i.e. client.rescore_user() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_rescore_user_ok(self) -> None: + """ + Test the POST /{version}/users/{userId}/score API, + i.e. client.rescore_user() """ test_timeout = 5 mock_response = mock.Mock() @@ -440,25 +474,32 @@ def test_rescore_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.rescore_user('12345', test_timeout) + + response = self.sift_client.rescore_user("12345", test_timeout) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', + "https://api.sift.com/v205/users/12345/score", params={}, headers=mock.ANY, timeout=test_timeout, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['entity_id'] == '12345') - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - assert ('latest_decisions' in response.body) - - def test_rescore_user_with_abuse_types_ok(self): - """Test the POST /{version}/users/{userId}/score?abuse_types=... API, i.e. client.rescore_user() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_rescore_user_with_abuse_types_ok(self) -> None: + """ + Test the POST /{version}/users/{userId}/score?abuse_types=... API, + i.e. client.rescore_user() """ test_timeout = 5 mock_response = mock.Mock() @@ -466,117 +507,126 @@ def test_rescore_user_with_abuse_types_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.rescore_user('12345', - abuse_types=['payment_abuse', 'content_abuse'], - timeout=test_timeout) + + response = self.sift_client.rescore_user( + "12345", + abuse_types=["payment_abuse", "content_abuse"], + timeout=test_timeout, + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', - params={'abuse_types': 'payment_abuse,content_abuse'}, + "https://api.sift.com/v205/users/12345/score", + params={"abuse_types": "payment_abuse,content_abuse"}, headers=mock.ANY, timeout=test_timeout, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['entity_id'] == '12345') - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - assert ('latest_decisions' in response.body) - - def test_sync_score_ok(self): - event = '$transaction' + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_sync_score_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % score_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {score_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.track( event, valid_transaction_properties(), return_score=True, - abuse_types=['payment_abuse', 'content_abuse', 'legacy']) - mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', - data=mock.ANY, - headers=mock.ANY, - timeout=mock.ANY, - params={'return_score': 'true', 'abuse_types': 'payment_abuse,content_abuse,legacy'}) - self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") - assert (response.body['score_response']['score'] == 0.85) - assert (response.body['score_response']['scores']['content_abuse']['score'] == 0.14) - assert (response.body['score_response']['scores']['payment_abuse']['score'] == 0.97) - - def test_sync_workflow_ok(self): - event = '$transaction' - mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "workflow_statuses": %s}' - % workflow_statuses_json()) - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - response = self.sift_client.track( - event, - valid_transaction_properties(), - return_workflow_status=True, - return_route_info=True, - abuse_types=['payment_abuse', 'content_abuse', 'legacy']) + abuse_types=["payment_abuse", "content_abuse", "legacy"], + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_workflow_status': 'true', 'return_route_info': 'true', - 'abuse_types': 'payment_abuse,content_abuse,legacy'}) + params={ + "return_score": "true", + "abuse_types": "payment_abuse,content_abuse,legacy", + }, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") - assert (response.body['workflow_statuses']['route']['name'] == 'my route') + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score_response"]["score"] == 0.85 + assert ( + response.body["score_response"]["scores"]["content_abuse"][ + "score" + ] + == 0.14 + ) + assert ( + response.body["score_response"]["scores"]["payment_abuse"][ + "score" + ] + == 0.97 + ) - def test_sync_workflow_ok(self): - event = '$transaction' + def test_sync_workflow_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "workflow_statuses": %s}' - % workflow_statuses_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "workflow_statuses": {workflow_statuses_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.track( event, valid_transaction_properties(), return_workflow_status=True, return_route_info=True, - abuse_types=['payment_abuse', 'content_abuse', 'legacy']) + abuse_types=["payment_abuse", "content_abuse", "legacy"], + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_workflow_status': 'true', 'return_route_info': 'true', - 'abuse_types': 'payment_abuse,content_abuse,legacy'}) + params={ + "return_workflow_status": "true", + "return_route_info": "true", + "abuse_types": "payment_abuse,content_abuse,legacy", + }, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") - assert (response.body['workflow_statuses']['route']['name'] == 'my route') - - def test_get_decisions_fails(self): + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert ( + response.body["workflow_statuses"]["route"]["name"] + == "my route" + ) + + def test_get_decisions_fails(self) -> None: with self.assertRaises(ValueError): - self.sift_client.get_decisions('usr') + self.sift_client.get_decisions( + t.cast(t.Literal["user", "order", "session", "content"], "usr") + ) - def test_get_decisions(self): + def test_get_decisions(self) -> None: mock_response = mock.Mock() get_decisions_response_json = """ @@ -600,31 +650,39 @@ def test_get_decisions(self): "next_ref": "v3/accounts/accountId/decisions" } """ - mock_response.content = get_decisions_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_decisions(entity_type="user", - limit=10, - start_from=None, - abuse_types="legacy,payment_abuse", - timeout=3) + response = self.sift_client.get_decisions( + entity_type="user", + limit=10, + start_from=None, + abuse_types="legacy,payment_abuse", + timeout=3, + ) + mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', + "https://api.sift.com/v3/accounts/ACCT/decisions", headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'user', 'limit': 10, 'abuse_types': 'legacy,payment_abuse'}, - timeout=3) - + params={ + "entity_type": "user", + "limit": 10, + "abuse_types": "legacy,payment_abuse", + }, + timeout=3, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.body['data'][0]['id'] == 'block_user') + assert response.is_ok() + assert isinstance(response.body, dict) + assert response.body["data"][0]["id"] == "block_user" - def test_get_decisions_entity_session(self): + def test_get_decisions_entity_session(self) -> None: mock_response = mock.Mock() get_decisions_response_json = """ { @@ -647,39 +705,47 @@ def test_get_decisions_entity_session(self): "next_ref": "v3/accounts/accountId/decisions" } """ - mock_response.content = get_decisions_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_decisions(entity_type="session", - limit=10, - start_from=None, - abuse_types="account_takeover", - timeout=3) + response = self.sift_client.get_decisions( + entity_type="session", + limit=10, + start_from=None, + abuse_types="account_takeover", + timeout=3, + ) + mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/decisions', + "https://api.sift.com/v3/accounts/ACCT/decisions", headers=mock.ANY, auth=mock.ANY, - params={'entity_type': 'session', 'limit': 10, 'abuse_types': 'account_takeover'}, - timeout=3) - + params={ + "entity_type": "session", + "limit": 10, + "abuse_types": "account_takeover", + }, + timeout=3, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.body['data'][0]['id'] == 'block_session') + assert response.is_ok() + assert isinstance(response.body, dict) + assert response.body["data"][0]["id"] == "block_session" - def test_apply_decision_to_user_ok(self): - user_id = '54321' + def test_apply_decision_to_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "analyst": "analyst@biz.com", + "description": "called user and verified account", + "time": 1481569575, } apply_decision_response_json = """ { @@ -697,108 +763,143 @@ def test_apply_decision_to_user_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_user_decision(user_id, apply_decision_request) - data = json.dumps(apply_decision_request) - mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/decisions' % user_id, - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + response = self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) + + mock_post.assert_called_with( + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.body['entity']['type'] == 'user') - assert (response.http_status_code == 200) - assert (response.is_ok()) + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "user" - def test_validate_no_user_id_string_fails(self): + def test_validate_no_user_id_string_fails(self) -> None: apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'analyst': 'analyst@biz.com', - 'description': 'called user and verified account', + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "analyst": "analyst@biz.com", + "description": "called user and verified account", } + with self.assertRaises(TypeError): - self.sift_client._validate_apply_decision_request(apply_decision_request, 123) + self.sift_client._validate_apply_decision_request( + apply_decision_request, t.cast(str, 123) + ) - def test_apply_decision_to_order_fails_with_no_order_id(self): + def test_apply_decision_to_order_fails_with_no_order_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.apply_order_decision("user_id", None, {}) + self.sift_client.apply_order_decision( + "user_id", t.cast(str, None), {} + ) - def test_apply_decision_to_session_fails_with_no_session_id(self): + def test_apply_decision_to_session_fails_with_no_session_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.apply_session_decision("user_id", None, {}) + self.sift_client.apply_session_decision( + "user_id", t.cast(str, None), {} + ) - def test_get_session_decisions_fails_with_no_session_id(self): + def test_get_session_decisions_fails_with_no_session_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.get_session_decisions("user_id", None) + self.sift_client.get_session_decisions( + "user_id", t.cast(str, None) + ) - def test_apply_decision_to_content_fails_with_no_content_id(self): + def test_apply_decision_to_content_fails_with_no_content_id(self) -> None: with self.assertRaises(TypeError): - self.sift_client.apply_content_decision("user_id", None, {}) + self.sift_client.apply_content_decision( + "user_id", t.cast(str, None), {} + ) - def test_validate_apply_decision_request_no_analyst_fails(self): + def test_validate_apply_decision_request_no_analyst_fails(self) -> None: apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) - def test_validate_apply_decision_request_no_source_fails(self): + def test_validate_apply_decision_request_no_source_fails(self) -> None: apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) + + def test_validate_empty_apply_decision_request_fails(self) -> None: + apply_decision_request: dict[str, t.Any] = {} - def test_validate_empty_apply_decision_request_fails(self): - apply_decision_request = {} with self.assertRaises(ValueError): - self.sift_client._validate_apply_decision_request(apply_decision_request, "userId") + self.sift_client._validate_apply_decision_request( + apply_decision_request, "userId" + ) - def test_apply_decision_manual_review_no_analyst_fails(self): - user_id = '54321' + def test_apply_decision_manual_review_no_analyst_fails(self) -> None: + user_id = "54321" apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'MANUAL_REVIEW', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "source": "MANUAL_REVIEW", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision(user_id, apply_decision_request) + self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) - def test_apply_decision_no_source_fails(self): - user_id = '54321' + def test_apply_decision_no_source_fails(self) -> None: + user_id = "54321" apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "time": 1481569575, } with self.assertRaises(ValueError): - self.sift_client.apply_user_decision(user_id, apply_decision_request) + self.sift_client.apply_user_decision( + user_id, apply_decision_request + ) - def test_apply_decision_invalid_source_fails(self): - user_id = '54321' + def test_apply_decision_invalid_source_fails(self) -> None: + user_id = "54321" apply_decision_request = { - 'decision_id': 'user_looks_ok_legacy', - 'source': 'INVALID_SOURCE', - 'time': 1481569575 + "decision_id": "user_looks_ok_legacy", + "source": "INVALID_SOURCE", + "time": 1481569575, } - self.assertRaises(ValueError, self.sift_client.apply_user_decision, user_id, apply_decision_request) + self.assertRaises( + ValueError, + self.sift_client.apply_user_decision, + user_id, + apply_decision_request, + ) - def test_apply_decision_to_order_ok(self): - user_id = '54321' - order_id = '43210' + def test_apply_decision_to_order_ok(self) -> None: + user_id = "54321" + order_id = "43210" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'order_looks_bad_payment_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 + "decision_id": "order_looks_bad_payment_abuse", + "source": "AUTOMATED_RULE", + "time": 1481569575, } apply_decision_response_json = """ @@ -813,31 +914,39 @@ def test_apply_decision_to_order_ok(self): "time": "1481569575" } """ - mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_order_decision(user_id, order_id, apply_decision_request) - data = json.dumps(apply_decision_request) + + response = self.sift_client.apply_order_decision( + user_id, order_id, apply_decision_request + ) + mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/orders/%s/decisions' % (user_id, order_id), - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/orders/{order_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.http_status_code == 200) - assert (response.body['entity']['type'] == 'order') - - def test_apply_decision_to_session_ok(self): - user_id = '54321' - session_id = 'gigtleqddo84l8cm15qe4il' + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "order" + + def test_apply_decision_to_session_ok(self) -> None: + user_id = "54321" + session_id = "gigtleqddo84l8cm15qe4il" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'session_looks_bad_ato', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 + "decision_id": "session_looks_bad_ato", + "source": "AUTOMATED_RULE", + "time": 1481569575, } apply_decision_response_json = """ @@ -852,31 +961,39 @@ def test_apply_decision_to_session_ok(self): "time": "1481569575" } """ - mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_session_decision(user_id, session_id, apply_decision_request) - data = json.dumps(apply_decision_request) + + response = self.sift_client.apply_session_decision( + user_id, session_id, apply_decision_request + ) + mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/sessions/%s/decisions' % (user_id, session_id), - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/sessions/{session_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.http_status_code == 200) - assert (response.body['entity']['type'] == 'login') - - def test_apply_decision_to_content_ok(self): - user_id = '54321' - content_id = 'listing-1231' + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "login" + + def test_apply_decision_to_content_ok(self) -> None: + user_id = "54321" + content_id = "listing-1231" mock_response = mock.Mock() apply_decision_request = { - 'decision_id': 'content_looks_bad_content_abuse', - 'source': 'AUTOMATED_RULE', - 'time': 1481569575 + "decision_id": "content_looks_bad_content_abuse", + "source": "AUTOMATED_RULE", + "time": 1481569575, } apply_decision_response_json = """ @@ -891,247 +1008,290 @@ def test_apply_decision_to_content_ok(self): "time": "1481569575" } """ - mock_response.content = apply_decision_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.apply_content_decision(user_id, content_id, apply_decision_request) - data = json.dumps(apply_decision_request) + + response = self.sift_client.apply_content_decision( + user_id, content_id, apply_decision_request + ) + mock_post.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/%s/content/%s/decisions' % (user_id, content_id), - auth=mock.ANY, data=data, headers=mock.ANY, timeout=mock.ANY) + f"https://api.sift.com/v3/accounts/ACCT/users/{user_id}/content/{content_id}/decisions", + auth=mock.ANY, + data=json.dumps(apply_decision_request), + headers=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.http_status_code == 200) - assert (response.body['entity']['type'] == 'create_content') + assert response.is_ok() + assert response.http_status_code == 200 + assert isinstance(response.body, dict) + assert response.body["entity"]["type"] == "create_content" - def test_label_user_ok(self): - user_id = '54321' + def test_label_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.label(user_id, valid_label_properties()) + + response = self.sift_client.label( + user_id, valid_label_properties() + ) + properties = { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=mock.ANY, params={}) + f"https://api.sift.com/v205/users/{user_id}/labels", + data=json.dumps(properties), + headers=mock.ANY, + timeout=mock.ANY, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_label_user_with_timeout_param_ok(self): - user_id = '54321' + def test_label_user_with_timeout_param_ok(self) -> None: + user_id = "54321" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.label( - user_id, valid_label_properties(), test_timeout) + user_id, valid_label_properties(), test_timeout + ) + properties = { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=test_timeout, params={}) + f"https://api.sift.com/v205/users/{user_id}/labels", + data=json.dumps(properties), + headers=mock.ANY, + timeout=test_timeout, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_unlabel_user_ok(self): - user_id = '54321' + def test_unlabel_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response - response = self.sift_client.unlabel(user_id, abuse_type='account_abuse') + + response = self.sift_client.unlabel( + user_id, abuse_type="account_abuse" + ) + mock_delete.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % user_id, + f"https://api.sift.com/v205/users/{user_id}/labels", headers=mock.ANY, timeout=mock.ANY, - params={'abuse_type': 'account_abuse'}, - auth=HTTPBasicAuth(self.test_key, '')) + params={"abuse_type": "account_abuse"}, + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - - def test_unicode_string_parameter_support(self): - # str is unicode in python 3, so no need to check as this was covered - # by other unit tests. - if sys.version_info[0] < 3: - mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK"}' - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - - user_id = '23056' - - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - assert (self.sift_client.track( - '$transaction', - valid_transaction_properties())) - assert (self.sift_client.label( - user_id, - valid_label_properties())) - with mock.patch.object(self.sift_client.session, 'get') as mock_get: - mock_get.return_value = mock_response - assert (self.sift_client.score( - user_id, abuse_types=['payment_abuse', 'content_abuse'])) - - def test_unlabel_user_with_special_chars_ok(self): + assert response.is_ok() + + def test_unlabel_user_with_special_chars_ok(self) -> None: user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response + response = self.sift_client.unlabel(user_id) + mock_delete.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), + f"https://api.sift.com/v205/users/{_q(user_id)}/labels", headers=mock.ANY, timeout=mock.ANY, params={}, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) + assert response.is_ok() - def test_label_user__with_special_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_label_user__with_special_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.label( - user_id, valid_label_properties()) + user_id, valid_label_properties() + ) + properties = { - '$abuse_type': 'content_abuse', - '$is_bad': True, - '$description': 'Listed a fake item', - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$abuse_type": "content_abuse", + "$is_bad": True, + "$description": "Listed a fake item", + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/users/%s/labels' % urllib.parse.quote(user_id), - data=data, + f"https://api.sift.com/v205/users/{_q(user_id)}/labels", + data=json.dumps(properties), headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score__with_special_user_id_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_score__with_special_user_id_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score(user_id, abuse_types=['legacy']) + + response = self.sift_client.score(user_id, abuse_types=["legacy"]) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/%s' % urllib.parse.quote(user_id), - params={'abuse_types': 'legacy'}, + f"https://api.sift.com/v205/score/{_q(user_id)}", + params={"abuse_types": "legacy"}, headers=mock.ANY, timeout=mock.ANY, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['score'] == 0.85) - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - - def test_exception_during_track_call(self): + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_exception_during_track_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) + with self.assertRaises(sift.client.ApiException): - self.sift_client.track('$transaction', valid_transaction_properties()) + self.sift_client.track( + "$transaction", valid_transaction_properties() + ) - def test_exception_during_score_call(self): + def test_exception_during_score_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) + with self.assertRaises(sift.client.ApiException): - self.sift_client.score('Fred') + self.sift_client.score("Fred") - def test_exception_during_unlabel_call(self): + def test_exception_during_unlabel_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) + with self.assertRaises(sift.client.ApiException): - self.sift_client.unlabel('Fred') + self.sift_client.unlabel("Fred") - def test_return_actions_on_track(self): - event = '$transaction' + def test_return_actions_on_track(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % action_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {action_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( - event, valid_transaction_properties(), return_action=True) + event, valid_transaction_properties(), return_action=True + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_action': 'true'}) + params={"return_action": "true"}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") - - actions = response.body["score_response"]['actions'] - assert (actions) - assert (actions[0]['action']) - assert (actions[0]['action']['id'] == 'freds_action') - assert (actions[0]['triggers']) - - def test_get_workflow_status(self): + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + + actions = response.body["score_response"]["actions"] + assert actions + assert actions[0]["action"] + assert actions[0]["action"]["id"] == "freds_action" + assert actions[0]["triggers"] + + def test_get_workflow_status(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1177,19 +1337,25 @@ def test_get_workflow_status(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_workflow_status('4zxwibludiaaa', timeout=3) - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa', - headers=mock.ANY, auth=mock.ANY, timeout=3) + response = self.sift_client.get_workflow_status( + "4zxwibludiaaa", timeout=3 + ) + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/workflows/runs/4zxwibludiaaa", + headers=mock.ANY, + auth=mock.ANY, + timeout=3, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.body['state'] == 'running') + assert response.is_ok() + assert isinstance(response.body, dict) + assert response.body["state"] == "running" - def test_get_user_decisions(self): + def test_get_user_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1208,19 +1374,26 @@ def test_get_user_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_user_decisions('example_user') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_user_decisions("example_user") + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/users/example_user/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.body['decisions']['payment_abuse']['decision']['id'] == 'user_decision') - - def test_get_order_decisions(self): + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["payment_abuse"]["decision"]["id"] + == "user_decision" + ) + + def test_get_order_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1246,20 +1419,30 @@ def test_get_order_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_order_decisions('example_order') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/orders/example_order/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_order_decisions("example_order") + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/orders/example_order/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.body['decisions']['payment_abuse']['decision']['id'] == 'decision7') - assert (response.body['decisions']['promotion_abuse']['decision']['id'] == 'good_order') - - def test_get_session_decisions(self): + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["payment_abuse"]["decision"]["id"] + == "decision7" + ) + assert ( + response.body["decisions"]["promotion_abuse"]["decision"]["id"] + == "good_order" + ) + + def test_get_session_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1278,19 +1461,30 @@ def test_get_session_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_session_decisions('example_user', 'example_session') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_session_decisions( + "example_user", "example_session" + ) + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/users/example_user/sessions/example_session/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.body['decisions']['account_takeover']['decision']['id'] == 'session_decision') + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["account_takeover"]["decision"][ + "id" + ] + == "session_decision" + ) - def test_get_content_decisions(self): + def test_get_content_decisions(self) -> None: mock_response = mock.Mock() mock_response.content = """ { @@ -1309,21 +1503,34 @@ def test_get_content_decisions(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_content_decisions('example_user', 'example_content') - mock_get.assert_called_with( - 'https://api3.siftscience.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions', - headers=mock.ANY, auth=mock.ANY, timeout=mock.ANY) + response = self.sift_client.get_content_decisions( + "example_user", "example_content" + ) + mock_get.assert_called_with( + "https://api.sift.com/v3/accounts/ACCT/users/example_user/content/example_content/decisions", + headers=mock.ANY, + auth=mock.ANY, + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.body['decisions']['content_abuse']['decision']['id'] == 'content_looks_bad_content_abuse') - - def test_provided_session(self): + assert response.is_ok() + assert isinstance(response.body, dict) + assert ( + response.body["decisions"]["content_abuse"]["decision"]["id"] + == "content_looks_bad_content_abuse" + ) + + def test_provided_session(self) -> None: session = mock.Mock() - client = sift.Client(api_key=self.test_key, account_id=self.account_id, session=session) + client = sift.Client( + api_key=self.test_key, + account_id=self.account_id, + session=session, + ) mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' @@ -1331,12 +1538,13 @@ def test_provided_session(self): mock_response.status_code = 200 mock_response.headers = response_with_data_header() session.post.return_value = mock_response + event = "$transaction" - event = '$transaction' client.track(event, valid_transaction_properties()) + session.post.assert_called_once() - def test_get_psp_merchant_profile(self): + def test_get_psp_merchant_profile(self) -> None: """Test the GET /{version}/accounts/{accountId}/scorepsp_management/merchants?batch_type=...""" test_timeout = 5 mock_response = mock.Mock() @@ -1344,149 +1552,192 @@ def test_get_psp_merchant_profile(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_post: + + with mock.patch.object(self.sift_client.session, "get") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.get_psp_merchant_profiles( - timeout=test_timeout) + timeout=test_timeout + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants', + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants", params={}, - headers=mock.ANY, auth=mock.ANY, - timeout=test_timeout) + headers=mock.ANY, + auth=mock.ANY, + timeout=test_timeout, + ) self.assertIsInstance(response, sift.client.Response) - assert ('address' in response.body) + assert isinstance(response.body, dict) + assert "address" in response.body - def test_get_psp_merchant_profile_id(self): - """Test the GET /{version}/accounts/{accountId}/scorepsp_management/merchants/{merchantId} - """ + def test_get_psp_merchant_profile_id(self) -> None: + """Test the GET /{version}/accounts/{accountId}/scorepsp_management/merchants/{merchantId}""" test_timeout = 5 mock_response = mock.Mock() mock_response.content = valid_psp_merchant_properties_response() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_post: + + with mock.patch.object(self.sift_client.session, "get") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.get_a_psp_merchant_profile( - merchant_id='api-key-1', timeout=test_timeout) + merchant_id="api-key-1", timeout=test_timeout + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants/api-key-1', + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants/api-key-1", headers=mock.ANY, auth=mock.ANY, - timeout=test_timeout) + timeout=test_timeout, + ) self.assertIsInstance(response, sift.client.Response) - assert ('address' in response.body) + assert isinstance(response.body, dict) + assert "address" in response.body - def test_create_psp_merchant_profile(self): + def test_create_psp_merchant_profile(self) -> None: mock_response = mock.Mock() mock_response.content = valid_psp_merchant_properties_response() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response response = self.sift_client.create_psp_merchant_profile( - valid_psp_merchant_properties()) + valid_psp_merchant_properties() + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants', + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants", data=json.dumps(valid_psp_merchant_properties()), headers=mock.ANY, auth=mock.ANY, - timeout=mock.ANY) - + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert ('address' in response.body) + assert isinstance(response.body, dict) + assert "address" in response.body - def test_update_psp_merchant_profile(self): + def test_update_psp_merchant_profile(self) -> None: mock_response = mock.Mock() mock_response.content = valid_psp_merchant_properties_response() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'put') as mock_post: + with mock.patch.object(self.sift_client.session, "put") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.update_psp_merchant_profile('api-key-1', - valid_psp_merchant_properties()) + response = self.sift_client.update_psp_merchant_profile( + "api-key-1", valid_psp_merchant_properties() + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v3/accounts/ACCT/psp_management/merchants/api-key-1', + "https://api.sift.com/v3/accounts/ACCT/psp_management/merchants/api-key-1", data=json.dumps(valid_psp_merchant_properties()), headers=mock.ANY, auth=mock.ANY, - timeout=mock.ANY) - + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert ('address' in response.body) + assert isinstance(response.body, dict) + assert "address" in response.body - def test_with_include_score_percentiles_ok(self): - event = '$transaction' + def test_with_include_score_percentiles_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties(), include_score_percentiles=True) + + response = self.sift_client.track( + event, + valid_transaction_properties(), + include_score_percentiles=True, + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'fields': 'SCORE_PERCENTILES'}) + params={"fields": "SCORE_PERCENTILES"}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_include_score_percentiles_as_false_ok(self): - event = '$transaction' + def test_include_score_percentiles_as_false_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties(), include_score_percentiles=False) + + response = self.sift_client.track( + event, + valid_transaction_properties(), + include_score_percentiles=False, + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_status == 0) - assert (response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score_api_include_score_percentiles_ok(self): + def test_score_api_include_score_percentiles_ok(self) -> None: mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score(user_id='12345', include_score_percentiles=True) + + response = self.sift_client.score( + user_id="12345", include_score_percentiles=True + ) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/score/12345', - params={'fields': 'SCORE_PERCENTILES'}, + "https://api.sift.com/v205/score/12345", + params={"fields": "SCORE_PERCENTILES"}, headers=mock.ANY, timeout=mock.ANY, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['score'] == 0.85) - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - - def test_get_user_score_include_score_percentiles_ok(self): - """Test the GET /{version}/users/{userId}/score API, i.e. client.get_user_score() + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.85 + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + + def test_get_user_score_include_score_percentiles_ok(self) -> None: + """ + Test the GET /{version}/users/{userId}/score API, + i.e. client.get_user_score() """ test_timeout = 5 mock_response = mock.Mock() @@ -1494,64 +1745,85 @@ def test_get_user_score_include_score_percentiles_ok(self): mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.get_user_score(user_id='12345', timeout=test_timeout, include_score_percentiles=True) + + response = self.sift_client.get_user_score( + user_id="12345", + timeout=test_timeout, + include_score_percentiles=True, + ) + mock_get.assert_called_with( - 'https://api.siftscience.com/v205/users/12345/score', - params={'fields': 'SCORE_PERCENTILES'}, + "https://api.sift.com/v205/users/12345/score", + params={"fields": "SCORE_PERCENTILES"}, headers=mock.ANY, timeout=test_timeout, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert (response.is_ok()) - assert (response.api_error_message == "OK") - assert (response.body['entity_id'] == '12345') - assert (response.body['scores']['content_abuse']['score'] == 0.14) - assert (response.body['scores']['payment_abuse']['score'] == 0.97) - assert ('latest_decisions' in response.body) - - def test_warnings_added_as_fields_param(self): - event = '$transaction' + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["entity_id"] == "12345" + assert response.body["scores"]["content_abuse"]["score"] == 0.14 + assert response.body["scores"]["payment_abuse"]["score"] == 0.97 + assert "latest_decisions" in response.body + + def test_warnings_added_as_fields_param(self) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties(), - include_warnings=True) + + response = self.sift_client.track( + event, valid_transaction_properties(), include_warnings=True + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'fields': 'WARNINGS'}) + params={"fields": "WARNINGS"}, + ) self.assertIsInstance(response, sift.client.Response) - def test_warnings_and_score_percentiles_added_as_fields_param(self): - event = '$transaction' + def test_warnings_and_score_percentiles_added_as_fields_param( + self, + ) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties(), - include_score_percentiles=True, - include_warnings=True) + response = self.sift_client.track( + event, + valid_transaction_properties(), + include_score_percentiles=True, + include_warnings=True, + ) mock_post.assert_called_with( - 'https://api.siftscience.com/v205/events', + "https://api.sift.com/v205/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'fields': 'SCORE_PERCENTILES,WARNINGS'}) + params={"fields": "SCORE_PERCENTILES,WARNINGS"}, + ) self.assertIsInstance(response, sift.client.Response) -def main(): - unittest.main() +def main() -> None: + main() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_client_v203.py b/tests/test_client_v203.py index 606d741..24a4f3c 100644 --- a/tests/test_client_v203.py +++ b/tests/test_client_v203.py @@ -1,50 +1,51 @@ +from __future__ import annotations + import datetime -from decimal import Decimal -import warnings import json -import mock -import sift +import typing as t import unittest -import sys -import requests.exceptions +import warnings +from decimal import Decimal +from unittest import mock + from requests.auth import HTTPBasicAuth -if sys.version_info[0] < 3: - import six.moves.urllib as urllib -else: - import urllib.parse +from requests.exceptions import RequestException + +import sift +from sift.utils import quote_path as _q -def valid_transaction_properties(): +def valid_transaction_properties() -> dict[str, t.Any]: return { - '$buyer_user_id': '123456', - '$seller_user_id': '654321', - '$amount': Decimal('1253200.0'), - '$currency_code': 'USD', - '$time': int(datetime.datetime.now().strftime('%S')), - '$transaction_id': 'my_transaction_id', - '$billing_name': 'Mike Snow', - '$billing_bin': '411111', - '$billing_last4': '1111', - '$billing_address1': '123 Main St.', - '$billing_city': 'San Francisco', - '$billing_region': 'CA', - '$billing_country': 'US', - '$billing_zip': '94131', - '$user_email': 'mike@example.com' + "$buyer_user_id": "123456", + "$seller_user_id": "654321", + "$amount": Decimal("1253200.0"), + "$currency_code": "USD", + "$time": int(datetime.datetime.now().strftime("%S")), + "$transaction_id": "my_transaction_id", + "$billing_name": "Mike Snow", + "$billing_bin": "411111", + "$billing_last4": "1111", + "$billing_address1": "123 Main St.", + "$billing_city": "San Francisco", + "$billing_region": "CA", + "$billing_country": "US", + "$billing_zip": "94131", + "$user_email": "mike@example.com", } -def valid_label_properties(): +def valid_label_properties() -> dict[str, t.Any]: return { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", } -def score_response_json(): +def score_response_json() -> str: return """{ "status": 0, "error_message": "OK", @@ -53,7 +54,7 @@ def score_response_json(): }""" -def action_response_json(): +def action_response_json() -> str: return """{ "actions": [ { @@ -82,371 +83,447 @@ def action_response_json(): }""" -def response_with_data_header(): +def response_with_data_header() -> dict[str, t.Any]: return { - 'content-length': 1, # Simply has to be > 0 - 'content-type': 'application/json; charset=UTF-8' + "content-length": 1, # Simply has to be > 0 + "content-type": "application/json; charset=UTF-8", } class TestSiftPythonClient(unittest.TestCase): - def setUp(self): - self.test_key = 'a_fake_test_api_key' - self.sift_client = sift.Client(self.test_key, version='203') - self.sift_client_v204 = sift.Client(self.test_key) + def setUp(self) -> None: + self.test_key = "a_fake_test_api_key" + self.sift_client = sift.Client(api_key=self.test_key, version="203") + self.sift_client_v204 = sift.Client(api_key=self.test_key) - def test_track_requires_valid_event(self): + def test_track_requires_valid_event(self) -> None: self.assertRaises(TypeError, self.sift_client.track, None, {}) - self.assertRaises(ValueError, self.sift_client.track, '', {}) - self.assertRaises(TypeError, self.sift_client_v204.track, 42, {'version': '203'}) + self.assertRaises(ValueError, self.sift_client.track, "", {}) + self.assertRaises( + TypeError, self.sift_client_v204.track, 42, {"version": "203"} + ) - def test_track_requires_properties(self): - event = 'custom_event' + def test_track_requires_properties(self) -> None: + event = "custom_event" self.assertRaises(TypeError, self.sift_client.track, event, None, {}) - self.assertRaises(TypeError, self.sift_client_v204.track, event, 42, {'version': '203'}) + self.assertRaises( + TypeError, + self.sift_client_v204.track, + event, + 42, + {"version": "203"}, + ) self.assertRaises(ValueError, self.sift_client.track, event, {}) - def test_score_requires_user_id(self): - self.assertRaises(TypeError, self.sift_client_v204.score, None, {'version': '203'}) - self.assertRaises(ValueError, self.sift_client.score, '', {}) + def test_score_requires_user_id(self) -> None: + self.assertRaises( + TypeError, self.sift_client_v204.score, None, {"version": "203"} + ) + self.assertRaises(ValueError, self.sift_client.score, "", {}) self.assertRaises(TypeError, self.sift_client.score, 42, {}) - def test_event_ok(self): - event = '$transaction' + def test_event_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.track(event, valid_transaction_properties()) + + response = self.sift_client.track( + event, valid_transaction_properties() + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_event_with_timeout_param_ok(self): - event = '$transaction' + def test_event_with_timeout_param_ok(self) -> None: + event = "$transaction" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client_v204.session, 'post') as mock_post: + + with mock.patch.object( + self.sift_client_v204.session, "post" + ) as mock_post: mock_post.return_value = mock_response + response = self.sift_client_v204.track( - event, valid_transaction_properties(), timeout=test_timeout, version='203') + event, + valid_transaction_properties(), + timeout=test_timeout, + version="203", + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=test_timeout, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score_ok(self): + def test_score_ok(self) -> None: mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client_v204.session, 'get') as mock_get: + + with mock.patch.object( + self.sift_client_v204.session, "get" + ) as mock_get: mock_get.return_value = mock_response - response = self.sift_client_v204.score('12345', version='203') + + response = self.sift_client_v204.score("12345", version="203") + mock_get.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', + "https://api.sift.com/v203/score/12345", params={}, headers=mock.ANY, timeout=mock.ANY, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.55 - def test_score_with_timeout_param_ok(self): + def test_score_with_timeout_param_ok(self) -> None: test_timeout = 5 mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response - response = self.sift_client.score('12345', test_timeout) + + response = self.sift_client.score("12345", test_timeout) + mock_get.assert_called_with( - 'https://api.siftscience.com/v203/score/12345', + "https://api.sift.com/v203/score/12345", params={}, headers=mock.ANY, timeout=test_timeout, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.55 - def test_sync_score_ok(self): - event = '$transaction' + def test_sync_score_ok(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % score_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {score_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.track( - event, valid_transaction_properties(), return_score=True) + event, valid_transaction_properties(), return_score=True + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_score': 'true'}) + params={"return_score": "true"}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - assert(response.body["score_response"]['score'] == 0.55) - - def test_label_user_ok(self): - user_id = '54321' + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score_response"]["score"] == 0.55 + + def test_label_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.label(user_id, valid_label_properties()) + + response = self.sift_client.label( + user_id, valid_label_properties() + ) + properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) + properties.update({"$api_key": self.test_key, "$type": "$label"}) data = json.dumps(properties) mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=mock.ANY, params={}) + f"https://api.sift.com/v203/users/{user_id}/labels", + data=data, + headers=mock.ANY, + timeout=mock.ANY, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_label_user_with_timeout_param_ok(self): - user_id = '54321' + def test_label_user_with_timeout_param_ok(self) -> None: + user_id = "54321" test_timeout = 5 mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client_v204.session, 'post') as mock_post: + + with mock.patch.object( + self.sift_client_v204.session, "post" + ) as mock_post: mock_post.return_value = mock_response + response = self.sift_client_v204.label( - user_id, valid_label_properties(), test_timeout, version='203') + user_id, valid_label_properties(), test_timeout, version="203" + ) + properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % user_id, - data=data, headers=mock.ANY, timeout=test_timeout, params={}) + f"https://api.sift.com/v203/users/{user_id}/labels", + data=json.dumps(properties), + headers=mock.ANY, + timeout=test_timeout, + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_unlabel_user_ok(self): - user_id = '54321' + def test_unlabel_user_ok(self) -> None: + user_id = "54321" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response + response = self.sift_client.unlabel(user_id) + mock_delete.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % user_id, + f"https://api.sift.com/v203/users/{user_id}/labels", headers=mock.ANY, timeout=mock.ANY, params={}, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - - def test_unicode_string_parameter_support(self): - # str is unicode in python 3, so no need to check as this was covered - # by other unit tests. - if sys.version_info[0] < 3: - mock_response = mock.Mock() - mock_response.content = '{"status": 0, "error_message": "OK"}' - mock_response.json.return_value = json.loads(mock_response.content) - mock_response.status_code = 200 - mock_response.headers = response_with_data_header() - - user_id = '23056' - - with mock.patch.object(self.sift_client.session, 'post') as mock_post: - mock_post.return_value = mock_response - assert( - self.sift_client.track( - '$transaction', - valid_transaction_properties())) - assert( - self.sift_client.label( - user_id, - valid_label_properties())) - with mock.patch.object(self.sift_client.session, 'get') as mock_get: - mock_get.return_value = mock_response - assert(self.sift_client.score(user_id)) - - def test_unlabel_user_with_special_chars_ok(self): + assert response.is_ok() + + def test_unlabel_user_with_special_chars_ok(self) -> None: user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.status_code = 204 - with mock.patch.object(self.sift_client_v204.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client_v204.session, "delete" + ) as mock_delete: mock_delete.return_value = mock_response - response = self.sift_client_v204.unlabel(user_id, version='203') + response = self.sift_client_v204.unlabel(user_id, version="203") + mock_delete.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % urllib.parse.quote(user_id), + f"https://api.sift.com/v203/users/{_q(user_id)}/labels", headers=mock.ANY, timeout=mock.ANY, params={}, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) + self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) + assert response.is_ok() - def test_label_user__with_special_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_label_user__with_special_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = '{"status": 0, "error_message": "OK"}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response + response = self.sift_client.label( - user_id, valid_label_properties()) + user_id, valid_label_properties() + ) + properties = { - '$description': 'Listed a fake item', - '$is_bad': True, - '$reasons': ["$fake"], - '$source': 'Internal Review Queue', - '$analyst': 'super.sleuth@example.com' + "$description": "Listed a fake item", + "$is_bad": True, + "$reasons": ["$fake"], + "$source": "Internal Review Queue", + "$analyst": "super.sleuth@example.com", + "$api_key": self.test_key, + "$type": "$label", } - properties.update({'$api_key': self.test_key, '$type': '$label'}) - data = json.dumps(properties) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/users/%s/labels' % urllib.parse.quote(user_id), - data=data, + f"https://api.sift.com/v203/users/{_q(user_id)}/labels", + data=json.dumps(properties), headers=mock.ANY, timeout=mock.ANY, - params={}) + params={}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_score__with_special_user_id_chars_ok(self): - user_id = '54321=.-_+@:&^%!$' + def test_score__with_special_user_id_chars_ok(self) -> None: + user_id = "54321=.-_+@:&^%!$" mock_response = mock.Mock() mock_response.content = score_response_json() mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.return_value = mock_response + response = self.sift_client.score(user_id) + mock_get.assert_called_with( - 'https://api.siftscience.com/v203/score/%s' % urllib.parse.quote(user_id), + f"https://api.sift.com/v203/score/{_q(user_id)}", params={}, headers=mock.ANY, timeout=mock.ANY, - auth=HTTPBasicAuth(self.test_key, '')) + auth=HTTPBasicAuth(self.test_key, ""), + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_error_message == "OK") - assert(response.body['score'] == 0.55) + assert response.is_ok() + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) + assert response.body["score"] == 0.55 - def test_exception_during_track_call(self): + def test_exception_during_track_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) self.assertRaises( - sift.client.ApiException, self.sift_client.track, - '$transaction', valid_transaction_properties()) + sift.client.ApiException, + self.sift_client.track, + "$transaction", + valid_transaction_properties(), + ) - def test_exception_during_score_call(self): + def test_exception_during_score_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'get') as mock_get: + + with mock.patch.object(self.sift_client.session, "get") as mock_get: mock_get.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) self.assertRaises( - sift.client.ApiException, self.sift_client.score, 'Fred') + sift.client.ApiException, self.sift_client.score, "Fred" + ) - def test_exception_during_unlabel_call(self): + def test_exception_during_unlabel_call(self) -> None: warnings.simplefilter("always") - with mock.patch.object(self.sift_client.session, 'delete') as mock_delete: + + with mock.patch.object( + self.sift_client.session, "delete" + ) as mock_delete: mock_delete.side_effect = mock.Mock( - side_effect=requests.exceptions.RequestException("Failed")) + side_effect=RequestException("Failed") + ) self.assertRaises( - sift.client.ApiException, self.sift_client.unlabel, 'Fred') + sift.client.ApiException, self.sift_client.unlabel, "Fred" + ) - def test_return_actions_on_track(self): - event = '$transaction' + def test_return_actions_on_track(self) -> None: + event = "$transaction" mock_response = mock.Mock() - mock_response.content = ('{"status": 0, "error_message": "OK", "score_response": %s}' - % action_response_json()) + mock_response.content = f'{{"status": 0, "error_message": "OK", "score_response": {action_response_json()}}}' mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() - with mock.patch.object(self.sift_client.session, 'post') as mock_post: + with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response response = self.sift_client.track( - event, valid_transaction_properties(), return_action=True) + event, valid_transaction_properties(), return_action=True + ) + mock_post.assert_called_with( - 'https://api.siftscience.com/v203/events', + "https://api.sift.com/v203/events", data=mock.ANY, headers=mock.ANY, timeout=mock.ANY, - params={'return_action': 'true'}) + params={"return_action": "true"}, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + assert isinstance(response.body, dict) - actions = response.body["score_response"]['actions'] - assert(actions) - assert(actions[0]['action']) - assert(actions[0]['action']['id'] == 'freds_action') - assert(actions[0]['triggers']) + actions = response.body["score_response"]["actions"] + assert actions + assert actions[0]["action"] + assert actions[0]["action"]["id"] == "freds_action" + assert actions[0]["triggers"] -def main(): +def main() -> None: unittest.main() -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/tests/test_verification_apis.py b/tests/test_verification_apis.py index af56011..ad799b6 100644 --- a/tests/test_verification_apis.py +++ b/tests/test_verification_apis.py @@ -1,14 +1,13 @@ -from decimal import Decimal -import unittest -import warnings +from __future__ import annotations + import json -import mock +import typing as t +from unittest import TestCase, mock + import sift -import sys -import requests.exceptions -def valid_verification_send_properties(): +def valid_verification_send_properties() -> dict[str, t.Any]: return { "$user_id": "billy_jones_301", "$send_to": "billy_jones_301@gmail.com", @@ -24,39 +23,43 @@ def valid_verification_send_properties(): "$ip": "192.168.1.1", "$browser": { "$user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" - } + }, + }, } -} -def valid_verification_resend_properties(): + +def valid_verification_resend_properties() -> dict[str, t.Any]: return { "$user_id": "billy_jones_301", "$verified_event": "$login", - "$verified_entity_id": "SOME_SESSION_ID" + "$verified_entity_id": "SOME_SESSION_ID", } -def valid_verification_check_properties(): + +def valid_verification_check_properties() -> dict[str, t.Any]: return { "$user_id": "billy_jones_301", "$code": "123456", "$verified_event": "$login", - "$verified_entity_id": "SOME_SESSION_ID" + "$verified_entity_id": "SOME_SESSION_ID", } -def response_with_data_header(): + +def response_with_data_header() -> dict[str, t.Any]: return { "content-length": 1, - "content-type": "application/json; charset=UTF-8" + "content-type": "application/json; charset=UTF-8", } -class TestVerificationAPI(unittest.TestCase): - def setUp(self): + +class TestVerificationAPI(TestCase): + def setUp(self) -> None: self.test_key = "a_fake_test_api_key" self.sift_client = sift.Client(self.test_key) - def test_verification_send_ok(self): + def test_verification_send_ok(self) -> None: mock_response = mock.Mock() - + send_response_json = """ { "status": 0, @@ -70,29 +73,32 @@ def test_verification_send_ok(self): "http_status_code": 200 } """ - + mock_response.content = send_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.verification_send(valid_verification_send_properties()) + response = self.sift_client.verification_send( + valid_verification_send_properties() + ) data = json.dumps(valid_verification_send_properties()) mock_post.assert_called_with( "https://api.sift.com/v1/verification/send", auth=mock.ANY, data=data, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" - def test_verification_resend_ok(self): + def test_verification_resend_ok(self) -> None: mock_response = mock.Mock() - + resend_response_json = """ { "status": 0, @@ -106,29 +112,32 @@ def test_verification_resend_ok(self): "http_status_code": 200 } """ - + mock_response.content = resend_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.verification_resend(valid_verification_resend_properties()) + response = self.sift_client.verification_resend( + valid_verification_resend_properties() + ) data = json.dumps(valid_verification_resend_properties()) mock_post.assert_called_with( "https://api.sift.com/v1/verification/resend", auth=mock.ANY, data=data, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") - - def test_verification_check_ok(self): + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + + def test_verification_check_ok(self) -> None: mock_response = mock.Mock() - + check_response_json = """ { "status": 0, @@ -137,28 +146,33 @@ def test_verification_check_ok(self): "http_status_code": 200 } """ - + mock_response.content = check_response_json mock_response.json.return_value = json.loads(mock_response.content) mock_response.status_code = 200 mock_response.headers = response_with_data_header() with mock.patch.object(self.sift_client.session, "post") as mock_post: mock_post.return_value = mock_response - response = self.sift_client.verification_check(valid_verification_check_properties()) + response = self.sift_client.verification_check( + valid_verification_check_properties() + ) data = json.dumps(valid_verification_check_properties()) mock_post.assert_called_with( "https://api.sift.com/v1/verification/check", auth=mock.ANY, data=data, headers=mock.ANY, - timeout=mock.ANY) + timeout=mock.ANY, + ) self.assertIsInstance(response, sift.client.Response) - assert(response.is_ok()) - assert(response.api_status == 0) - assert(response.api_error_message == "OK") + assert response.is_ok() + assert response.api_status == 0 + assert response.api_error_message == "OK" + + +def main() -> None: + main() -def main(): - unittest.main() if __name__ == "__main__": main() From af74d5a525f3c54ec72e9a1f708bd2bf313fef85 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Tue, 25 Mar 2025 15:07:01 +0100 Subject: [PATCH 097/112] Minor fixes and remove TODO --- .flake8 | 2 +- .github/workflows/publishing2PyPI.yml | 2 +- sift/__init__.py | 6 ++++-- sift/client.py | 16 ++++------------ sift/version.py | 2 +- tests/test_client.py | 7 +++++-- 6 files changed, 16 insertions(+), 19 deletions(-) diff --git a/.flake8 b/.flake8 index 321faca..ef6c702 100644 --- a/.flake8 +++ b/.flake8 @@ -2,4 +2,4 @@ ignore = E501,W503 per-file-ignores = __init__.py:F401 max-line-length = 79 -disable-noqa = true \ No newline at end of file +disable-noqa = true diff --git a/.github/workflows/publishing2PyPI.yml b/.github/workflows/publishing2PyPI.yml index b9b2c83..0b63ba4 100644 --- a/.github/workflows/publishing2PyPI.yml +++ b/.github/workflows/publishing2PyPI.yml @@ -18,7 +18,7 @@ jobs: VERSION=$(cat ./sift/version.py | grep -E -i '^VERSION.*' | cut -d'=' -f2 | cut -d\' -f2) [[ $VERSION == "NOT_SET" ]] && echo "Version in version.py NOT_SET" && exit 1 echo "curr_version=$(echo $VERSION)" >> $GITHUB_ENV - - name: Compare package version and Releas tag + - name: Compare package version and Release tag run: | TAG=${GITHUB_REF##*/} if [[ $TAG != *"$curr_version"* ]]; then diff --git a/sift/__init__.py b/sift/__init__.py index e9ad03f..da4b03f 100644 --- a/sift/__init__.py +++ b/sift/__init__.py @@ -1,9 +1,11 @@ from __future__ import annotations +import os + from .client import Client from .version import VERSION __version__ = VERSION -api_key: str | None = None -account_id: str | None = None +api_key: str | None = os.environ.get("API_KEY") +account_id: str | None = os.environ.get("ACCOUNT_ID") diff --git a/sift/client.py b/sift/client.py index 8b9c85b..88a4c0d 100644 --- a/sift/client.py +++ b/sift/client.py @@ -24,7 +24,6 @@ "content_abuse", "legacy", "payment_abuse", - # TODO: Ask which of the following is supported (?) "promo_abuse", "promotion_abuse", ] @@ -126,16 +125,11 @@ class Client: account_id: str def __init__( - self, # TODO: Require to pass all arguments as a keyword arguments (?) + self, api_key: str | None = None, api_url: str = API_URL, - timeout: ( - int - | float - | tuple[int | float, int | float] - | tuple[int | float, int | float] - ) = 2, - account_id: str | None = None, # TODO: Move as a second argument (?) + timeout: int | float | tuple[int | float, int | float] = 2, + account_id: str | None = None, version: str = API_VERSION, session: requests.Session | None = None, ) -> None: @@ -887,9 +881,7 @@ def get_decisions( entity_type: t.Literal["user", "order", "session", "content"], limit: int | None = None, start_from: int | None = None, - abuse_types: ( - str | None - ) = None, # TODO: Ask if here should be a Sequence[AbuseType] instead of str + abuse_types: str | None = None, timeout: int | float | tuple[int | float, int | float] | None = None, ) -> Response: """Get decisions available to the customer diff --git a/sift/version.py b/sift/version.py index e85c97b..ad368aa 100644 --- a/sift/version.py +++ b/sift/version.py @@ -1,2 +1,2 @@ -VERSION = "5.6.1" +VERSION = "6.0.0" API_VERSION = "205" diff --git a/tests/test_client.py b/tests/test_client.py index fb38a53..aad819a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -239,7 +239,9 @@ def setUp(self) -> None: def test_global_api_key(self) -> None: # test for error if global key is undefined - self.assertRaises(TypeError, sift.Client) + with mock.patch("sift.api_key"): + self.assertRaises(TypeError, sift.Client) + sift.api_key = "a_test_global_api_key" local_api_key = "a_test_local_api_key" @@ -255,7 +257,8 @@ def test_global_api_key(self) -> None: # test that client2 is assigned a new object with global api_key assert client2.api_key == sift.api_key - def test_constructor_requires_valid_api_key(self) -> None: + @mock.patch("sift.api_key", return_value=None) + def test_constructor_requires_valid_api_key(self, _) -> None: self.assertRaises(TypeError, sift.Client, None) self.assertRaises(ValueError, sift.Client, "") From a9ba7a6b9e581d4129345ef5807376d839b2cf65 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Tue, 25 Mar 2025 15:08:10 +0100 Subject: [PATCH 098/112] Update documentation --- CHANGES.md | 7 ++-- CONTRIBUTING.md | 68 ++++++++++++++++++++++++++++++++ README.md | 103 +++++++++++++++--------------------------------- 3 files changed, 104 insertions(+), 74 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 349c7a0..0ef41ce 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,11 @@ -6.0.0 Unreleased +6.0.0 (Not released yet) ================ -- Support for Python 3.13 + +- Added support for Python 3.13 INCOMPATIBLE CHANGES INTRODUCED IN 6.0.0: -- Removed support for Python < 3.8 +- Dropped support for Python < 3.8 5.6.1 2024-10-08 - Updated implementation to use Basic Authentication instead of passing `API_KEY` as a request parameter for the following calls: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e69de29..38ffd0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ + +## Setting up the environment + +1. Install [pyenv](https://github.com/pyenv/pyenv?tab=readme-ov-file#installation) +2. Setup virtual environment + +```sh +# install necessary Python version +pyenv install 3.13.2 + +# create a virtual enviroment +pyenv virtualenv 3.13.2 v3.13 +pyenv activate v3.13 +``` + +3. Upgrade pip + +```sh +pip install -U pip +``` + +4. Install pre-commit + +```sh +pip install -U pre-commit +``` + +5. Install the library: + +```sh +pip install -e . +``` + +## Testing + +Before submitting a change, make sure the following commands run without +errors from the root folder of the repository: + +```sh +python -m unittest discover +``` + +## Integration testing app + +For testing the app with real calls it is possible to run the integration testing app, +it makes calls to almost all Sift public API endpoints to make sure the library integrates +well. At the moment, the app is run on every merge to master + +#### How to run it locally + +1. Add env variable `API_KEY` with the valid Api Key associated from the account + +```sh +export API_KEY="api_key" +``` + +1. Add env variable `ACCOUNT_ID` with the valid account id + +```sh +export ACCOUNT_ID="account_id" +``` + +3. Run the following under the project root folder + +```sh +# run the app +python test_integration_app/main.py +``` diff --git a/README.md b/README.md index 288a8df..aae5a89 100644 --- a/README.md +++ b/README.md @@ -10,36 +10,14 @@ APIs. ## Installation -Set up a virtual environment with virtualenv (otherwise you will need -to make the pip calls as sudo): - - virtualenv venv - source venv/bin/activate - -Get the latest released package from pip: - -Python 2: - - pip install Sift - -Python 3: - - pip3 install Sift - -or install newest source directly from GitHub: - -Python 2: - - pip install git+https://github.com/SiftScience/sift-python - -Python 3: - - pip3 install git+https://github.com/SiftScience/sift-python - +```sh +# install from PyPi +pip install Sift +``` ## Documentation -Please see [here](https://sift.com/developers/docs/python/events-api/overview) for the +Please see [here](https://developers.sift.com/docs/python/apis-overview) for the most up-to-date documentation. ## Changelog @@ -59,8 +37,7 @@ Here's an example: ```python -import json -import sift.client +import sift client = sift.Client(api_key='', account_id='') @@ -85,12 +62,17 @@ properties = { } try: - response = client.track("$transaction", properties) - if response.is_ok(): - print "Successfully tracked event" + response = client.track( + "$transaction", + properties, + ) except sift.client.ApiException: # request failed pass +else: + if response.is_ok(): + print("Successfully tracked event") + # Track a transaсtion event and receive a score with percentiles in response (sync flow). # Note: `return_score` or `return_workflow_status` must be set `True`. @@ -111,15 +93,24 @@ properties = { } try: - response = client.track("$transaction", properties, return_score=True, include_score_percentiles=True, abuse_types=["promotion_abuse", "content_abuse", "payment_abuse"]) - if response.is_ok(): - score_response = response.body["score_response"] - print(score_response) + response = client.track( + "$transaction", + properties, + return_score=True, + include_score_percentiles=True, + abuse_types=("promotion_abuse", "content_abuse", "payment_abuse"), + ) except sift.client.ApiException: # request failed pass +else: + if response.is_ok(): + score_response = response.body["score_response"] + print(score_response) -# To include `warnings` field to Events API response via calling `track()` method, set it by the `include_warnings` param: + +# In order to include `warnings` field to Events API response via calling +# `track()` method, set it by the `include_warnings` param: try: response = client.track("$transaction", properties, include_warnings=True) # ... @@ -130,12 +121,12 @@ except sift.client.ApiException: # Request a score for the user with user_id 23056 try: response = client.score(user_id) - s = json.dumps(response.body) - print s - except sift.client.ApiException: # request failed pass +else: + print(response.body) + try: # Label the user with user_id 23056 as Bad with all optional fields @@ -211,6 +202,7 @@ send_properties = { } } } + try: response = client.verification_send(send_properties) except sift.client.ApiException: @@ -241,35 +233,4 @@ try: except sift.client.ApiException: # request failed pass - -``` - -## Testing - -Before submitting a change, make sure the following commands run without -errors from the root dir of the repository: - - python -m unittest discover - python3 -m unittest discover - -## Integration testing app - -For testing the app with real calls it is possible to run the integration testing app, -it makes calls to almost all our public endpoints to make sure the library integrates -well. At the moment, the app is run on every merge to master - -#### How to run it locally - -1. Add env variable `ACCOUNT_ID` with the valid account id -2. Add env variable `API_KEY` with the valid Api Key associated from the account -3. Run the following under the project root folder -``` -# uninstall the lib from the local env (if it was installed) -pip uninstall sift - -# install the lib from the local source code -pip install ../sift-python - -# run the app -python test_integration_app/main.py ``` From aca91e9dc17081a2b8bd029b405026b6659eebfd Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Wed, 26 Mar 2025 10:39:16 +0100 Subject: [PATCH 099/112] Minor fixes --- .github/workflows/ci.yml | 1 - sift/client.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26808d3..3335195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,6 @@ jobs: python-version: "3.10.14" - name: Install test dependencies run: | - pip install mock=="${{ env.mock_version_python3 }}" pip install requests=="${{ env.requests_version_python3 }}" - name: Run tests run: | diff --git a/sift/client.py b/sift/client.py index 88a4c0d..a80b4f7 100644 --- a/sift/client.py +++ b/sift/client.py @@ -204,10 +204,12 @@ def _v3_api(self, endpoint: str) -> str: return self._api_url("v3", endpoint) def _user_agent(self, version: str | None = None) -> str: + py_version = sys.version.split(" ")[0].split(".") + return ( f"SiftScience/v{version or self.version} " f"sift-python/{VERSION} " - f"Python/{sys.version.split(' ')[0]}" + f"Python/{py_version[0]}.{py_version[1]}" ) def _headers(self, version: str | None = None) -> dict[str, str]: From 153e8785f9418cf3a216d7c758022f835b700f3e Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Wed, 26 Mar 2025 10:42:04 +0100 Subject: [PATCH 100/112] Updated changelog --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 0ef41ce..2536aa5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ================ - Added support for Python 3.13 +- Dropped support for Python < 3.8 +- Added typing annotations overall the library +- Updated doc strings with actual information +- Fixed issue when library could send requests with invalid version in the "User-Agent" header INCOMPATIBLE CHANGES INTRODUCED IN 6.0.0: From e4cc5153040f9e99820e1f5aefd71ef18338ed22 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Wed, 26 Mar 2025 11:59:34 +0100 Subject: [PATCH 101/112] Pass full version of Python to the User-Agent header --- CHANGES.md | 2 +- sift/client.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 2536aa5..3364c0a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,7 @@ - Dropped support for Python < 3.8 - Added typing annotations overall the library - Updated doc strings with actual information -- Fixed issue when library could send requests with invalid version in the "User-Agent" header +- Fixed issue when the client could send requests with invalid version in the "User-Agent" header INCOMPATIBLE CHANGES INTRODUCED IN 6.0.0: diff --git a/sift/client.py b/sift/client.py index a80b4f7..88a4c0d 100644 --- a/sift/client.py +++ b/sift/client.py @@ -204,12 +204,10 @@ def _v3_api(self, endpoint: str) -> str: return self._api_url("v3", endpoint) def _user_agent(self, version: str | None = None) -> str: - py_version = sys.version.split(" ")[0].split(".") - return ( f"SiftScience/v{version or self.version} " f"sift-python/{VERSION} " - f"Python/{py_version[0]}.{py_version[1]}" + f"Python/{sys.version.split(' ')[0]}" ) def _headers(self, version: str | None = None) -> dict[str, str]: From 79081cc8c543025e8a2d04b4ac5616a6faad1793 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Sun, 30 Mar 2025 16:53:40 +0200 Subject: [PATCH 102/112] Change signature of .get_decisions() method --- CHANGES.md | 10 +- sift/client.py | 388 +++++++++--------- .../decisions_api/test_decisions_api.py | 5 +- tests/test_client.py | 28 +- 4 files changed, 232 insertions(+), 199 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3364c0a..cbc7628 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,11 +5,19 @@ - Dropped support for Python < 3.8 - Added typing annotations overall the library - Updated doc strings with actual information -- Fixed issue when the client could send requests with invalid version in the "User-Agent" header +- Fixed an issue when the client could send requests with invalid version in the "User-Agent" header +- Changed the type of the `abuse_types` parameter in the `client.get_decisions()` method INCOMPATIBLE CHANGES INTRODUCED IN 6.0.0: - Dropped support for Python < 3.8 +- Passing `abuse_types` as a comma-separated string to the `client.get_decisions()` is deprecated. + + Previously, `client.get_decisions()` method allowed to pass `abuse_types` parameter as a + comma-separated string e.g. `abuse_types="legacy,payment_abuse"`. This is deprecated now. + Starting from 6.0.0 callers must pass `abuse_types` parameter to the `client.get_decisions()` + method as a sequence of string literals e.g. `abuse_types=("legacy", "payment_abuse")`. The same + way as it passed to the other client's methods which receive `abuse_types` parameter. 5.6.1 2024-10-08 - Updated implementation to use Basic Authentication instead of passing `API_KEY` as a request parameter for the following calls: diff --git a/sift/client.py b/sift/client.py index 88a4c0d..16b4f26 100644 --- a/sift/client.py +++ b/sift/client.py @@ -46,9 +46,9 @@ def _assert_non_empty_str( def _assert_non_empty_dict(val: object, name: str) -> None: - error = f"{name} must be a non-empty dict" + error = f"{name} must be a non-empty mapping (dict)" - if not isinstance(val, dict): + if not isinstance(val, Mapping): raise TypeError(error) if not val: @@ -128,7 +128,7 @@ def __init__( self, api_key: str | None = None, api_url: str = API_URL, - timeout: int | float | tuple[int | float, int | float] = 2, + timeout: float | tuple[float, float] = 2, account_id: str | None = None, version: str = API_VERSION, session: requests.Session | None = None, @@ -140,23 +140,24 @@ def __init__( The Sift Science API key associated with your account. You can obtain it from https://console.sift.com/developer/api-keys - api_url(optional): + api_url (optional): Base URL, including scheme and host, for sending events. Defaults to 'https://api.sift.com'. - timeout(optional): - Number of seconds to wait before failing a request. + timeout (optional): + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Defaults to 2 seconds. - account_id(optional): + account_id (optional): The ID of your Sift Science account. You can obtain it from https://developers.sift.com/console/account/profile - version{optional}: + version (optional): The version of the Sift Science API to call. Defaults to the latest version. - session(optional): + session (optional): requests.Session object https://requests.readthedocs.io/en/latest/user/advanced/#session-objects """ @@ -188,21 +189,10 @@ def _get_fields_param( if include ] + @property def _auth(self) -> HTTPBasicAuth: return HTTPBasicAuth(self.api_key, "") - def _api_url(self, version: str, endpoint: str) -> str: - return f"{self.url}/{version}{endpoint}" - - def _versioned_api(self, version: str, endpoint: str) -> str: - return self._api_url(f"v{version}", endpoint) - - def _v1_api(self, endpoint: str) -> str: - return self._api_url("v1", endpoint) - - def _v3_api(self, endpoint: str) -> str: - return self._api_url("v3", endpoint) - def _user_agent(self, version: str | None = None) -> str: return ( f"SiftScience/v{version or self.version} " @@ -210,18 +200,30 @@ def _user_agent(self, version: str | None = None) -> str: f"Python/{sys.version.split(' ')[0]}" ) - def _headers(self, version: str | None = None) -> dict[str, str]: + def _default_headers(self, version: str | None = None) -> dict[str, str]: return { "User-Agent": self._user_agent(version), } def _post_headers(self, version: str | None = None) -> dict[str, str]: return { + **self._default_headers(version), "Content-type": "application/json", "Accept": "*/*", - "User-Agent": self._user_agent(version), } + def _api_url(self, version: str, endpoint: str) -> str: + return f"{self.url}/{version}{endpoint}" + + def _v1_api(self, endpoint: str) -> str: + return self._api_url("v1", endpoint) + + def _v3_api(self, endpoint: str) -> str: + return self._api_url("v3", endpoint) + + def _versioned_api(self, version: str, endpoint: str) -> str: + return self._api_url(f"v{version}", endpoint) + def _events_url(self, version: str) -> str: return self._versioned_api(version, "/events") @@ -309,10 +311,10 @@ def _validate_send_request(self, properties: Mapping[str, t.Any]) -> None: ) event = properties.get("$event") - if not isinstance(event, dict): - raise TypeError("$event must be a dict") + if not isinstance(event, Mapping): + raise TypeError("$event must be a mapping (dict)") elif not event: - raise ValueError("$event dictionary may not be empty") + raise ValueError("$event mapping (dict) may not be empty") session_id = event.get("$session_id") _assert_non_empty_str(session_id, "session_id", error_cls=ValueError) @@ -336,8 +338,7 @@ def _validate_check_request(self, properties: Mapping[str, t.Any]) -> None: user_id = properties.get("$user_id") _assert_non_empty_str(user_id, "user_id", error_cls=ValueError) - otp_code = properties.get("$code") - if otp_code is None: + if properties.get("$code") is None: raise ValueError("code is required") def _validate_apply_decision_request( @@ -373,7 +374,7 @@ def track( return_route_info: bool = False, force_workflow_run: bool = False, abuse_types: Sequence[AbuseType] | None = None, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, version: str | None = None, include_score_percentiles: bool = False, include_warnings: bool = False, @@ -394,7 +395,7 @@ def track( a custom event name (that does not start with a $). properties: - A dict of additional event-specific attributes to track. + A mapping of additional event-specific attributes to track. path: An API endpoint to make a request to. @@ -408,7 +409,7 @@ def track( Whether the API response should include actions in the response. For more information on how this works, please visit the tutorial at: - https://developers.sift.com/tutorials/formulas . + https://developers.sift.com/tutorials/formulas return_workflow_status (optional): Whether the API response should include the status of any @@ -426,13 +427,14 @@ def track( score response, and no workflow will run. abuse_types (optional): - A Sequence of abuse types, specifying for which abuse types + A sequence of abuse types, specifying for which abuse types a score should be returned (if scores were requested). If not specified, a score will be returned for every abuse_type to which you are subscribed. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. version (optional): Use a different version of the Sift Science API for this call. @@ -450,11 +452,10 @@ def track( but important enough to be fixed. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(event, "event") _assert_non_empty_dict(properties, "properties") @@ -517,7 +518,7 @@ def track( def score( self, user_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, abuse_types: Sequence[AbuseType] | None = None, version: str | None = None, include_score_percentiles: bool = False, @@ -536,10 +537,11 @@ def score( used in event calls. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. abuse_types (optional): - A Sequence of abuse types, specifying for which abuse types + A sequence of abuse types, specifying for which abuse types a score should be returned (if scores were requested). If not specified, a score will be returned for every abuse_type to which you are subscribed. @@ -553,11 +555,10 @@ def score( parameter called `fields` in the query parameter Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(user_id, "user_id") @@ -581,8 +582,8 @@ def score( response = self.session.get( url, params=params, - auth=self._auth(), - headers=self._headers(version), + auth=self._auth, + headers=self._default_headers(version), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -593,7 +594,7 @@ def score( def get_user_score( self, user_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, abuse_types: Sequence[AbuseType] | None = None, include_score_percentiles: bool = False, ) -> Response: @@ -615,10 +616,11 @@ def get_user_score( event calls. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. abuse_types (optional): - A Sequence of abuse types, specifying for which abuse types + A sequence of abuse types, specifying for which abuse types a score should be returned (if scores were requested). If not specified, a score will be returned for every abuse_type to which you are subscribed. @@ -629,11 +631,10 @@ def get_user_score( called fields in the query parameter Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(user_id, "user_id") @@ -653,8 +654,8 @@ def get_user_score( response = self.session.get( url, params=params, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -665,7 +666,7 @@ def get_user_score( def rescore_user( self, user_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, abuse_types: Sequence[AbuseType] | None = None, ) -> Response: """ @@ -683,20 +684,20 @@ def rescore_user( event calls. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. abuse_types (optional): - A Sequence of abuse types, specifying for which abuse types + A sequence of abuse types, specifying for which abuse types a score should be returned (if scores were requested). If not specified, a score will be returned for every abuse_type to which you are subscribed. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(user_id, "user_id") @@ -713,8 +714,8 @@ def rescore_user( response = self.session.post( url, params=params, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -726,7 +727,7 @@ def label( self, user_id: str, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, version: str | None = None, ) -> Response: """ @@ -743,20 +744,20 @@ def label( event calls. properties: - A dict of additional event-specific attributes to track. + A mapping of additional event-specific attributes to track. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. version (optional): Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(user_id, "user_id") @@ -774,7 +775,7 @@ def label( def unlabel( self, user_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, abuse_type: AbuseType | None = None, version: str | None = None, ) -> Response: @@ -792,7 +793,8 @@ def unlabel( event calls. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. abuse_type (optional): The abuse type for which the user should be unlabeled. @@ -802,11 +804,10 @@ def unlabel( Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(user_id, "user_id") @@ -826,8 +827,8 @@ def unlabel( response = self.session.delete( url, params=params, - auth=self._auth(), - headers=self._headers(version), + auth=self._auth, + headers=self._default_headers(version), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -838,7 +839,7 @@ def unlabel( def get_workflow_status( self, run_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Gets the status of a workflow run. @@ -847,14 +848,14 @@ def get_workflow_status( The workflow run unique identifier. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") _assert_non_empty_str(run_id, "run_id") @@ -867,8 +868,8 @@ def get_workflow_status( try: response = self.session.get( url, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -881,8 +882,8 @@ def get_decisions( entity_type: t.Literal["user", "order", "session", "content"], limit: int | None = None, start_from: int | None = None, - abuse_types: str | None = None, - timeout: int | float | tuple[int | float, int | float] | None = None, + abuse_types: Sequence[AbuseType] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Get decisions available to the customer @@ -898,28 +899,34 @@ def get_decisions( Result set offset for use in pagination [default: 0] abuse_types (optional): - comma-separated list of abuse_types used to filter returned - decisions + A sequence of abuse types, specifying by which abuse types + decisions should be filtered. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") _assert_non_empty_str(entity_type, "entity_type") - if entity_type.lower() not in ["user", "order", "session", "content"]: + if entity_type.lower() not in ("user", "order", "session", "content"): raise ValueError( "entity_type must be one of {user, order, session, content}" ) + if isinstance(abuse_types, str): + raise ValueError( + "Passing `abuse_types` as string is deprecated. " + "Expected a sequence of string literals." + ) + params: dict[str, t.Any] = { "entity_type": entity_type, } @@ -931,7 +938,7 @@ def get_decisions( params["from"] = start_from if abuse_types: - params["abuse_types"] = abuse_types + params["abuse_types"] = ",".join(abuse_types) if timeout is None: timeout = self.timeout @@ -942,8 +949,8 @@ def get_decisions( response = self.session.get( url, params=params, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -955,7 +962,7 @@ def apply_user_decision( self, user_id: str, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Apply decision to a user @@ -969,14 +976,14 @@ def apply_user_decision( time: in millis when decision was applied timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -991,7 +998,7 @@ def apply_user_decision( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(), timeout=timeout, ) @@ -1005,7 +1012,7 @@ def apply_order_decision( user_id: str, order_id: str, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Apply decision to order @@ -1024,14 +1031,14 @@ def apply_order_decision( time: in millis when decision was applied (optional) timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1051,7 +1058,7 @@ def apply_order_decision( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(), timeout=timeout, ) @@ -1063,7 +1070,7 @@ def apply_order_decision( def get_user_decisions( self, user_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Gets the decisions for a user. @@ -1072,14 +1079,14 @@ def get_user_decisions( The ID of a user. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1093,8 +1100,8 @@ def get_user_decisions( try: response = self.session.get( url, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -1105,7 +1112,7 @@ def get_user_decisions( def get_order_decisions( self, order_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Gets the decisions for an order. @@ -1114,14 +1121,14 @@ def get_order_decisions( The ID for the order. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1135,8 +1142,8 @@ def get_order_decisions( try: response = self.session.get( url, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -1148,7 +1155,7 @@ def get_content_decisions( self, user_id: str, content_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Gets the decisions for a piece of content. @@ -1160,14 +1167,14 @@ def get_content_decisions( The ID for the content. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1182,8 +1189,8 @@ def get_content_decisions( try: response = self.session.get( url, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -1195,7 +1202,7 @@ def get_session_decisions( self, user_id: str, session_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Gets the decisions for a user's session. @@ -1207,14 +1214,14 @@ def get_session_decisions( The ID for the session. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1229,8 +1236,8 @@ def get_session_decisions( try: response = self.session.get( url, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -1243,7 +1250,7 @@ def apply_session_decision( user_id: str, session_id: str, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Apply decision to a session. @@ -1262,14 +1269,14 @@ def apply_session_decision( time: in millis when decision was applied (optional) timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1287,7 +1294,7 @@ def apply_session_decision( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(), timeout=timeout, ) @@ -1301,7 +1308,7 @@ def apply_content_decision( user_id: str, content_id: str, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Apply decision to a piece of content. @@ -1320,14 +1327,14 @@ def apply_content_decision( time: in millis when decision was applied (optional) timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") _assert_non_empty_str(user_id, "user_id") @@ -1344,7 +1351,7 @@ def apply_content_decision( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(), timeout=timeout, ) @@ -1356,23 +1363,23 @@ def apply_content_decision( def create_psp_merchant_profile( self, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Create a new PSP Merchant profile Args: properties: - A dict of merchant profile data. + A mapping of merchant profile data. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1386,7 +1393,7 @@ def create_psp_merchant_profile( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(), timeout=timeout, ) @@ -1399,7 +1406,7 @@ def update_psp_merchant_profile( self, merchant_id: str, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Update already existing PSP Merchant profile @@ -1409,17 +1416,17 @@ def update_psp_merchant_profile( the good or service. properties: - A dict of merchant profile data. + A mapping of merchant profile data. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1433,7 +1440,7 @@ def update_psp_merchant_profile( response = self.session.put( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(), timeout=timeout, ) @@ -1446,7 +1453,7 @@ def get_psp_merchant_profiles( self, batch_token: str | None = None, batch_size: int | None = None, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Gets all PSP merchant profiles (paginated). @@ -1458,14 +1465,14 @@ def get_psp_merchant_profiles( Batch or page size of the paginated sequence. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1486,8 +1493,8 @@ def get_psp_merchant_profiles( try: response = self.session.get( url, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), params=params, timeout=timeout, ) @@ -1499,7 +1506,7 @@ def get_psp_merchant_profiles( def get_a_psp_merchant_profile( self, merchant_id: str, - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Response: """Gets a PSP merchant profile by merchant id. @@ -1509,14 +1516,14 @@ def get_a_psp_merchant_profile( the good or service. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ _assert_non_empty_str(self.account_id, "account_id") @@ -1529,8 +1536,8 @@ def get_a_psp_merchant_profile( try: response = self.session.get( url, - auth=self._auth(), - headers=self._headers(), + auth=self._auth, + headers=self._default_headers(), timeout=timeout, ) except requests.exceptions.RequestException as e: @@ -1541,7 +1548,7 @@ def get_a_psp_merchant_profile( def verification_send( self, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, version: str | None = None, ) -> Response: """ @@ -1588,17 +1595,17 @@ def verification_send( Use this field if the client is a browser. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. version (optional): Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ if timeout is None: @@ -1615,7 +1622,7 @@ def verification_send( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(version), timeout=timeout, ) @@ -1627,7 +1634,7 @@ def verification_send( def verification_resend( self, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, version: str | None = None, ) -> Response: """ @@ -1642,7 +1649,6 @@ def verification_resend( Args: properties: - $user_id: User ID of user being verified, e.g. johndoe123. $verified_event (optional): @@ -1651,17 +1657,17 @@ def verification_resend( The ID of the entity impacted by the event being verified. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. version (optional): Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ if timeout is None: @@ -1678,7 +1684,7 @@ def verification_resend( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(version), timeout=timeout, ) @@ -1690,7 +1696,7 @@ def verification_resend( def verification_check( self, properties: Mapping[str, t.Any], - timeout: int | float | tuple[int | float, int | float] | None = None, + timeout: float | tuple[float, float] | None = None, version: str | None = None, ) -> Response: """ @@ -1718,17 +1724,17 @@ def verification_check( The ID of the entity impacted by the event being verified. timeout (optional): - Use a custom timeout (in seconds) for this call. + How many seconds to wait for the server to send data before + giving up, as a float, or a (connect timeout, read timeout) tuple. version (optional): Use a different version of the Sift Science API for this call. Returns: - A sift.client.Response object if the call succeeded + A sift.client.Response object if the call to the Sift API is successful Raises: - ApiException: - if the call not succeeded + ApiException: If the call to the Sift API is not successful """ if timeout is None: timeout = self.timeout @@ -1744,7 +1750,7 @@ def verification_check( response = self.session.post( url, data=json.dumps(properties), - auth=self._auth(), + auth=self._auth, headers=self._post_headers(version), timeout=timeout, ) diff --git a/test_integration_app/decisions_api/test_decisions_api.py b/test_integration_app/decisions_api/test_decisions_api.py index 0a16364..da05991 100644 --- a/test_integration_app/decisions_api/test_decisions_api.py +++ b/test_integration_app/decisions_api/test_decisions_api.py @@ -76,5 +76,8 @@ def get_decisions(self) -> sift.client.Response: entity_type="user", limit=10, start_from=5, - abuse_types="legacy,payment_abuse", + abuse_types=( + "legacy", + "payment_abuse", + ), ) diff --git a/tests/test_client.py b/tests/test_client.py index aad819a..9021b48 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -257,10 +257,10 @@ def test_global_api_key(self) -> None: # test that client2 is assigned a new object with global api_key assert client2.api_key == sift.api_key - @mock.patch("sift.api_key", return_value=None) - def test_constructor_requires_valid_api_key(self, _) -> None: - self.assertRaises(TypeError, sift.Client, None) - self.assertRaises(ValueError, sift.Client, "") + def test_constructor_requires_valid_api_key(self) -> None: + with mock.patch("sift.api_key", return_value=None): + self.assertRaises(TypeError, sift.Client, None) + self.assertRaises(ValueError, sift.Client, "") def test_constructor_invalid_api_url(self) -> None: self.assertRaises(TypeError, sift.Client, self.test_key, None) @@ -665,7 +665,10 @@ def test_get_decisions(self) -> None: entity_type="user", limit=10, start_from=None, - abuse_types="legacy,payment_abuse", + abuse_types=( + "legacy", + "payment_abuse", + ), timeout=3, ) @@ -720,7 +723,7 @@ def test_get_decisions_entity_session(self) -> None: entity_type="session", limit=10, start_from=None, - abuse_types="account_takeover", + abuse_types=("account_takeover",), timeout=3, ) @@ -740,6 +743,19 @@ def test_get_decisions_entity_session(self) -> None: assert isinstance(response.body, dict) assert response.body["data"][0]["id"] == "block_session" + def test_get_decisions_with_deprecated_signature(self) -> None: + with mock.patch.object(self.sift_client.session, "get") as mock_get: + with self.assertRaises(ValueError): + self.sift_client.get_decisions( + entity_type="session", + limit=10, + start_from=None, + abuse_types=("legacy", "account_takeover"), + timeout=3, + ) + + mock_get.assert_not_called() + def test_apply_decision_to_user_ok(self) -> None: user_id = "54321" mock_response = mock.Mock() From f13189b48af595888dd5851b2b57b1f528b0d974 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Sun, 30 Mar 2025 16:57:50 +0200 Subject: [PATCH 103/112] Change signature of .get_decisions() method --- tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index 9021b48..b69abee 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -750,7 +750,7 @@ def test_get_decisions_with_deprecated_signature(self) -> None: entity_type="session", limit=10, start_from=None, - abuse_types=("legacy", "account_takeover"), + abuse_types=t.cast(list, "legacy,account_takeover"), timeout=3, ) From 79d048e822c372e6219f4a08ee77ff967f0f3f21 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Mon, 28 Apr 2025 15:31:19 +0200 Subject: [PATCH 104/112] Allow abuse type to be passed as any string --- sift/client.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/sift/client.py b/sift/client.py index 16b4f26..79c4b32 100644 --- a/sift/client.py +++ b/sift/client.py @@ -18,16 +18,6 @@ from sift.utils import DecimalEncoder, quote_path as _q from sift.version import API_VERSION, VERSION -AbuseType = t.Literal[ - "account_abuse", - "account_takeover", - "content_abuse", - "legacy", - "payment_abuse", - "promo_abuse", - "promotion_abuse", -] - def _assert_non_empty_str( val: object, @@ -373,7 +363,7 @@ def track( return_workflow_status: bool = False, return_route_info: bool = False, force_workflow_run: bool = False, - abuse_types: Sequence[AbuseType] | None = None, + abuse_types: Sequence[str] | None = None, timeout: float | tuple[float, float] | None = None, version: str | None = None, include_score_percentiles: bool = False, @@ -519,7 +509,7 @@ def score( self, user_id: str, timeout: float | tuple[float, float] | None = None, - abuse_types: Sequence[AbuseType] | None = None, + abuse_types: Sequence[str] | None = None, version: str | None = None, include_score_percentiles: bool = False, ) -> Response: @@ -595,7 +585,7 @@ def get_user_score( self, user_id: str, timeout: float | tuple[float, float] | None = None, - abuse_types: Sequence[AbuseType] | None = None, + abuse_types: Sequence[str] | None = None, include_score_percentiles: bool = False, ) -> Response: """ @@ -667,7 +657,7 @@ def rescore_user( self, user_id: str, timeout: float | tuple[float, float] | None = None, - abuse_types: Sequence[AbuseType] | None = None, + abuse_types: Sequence[str] | None = None, ) -> Response: """ Rescores the specified user for the specified abuse types and returns @@ -776,7 +766,7 @@ def unlabel( self, user_id: str, timeout: float | tuple[float, float] | None = None, - abuse_type: AbuseType | None = None, + abuse_type: str | None = None, version: str | None = None, ) -> Response: """ @@ -882,7 +872,7 @@ def get_decisions( entity_type: t.Literal["user", "order", "session", "content"], limit: int | None = None, start_from: int | None = None, - abuse_types: Sequence[AbuseType] | None = None, + abuse_types: Sequence[str] | None = None, timeout: float | tuple[float, float] | None = None, ) -> Response: """Get decisions available to the customer From f00e56852ace01e72ecb983c74bcc2aadc2914f2 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Mon, 28 Apr 2025 15:36:42 +0200 Subject: [PATCH 105/112] Update linters --- .pre-commit-config.yaml | 2 +- requirements-dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 087e858..37254e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/crate-ci/typos - rev: v1.30.2 + rev: v1.31.1 hooks: - id: typos args: [ --force-exclude ] diff --git a/requirements-dev.txt b/requirements-dev.txt index a78b232..fc40ddf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,4 +2,4 @@ black==24.8.0 flake8==7.1.2 isort==5.13.2 mypy==1.14.1 -typos==1.30.2 +typos==1.31.1 From 3a9d5ea749555fbb9abf7c7d73f5222b9d0487ad Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Tue, 29 Apr 2025 19:40:30 +0200 Subject: [PATCH 106/112] Update CI --- .github/workflows/ci.yml | 15 +++++++++------ .github/workflows/publishing2PyPI.yml | 7 ++++--- .travis.yml | 23 ----------------------- CONTRIBUTING.md | 4 ++-- 4 files changed, 15 insertions(+), 34 deletions(-) delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3335195..2e7c39e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,6 @@ permissions: contents: read env: - mock_version_python3: "5.0.1" - requests_version_python3: "2.28.2" ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} API_KEY: ${{ secrets.API_KEY }} @@ -28,7 +26,12 @@ jobs: python-version: "3.10.14" - name: Install test dependencies run: | - pip install requests=="${{ env.requests_version_python3 }}" + pip install -e . + pip install -r requirements-dev.txt + - name: Run linters + run: | + pip install -U pre-commit + pre-commit run -v --all-files - name: Run tests run: | python -m unittest discover @@ -42,7 +45,7 @@ jobs: uses: actions/setup-python@v3 with: python-version: "3.10.14" - - name: run-integration-tests-python3 + - name: Run integration tests run: | - pip3 install . - python3 test_integration_app/main.py + pip install . + python test_integration_app/main.py diff --git a/.github/workflows/publishing2PyPI.yml b/.github/workflows/publishing2PyPI.yml index 0b63ba4..810a8f4 100644 --- a/.github/workflows/publishing2PyPI.yml +++ b/.github/workflows/publishing2PyPI.yml @@ -27,11 +27,12 @@ jobs: fi - name: Create distribution files run: | - python3 setup.py sdist + python -m pip install build + python -m build - name: Upload distribution files env: TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} TWINE_USER: ${{ secrets.USER }} run: | - python3 -m pip install --user --upgrade twine - ls dist/ | xargs -I % python3 -m twine upload --repository pypi dist/% + python -m pip install --user --upgrade twine + ls dist/ | xargs -I % python -m twine upload --repository pypi dist/% diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ff6e263..0000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: python -python: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" - - "3.13" -before_install: - - python --version - - pip install -U pip -# command to install dependencies -install: - - pip install -e . - - pip install -r requirements-dev.txt -# command to run tests -script: - - flake8 --count - - black --check . - - isort --check . - - mypy --install-types --non-interactive . - - typos - - python -m unittest discover diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38ffd0d..c53bb5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,9 @@ ```sh # install necessary Python version -pyenv install 3.13.2 +pyenv install 3.13.2 -# create a virtual enviroment +# create a virtual environment pyenv virtualenv 3.13.2 v3.13 pyenv activate v3.13 ``` From 324776ea275443c2a6cd66611aac7f4bae0d4478 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Tue, 29 Apr 2025 19:41:31 +0200 Subject: [PATCH 107/112] Remove trailing space --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c53bb5e..b8db25a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ ```sh # install necessary Python version -pyenv install 3.13.2 +pyenv install 3.13.2 # create a virtual environment pyenv virtualenv 3.13.2 v3.13 From 6533609847aa3f5b19ca76678c79e3b56ebd76ab Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Tue, 29 Apr 2025 19:49:08 +0200 Subject: [PATCH 108/112] Install requirements from pre-commit config --- .github/workflows/ci.yml | 3 +-- requirements-dev.txt | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 requirements-dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e7c39e..cff872f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,9 @@ jobs: uses: actions/setup-python@v3 with: python-version: "3.10.14" - - name: Install test dependencies + - name: Install the library run: | pip install -e . - pip install -r requirements-dev.txt - name: Run linters run: | pip install -U pre-commit diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index fc40ddf..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -black==24.8.0 -flake8==7.1.2 -isort==5.13.2 -mypy==1.14.1 -typos==1.31.1 From 4b46a84866f29ebc6fe768bb934cee44a4051566 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Tue, 29 Apr 2025 20:05:04 +0200 Subject: [PATCH 109/112] Add missing command --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8db25a..e583866 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,7 @@ pip install -U pip ```sh pip install -U pre-commit +pre-commit install ``` 5. Install the library: From b5a13663adeace8110357b5d98fe61231c7d01e0 Mon Sep 17 00:00:00 2001 From: Denys Pecheniev Date: Wed, 30 Apr 2025 12:33:49 +0200 Subject: [PATCH 110/112] Update links to the documentation --- README.md | 12 +++--------- sift/client.py | 14 +++++++------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index aae5a89..5bc94c5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # Sift Python Bindings Bindings for Sift's APIs -- including the -[Events](https://sift.com/resources/references/events-api.html), -[Labels](https://sift.com/resources/references/labels-api.html), +[Events](https://developers.sift.com/docs/python/events-api/, +[Labels](https://developers.sift.com/docs/python/labels-api/), and -[Score](https://sift.com/resources/references/score-api.html) +[Score](https://developers.sift.com/docs/python/score-api/) APIs. - ## Installation ```sh @@ -26,11 +25,6 @@ Please see [the CHANGELOG](https://github.com/SiftScience/sift-python/blob/master/CHANGES.md) for a history of all changes. -Note, that in v2.0.0, the API semantics were changed to raise an -exception in the case of error to be more pythonic. Client code will -need to be updated to catch `sift.client.ApiException` exceptions. - - ## Usage Here's an example: diff --git a/sift/client.py b/sift/client.py index 79c4b32..b421ce4 100644 --- a/sift/client.py +++ b/sift/client.py @@ -1,5 +1,5 @@ """Python client for Sift Science's API. -See: https://developers.sift.com/docs/python/events-api +See: https://developers.sift.com/docs/python/events-api/ """ from __future__ import annotations @@ -374,7 +374,7 @@ def track( This call is blocking. - Visit https://siftscience.com/resources/references/events-api + Visit https://developers.sift.com/docs/python/events-api/ for more information on what types of events you can send and fields you can add to the properties parameter. @@ -518,7 +518,7 @@ def score( This call is blocking. - Visit https://developers.sift.com/docs/python/score-api + Visit https://developers.sift.com/docs/python/score-api/ for more details on our Score response structure. Args: @@ -597,7 +597,7 @@ def get_user_score( This call is blocking. - Visit https://developers.sift.com/docs/python/score-api/get-score + Visit https://developers.sift.com/docs/python/score-api/get-score/ for more details. Args: @@ -665,7 +665,7 @@ def rescore_user( This call is blocking. - Visit https://developers.sift.com/docs/python/score-api/rescore/overview + Visit https://developers.sift.com/docs/python/score-api/rescore/ for more details. Args: @@ -725,7 +725,7 @@ def label( This call is blocking. - Visit https://developers.sift.com/docs/python/labels-api + Visit https://developers.sift.com/docs/python/labels-api/label-user for more details on what fields to send in properties. Args: @@ -774,7 +774,7 @@ def unlabel( This call is blocking. - Visit https://developers.sift.com/docs/python/labels-api + Visit https://developers.sift.com/docs/python/labels-api/unlabel-user for more details. Args: From 3c2984f6a06bc9ab8d6873e6ba4b20c832547722 Mon Sep 17 00:00:00 2001 From: Eduard Chumak <71296844+echumak-sift@users.noreply.github.com> Date: Mon, 5 May 2025 13:47:14 -0700 Subject: [PATCH 111/112] Update CHANGES.md Updated release date --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index cbc7628..31c250b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,4 @@ -6.0.0 (Not released yet) +6.0.0 2025-05-05 ================ - Added support for Python 3.13 From 9e14975230ec249987bb519ee4fa4a8f58645453 Mon Sep 17 00:00:00 2001 From: Eduard Chumak <71296844+echumak-sift@users.noreply.github.com> Date: Mon, 5 May 2025 14:53:43 -0700 Subject: [PATCH 112/112] Update publishing2PyPI.yml Fixed code that parses version --- .github/workflows/publishing2PyPI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publishing2PyPI.yml b/.github/workflows/publishing2PyPI.yml index 810a8f4..a8ea16b 100644 --- a/.github/workflows/publishing2PyPI.yml +++ b/.github/workflows/publishing2PyPI.yml @@ -15,7 +15,7 @@ jobs: - name: Get package version run: | VERSION=NOT_SET - VERSION=$(cat ./sift/version.py | grep -E -i '^VERSION.*' | cut -d'=' -f2 | cut -d\' -f2) + VERSION=$(cat ./sift/version.py | grep -E -i '^VERSION.*' | cut -d'=' -f2 | cut -d\" -f2) [[ $VERSION == "NOT_SET" ]] && echo "Version in version.py NOT_SET" && exit 1 echo "curr_version=$(echo $VERSION)" >> $GITHUB_ENV - name: Compare package version and Release tag