Repository: Learnosity/learnosity-sdk-python

Issue:
Type stubs
Hey Learnosity team,

I was wondering if there are any plans to publish type stubs for this repository.  I work in a Python environment with strict type-checking using a third party type-checker ([pyright](https://github.com/microsoft/pyright)).  It's a pretty small repository so I'd be happy to PR some type stubs (in the form of `.pyi` files adjacent to the existing `.py` files) so that others using type-checkers can access this repository as well.

Best,
Violet

Discussion:

Patch:
diff --git a/ChangeLog.md b/ChangeLog.md
index 5296103..2c3b2d3 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
 
 ## [Unreleased]
 
-## Fixed
-- Fixed quickstart tutorial when installed via pip
+## [v0.3.9] - 2024-08-29
+### Added
+- Added type hints
+- Added more unit tests for daatapi.py
+
+### Fixed
+- fixed usage of deprecated iterritems to items
 
 ## [v0.3.8] - 2024-05-28
 ### Added
diff --git a/learnosity_sdk/_version.py b/learnosity_sdk/_version.py
index 30a2b65..e7ba358 100644
--- a/learnosity_sdk/_version.py
+++ b/learnosity_sdk/_version.py
@@ -1,1 +1,1 @@
-__version__ = 'v0.3.8'
+__version__ = 'v0.3.9'
diff --git a/learnosity_sdk/request/dataapi.py b/learnosity_sdk/request/dataapi.py
index 0e99804..0ade205 100644
--- a/learnosity_sdk/request/dataapi.py
+++ b/learnosity_sdk/request/dataapi.py
@@ -1,3 +1,5 @@
+from typing import Any, Dict, Generator
+from requests import Response
 import requests
 import copy
 
@@ -7,8 +9,8 @@ from learnosity_sdk.request import Init
 
 class DataApi(object):
 
-    def request(self, endpoint, security_packet,
-                secret, request_packet={}, action='get'):
+    def request(self, endpoint: str, security_packet: Dict[str, str],
+                secret: str, request_packet:Dict[str, Any] = {}, action: str = 'get') -> Response:
         """
         Make a request to Data API
 
@@ -33,9 +35,9 @@ class DataApi(object):
         init = Init('data', security_packet, secret, request_packet, action)
         return requests.post(endpoint, data=init.generate())
 
-    def results_iter(self, endpoint, security_packet,
-                     secret, request_packet={},
-                     action='get'):
+    def results_iter(self, endpoint: str, security_packet: Dict[str, str],
+                     secret: str, request_packet: Dict[str, Any] = {},
+                     action:str = 'get') -> Generator[Dict[str, Any], None, None]:
         """
         Return an iterator of all results from a request to Data API
 
@@ -60,15 +62,15 @@ class DataApi(object):
                                           secret, request_packet,
                                           action):
             if type(response['data']) == dict:
-                for key, value in response['data'].iteritems():
+                for key, value in response['data'].items():
                     yield {key: value}
             else:
                 for result in response['data']:
                     yield result
 
-    def request_iter(self, endpoint, security_packet,
-                     secret, request_packet={},
-                     action='get'):
+    def request_iter(self, endpoint: str, security_packet: Dict[str, str],
+                     secret: str, request_packet: Dict[str, Any] = {},
+                     action: str = 'get') -> Generator[Dict[str, Any], None, None]:
         """
         Iterate over the pages of results of a query to data api
 
diff --git a/learnosity_sdk/request/init.py b/learnosity_sdk/request/init.py
index 0f97f49..2bdc38f 100644
--- a/learnosity_sdk/request/init.py
+++ b/learnosity_sdk/request/init.py
@@ -4,12 +4,13 @@ import hashlib
 import hmac
 import json
 import platform
+from typing import Any, Dict, Iterable, Optional, Union
 from learnosity_sdk._version import __version__
 
 from learnosity_sdk.exceptions import ValidationException
 
 
-def format_utc_time():
+def format_utc_time() -> str:
     "Get the current UTC time, formatted for a security timestamp"
     now = datetime.datetime.utcnow()
     return now.strftime("%Y%m%d-%H%M")
@@ -33,8 +34,9 @@ class Init(object):
     __telemetry_enabled = True
 
     def __init__(
-            self, service, security, secret,
-            request=None, action=None):
+            self, service: str, security: Dict[str, Any], secret: str,
+            request: Optional[Dict[str, Any]] = None, action:Optional[str] = None) -> None:
+        # Using None as a default value will throw mypy typecheck issues. This should be addressed
         self.service = service
         self.security = security.copy()
         self.secret = secret
@@ -50,10 +52,10 @@ class Init(object):
         self.set_service_options()
         self.security['signature'] = self.generate_signature()
 
-    def is_telemetry_enabled(self):
+    def is_telemetry_enabled(self) -> bool:
         return self.__telemetry_enabled
 
-    def generate(self, encode=True):
+    def generate(self, encode: bool = True) -> Union[str, Dict[str, Any]]:
         """
         Generate the data necessary to make a request to one of the Learnosity
         products/services.
@@ -106,7 +108,7 @@ class Init(object):
         else:
             return output
 
-    def get_sdk_meta(self):
+    def get_sdk_meta(self) -> Dict[str, str]:
         return {
             'version': self.get_sdk_version(),
             'lang': 'python',
@@ -115,15 +117,15 @@ class Init(object):
             'platform_version': platform.release()
         }
 
-    def get_sdk_version(self):
+    def get_sdk_version(self) -> str:
         return __version__
 
-    def generate_request_string(self):
+    def generate_request_string(self) -> Union[str, None]:
         if self.request is None:
             return None
         return json.dumps(self.request, separators=(',', ':'), ensure_ascii=False)
 
-    def generate_signature(self):
+    def generate_signature(self) -> str:
 
         vals = []
 
@@ -142,7 +144,7 @@ class Init(object):
 
         return self.hash_list(vals)
 
-    def validate(self):
+    def validate(self) -> None:
         # Parse the security packet if the user provided it as a string
         if isinstance(self.security, str):
             self.security = json.loads(self.security)
@@ -185,7 +187,7 @@ class Init(object):
                 'user_id' not in self.security:
             raise ValidationException("questions API requires a user id")
 
-    def set_service_options(self):
+    def set_service_options(self) -> None:
         if self.service == 'questions':
             self.sign_request_data = False
         elif self.service == 'assess':
@@ -235,13 +237,13 @@ class Init(object):
             if len(hashed_users) > 0:
                 self.security['users'] = hashed_users
 
-    def hash_list(self, l):
+    def hash_list(self, l: Iterable[Any]) -> str:
         "Hash a list by concatenating values with an underscore"
         concatValues = "_".join(l)
         signature =  hmac.new(bytes(str(self.secret),'utf_8'), msg = bytes(str(concatValues) , 'utf-8'), digestmod = hashlib.sha256).hexdigest()
         return '$02$' + signature
 
-    def add_telemetry_data(self):
+    def add_telemetry_data(self) -> None:
         if self.__telemetry_enabled:
             if 'meta' in self.request:
                 self.request['meta']['sdk'] = self.get_sdk_meta()
@@ -256,9 +258,9 @@ class Init(object):
     """
 
     @classmethod
-    def disable_telemetry(cls):
+    def disable_telemetry(cls) -> None:
         cls.__telemetry_enabled = False
 
     @classmethod
-    def enable_telemetry(cls):
+    def enable_telemetry(cls) -> None:
         cls.__telemetry_enabled = True
diff --git a/learnosity_sdk/utils/lrnuuid.py b/learnosity_sdk/utils/lrnuuid.py
index c4c0ccf..19d1d20 100644
--- a/learnosity_sdk/utils/lrnuuid.py
+++ b/learnosity_sdk/utils/lrnuuid.py
@@ -2,5 +2,5 @@ import uuid
 
 class Uuid:
     @staticmethod
-    def generate():
+    def generate() -> str:
         return str(uuid.uuid4())


Test patch:
diff --git a/tests/unit/test_dataapi.py b/tests/unit/test_dataapi.py
index f06333e..c98c012 100644
--- a/tests/unit/test_dataapi.py
+++ b/tests/unit/test_dataapi.py
@@ -1,68 +1,70 @@
 import unittest
 import responses
 from learnosity_sdk.request import DataApi
-
-# This test uses the consumer key and secret for the demos consumer
-# this is the only consumer with publicly available keys
-security = {
-    'consumer_key': 'yis0TYCu7U9V4o7M',
-    'domain': 'demos.learnosity.com'
-}
-# WARNING: Normally the consumer secret should not be committed to a public
-# repository like this one. Only this specific key is publically available.
-consumer_secret = '74c5fd430cf1242a527f6223aebd42d30464be22'
-request = {
-    # These items should already exist for the demos consumer
-    'references': ['item_2', 'item_3'],
-    'limit': 1
-}
-action = 'get'
-endpoint = 'https://data.learnosity.com/v1/itembank/items'
-dummy_responses = [{
-    'meta': {
-        'status': True,
-        'timestamp': 1514874527,
-        'records': 2,
-        'next': '1'
-    },
-    'data': [{'id': 'a'}]
-}, {
-    'meta': {
-        'status': True,
-        'timestamp': 1514874527,
-        'records': 2
-    },
-    'data': [{'id': 'b'}]
-}]
-
+from learnosity_sdk.exceptions import DataApiException
 
 class UnitTestDataApiClient(unittest.TestCase):
     """
     Tests to ensure that the Data API client functions correctly.
     """
 
+    def setUp(self):
+        # This test uses the consumer key and secret for the demos consumer
+        # this is the only consumer with publicly available keys
+        self.security = {
+            'consumer_key': 'yis0TYCu7U9V4o7M',
+            'domain': 'demos.learnosity.com'
+        }
+        # WARNING: Normally the consumer secret should not be committed to a public
+        # repository like this one. Only this specific key is publically available.
+        self.consumer_secret = '74c5fd430cf1242a527f6223aebd42d30464be22'
+        self.request = {
+            # These items should already exist for the demos consumer
+            'references': ['item_2', 'item_3'],
+            'limit': 1
+        }
+        self.action = 'get'
+        self.endpoint = 'https://data.learnosity.com/v1/itembank/items'
+        self.dummy_responses = [{
+            'meta': {
+                'status': True,
+                'timestamp': 1514874527,
+                'records': 2,
+                'next': '1'
+            },
+            'data': [{'id': 'a'}]
+        }, {
+            'meta': {
+                'status': True,
+                'timestamp': 1514874527,
+                'records': 2
+            },
+            'data': [{'id': 'b'}]
+        }]
+        self.invalid_json = "This is not valid JSON!"
+
     @responses.activate
     def test_request(self):
         """
         Verify that `request` sends a request after it has been signed
         """
-        for dummy in dummy_responses:
-            responses.add(responses.POST, endpoint, json=dummy)
+        for dummy in self.dummy_responses:
+            responses.add(responses.POST, self.endpoint, json=dummy)
         client = DataApi()
-        res = client.request(endpoint, security, consumer_secret, request,
-                             action)
-        assert res.json() == dummy_responses[0]
-        assert responses.calls[0].request.url == endpoint
+        res = client.request(self.endpoint, self.security, self.consumer_secret, self.request,
+                             self.action)
+        assert res.json() == self.dummy_responses[0]
+        assert responses.calls[0].request.url == self.endpoint
         assert 'signature' in responses.calls[0].request.body
 
     @responses.activate
     def test_request_iter(self):
         """Verify that `request_iter` returns an iterator of pages"""
-        for dummy in dummy_responses:
-            responses.add(responses.POST, endpoint, json=dummy)
+        for dummy in self.dummy_responses:
+            responses.add(responses.POST, self.endpoint, json=dummy)
         client = DataApi()
-        pages = client.request_iter(endpoint, security, consumer_secret,
-                                    request, action)
+        pages = client.request_iter(self.endpoint, self.security, self.consumer_secret,
+                                    self.request, self.action)
         results = []
         for page in pages:
             results.append(page)
@@ -74,13 +76,47 @@ class UnitTestDataApiClient(unittest.TestCase):
     @responses.activate
     def test_results_iter(self):
         """Verify that `result_iter` returns an iterator of results"""
-        for dummy in dummy_responses:
-            responses.add(responses.POST, endpoint, json=dummy)
+        self.dummy_responses[1]['data'] = {'id': 'b'}
+        for dummy in self.dummy_responses:
+            responses.add(responses.POST, self.endpoint, json=dummy)
         client = DataApi()
-        result_iter = client.results_iter(endpoint, security, consumer_secret,
-                                          request, action)
+        result_iter = client.results_iter(self.endpoint, self.security, self.consumer_secret,
+                                          self.request, self.action)
         results = list(result_iter)
 
         assert len(results) == 2
         assert results[0]['id'] == 'a'
         assert results[1]['id'] == 'b'
+
+    @responses.activate
+    def test_results_iter_error_status(self):
+        """Verify that a DataApiException is raised http status is not ok"""
+        for dummy in self.dummy_responses:
+            responses.add(responses.POST, self.endpoint, json={}, status=500)
+        client = DataApi()
+        with self.assertRaisesRegex(DataApiException, "server returned HTTP status 500"):
+            list(client.results_iter(self.endpoint, self.security, self.consumer_secret,
+                                     self.request, self.action))
+
+    @responses.activate
+    def test_results_iter_no_meta_status(self):
+        """Verify that a DataApiException is raised when 'meta' 'status' is None"""
+        for response in self.dummy_responses:
+            response['meta']['status'] = None
+
+        for dummy in self.dummy_responses:
+            responses.add(responses.POST, self.endpoint, json=dummy)
+        client = DataApi()
+        with self.assertRaisesRegex(DataApiException, "server returned unsuccessful status:"):
+            list(client.results_iter(self.endpoint, self.security, self.consumer_secret,
+                                     self.request, self.action))
+
+    @responses.activate
+    def test_results_iter_invalid_response_data(self):
+        """Verify that a DataApiException is raised response data isn't valid JSON"""
+        for dummy in self.dummy_responses:
+            responses.add(responses.POST, self.endpoint, json=None)
+        client = DataApi()
+        with self.assertRaisesRegex(DataApiException, "server returned invalid json: "):
+            list(client.results_iter(self.endpoint, self.security, self.consumer_secret,
+                                     self.request, self.action))