Skip to content

Commit e10dfeb

Browse files
Address Issue #74 - [DOC] add typings to functions (#81)
* [DOC] add typings - replace iterritems - add unit tests
1 parent ac048d6 commit e10dfeb

File tree

6 files changed

+121
-76
lines changed

6 files changed

+121
-76
lines changed

ChangeLog.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

10-
## Fixed
11-
- Fixed quickstart tutorial when installed via pip
10+
## [v0.3.9] - 2024-08-29
11+
### Added
12+
- Added type hints
13+
- Added more unit tests for daatapi.py
14+
15+
### Fixed
16+
- fixed usage of deprecated iterritems to items
1217

1318
## [v0.3.8] - 2024-05-28
1419
### Added

learnosity_sdk/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = 'v0.3.8'
1+
__version__ = 'v0.3.9'

learnosity_sdk/request/dataapi.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any, Dict, Generator
2+
from requests import Response
13
import requests
24
import copy
35

@@ -7,8 +9,8 @@
79

810
class DataApi(object):
911

10-
def request(self, endpoint, security_packet,
11-
secret, request_packet={}, action='get'):
12+
def request(self, endpoint: str, security_packet: Dict[str, str],
13+
secret: str, request_packet:Dict[str, Any] = {}, action: str = 'get') -> Response:
1214
"""
1315
Make a request to Data API
1416
@@ -33,9 +35,9 @@ def request(self, endpoint, security_packet,
3335
init = Init('data', security_packet, secret, request_packet, action)
3436
return requests.post(endpoint, data=init.generate())
3537

36-
def results_iter(self, endpoint, security_packet,
37-
secret, request_packet={},
38-
action='get'):
38+
def results_iter(self, endpoint: str, security_packet: Dict[str, str],
39+
secret: str, request_packet: Dict[str, Any] = {},
40+
action:str = 'get') -> Generator[Dict[str, Any], None, None]:
3941
"""
4042
Return an iterator of all results from a request to Data API
4143
@@ -60,15 +62,15 @@ def results_iter(self, endpoint, security_packet,
6062
secret, request_packet,
6163
action):
6264
if type(response['data']) == dict:
63-
for key, value in response['data'].iteritems():
65+
for key, value in response['data'].items():
6466
yield {key: value}
6567
else:
6668
for result in response['data']:
6769
yield result
6870

69-
def request_iter(self, endpoint, security_packet,
70-
secret, request_packet={},
71-
action='get'):
71+
def request_iter(self, endpoint: str, security_packet: Dict[str, str],
72+
secret: str, request_packet: Dict[str, Any] = {},
73+
action: str = 'get') -> Generator[Dict[str, Any], None, None]:
7274
"""
7375
Iterate over the pages of results of a query to data api
7476

learnosity_sdk/request/init.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
import hmac
55
import json
66
import platform
7+
from typing import Any, Dict, Iterable, Optional, Union
78
from learnosity_sdk._version import __version__
89

910
from learnosity_sdk.exceptions import ValidationException
1011

1112

12-
def format_utc_time():
13+
def format_utc_time() -> str:
1314
"Get the current UTC time, formatted for a security timestamp"
1415
now = datetime.datetime.utcnow()
1516
return now.strftime("%Y%m%d-%H%M")
@@ -33,8 +34,9 @@ class Init(object):
3334
__telemetry_enabled = True
3435

3536
def __init__(
36-
self, service, security, secret,
37-
request=None, action=None):
37+
self, service: str, security: Dict[str, Any], secret: str,
38+
request: Optional[Dict[str, Any]] = None, action:Optional[str] = None) -> None:
39+
# Using None as a default value will throw mypy typecheck issues. This should be addressed
3840
self.service = service
3941
self.security = security.copy()
4042
self.secret = secret
@@ -50,10 +52,10 @@ def __init__(
5052
self.set_service_options()
5153
self.security['signature'] = self.generate_signature()
5254

53-
def is_telemetry_enabled(self):
55+
def is_telemetry_enabled(self) -> bool:
5456
return self.__telemetry_enabled
5557

56-
def generate(self, encode=True):
58+
def generate(self, encode: bool = True) -> Union[str, Dict[str, Any]]:
5759
"""
5860
Generate the data necessary to make a request to one of the Learnosity
5961
products/services.
@@ -106,7 +108,7 @@ def generate(self, encode=True):
106108
else:
107109
return output
108110

109-
def get_sdk_meta(self):
111+
def get_sdk_meta(self) -> Dict[str, str]:
110112
return {
111113
'version': self.get_sdk_version(),
112114
'lang': 'python',
@@ -115,15 +117,15 @@ def get_sdk_meta(self):
115117
'platform_version': platform.release()
116118
}
117119

118-
def get_sdk_version(self):
120+
def get_sdk_version(self) -> str:
119121
return __version__
120122

121-
def generate_request_string(self):
123+
def generate_request_string(self) -> Union[str, None]:
122124
if self.request is None:
123125
return None
124126
return json.dumps(self.request, separators=(',', ':'), ensure_ascii=False)
125127

126-
def generate_signature(self):
128+
def generate_signature(self) -> str:
127129

128130
vals = []
129131

@@ -142,7 +144,7 @@ def generate_signature(self):
142144

143145
return self.hash_list(vals)
144146

145-
def validate(self):
147+
def validate(self) -> None:
146148
# Parse the security packet if the user provided it as a string
147149
if isinstance(self.security, str):
148150
self.security = json.loads(self.security)
@@ -185,7 +187,7 @@ def validate(self):
185187
'user_id' not in self.security:
186188
raise ValidationException("questions API requires a user id")
187189

188-
def set_service_options(self):
190+
def set_service_options(self) -> None:
189191
if self.service == 'questions':
190192
self.sign_request_data = False
191193
elif self.service == 'assess':
@@ -235,13 +237,13 @@ def set_service_options(self):
235237
if len(hashed_users) > 0:
236238
self.security['users'] = hashed_users
237239

238-
def hash_list(self, l):
240+
def hash_list(self, l: Iterable[Any]) -> str:
239241
"Hash a list by concatenating values with an underscore"
240242
concatValues = "_".join(l)
241243
signature = hmac.new(bytes(str(self.secret),'utf_8'), msg = bytes(str(concatValues) , 'utf-8'), digestmod = hashlib.sha256).hexdigest()
242244
return '$02$' + signature
243245

244-
def add_telemetry_data(self):
246+
def add_telemetry_data(self) -> None:
245247
if self.__telemetry_enabled:
246248
if 'meta' in self.request:
247249
self.request['meta']['sdk'] = self.get_sdk_meta()
@@ -256,9 +258,9 @@ def add_telemetry_data(self):
256258
"""
257259

258260
@classmethod
259-
def disable_telemetry(cls):
261+
def disable_telemetry(cls) -> None:
260262
cls.__telemetry_enabled = False
261263

262264
@classmethod
263-
def enable_telemetry(cls):
265+
def enable_telemetry(cls) -> None:
264266
cls.__telemetry_enabled = True

learnosity_sdk/utils/lrnuuid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
class Uuid:
44
@staticmethod
5-
def generate():
5+
def generate() -> str:
66
return str(uuid.uuid4())

tests/unit/test_dataapi.py

Lines changed: 84 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,70 @@
11
import unittest
22
import responses
33
from learnosity_sdk.request import DataApi
4-
5-
# This test uses the consumer key and secret for the demos consumer
6-
# this is the only consumer with publicly available keys
7-
security = {
8-
'consumer_key': 'yis0TYCu7U9V4o7M',
9-
'domain': 'demos.learnosity.com'
10-
}
11-
# WARNING: Normally the consumer secret should not be committed to a public
12-
# repository like this one. Only this specific key is publically available.
13-
consumer_secret = '74c5fd430cf1242a527f6223aebd42d30464be22'
14-
request = {
15-
# These items should already exist for the demos consumer
16-
'references': ['item_2', 'item_3'],
17-
'limit': 1
18-
}
19-
action = 'get'
20-
endpoint = 'https://data.learnosity.com/v1/itembank/items'
21-
dummy_responses = [{
22-
'meta': {
23-
'status': True,
24-
'timestamp': 1514874527,
25-
'records': 2,
26-
'next': '1'
27-
},
28-
'data': [{'id': 'a'}]
29-
}, {
30-
'meta': {
31-
'status': True,
32-
'timestamp': 1514874527,
33-
'records': 2
34-
},
35-
'data': [{'id': 'b'}]
36-
}]
37-
4+
from learnosity_sdk.exceptions import DataApiException
385

396
class UnitTestDataApiClient(unittest.TestCase):
407
"""
418
Tests to ensure that the Data API client functions correctly.
429
"""
4310

11+
def setUp(self):
12+
# This test uses the consumer key and secret for the demos consumer
13+
# this is the only consumer with publicly available keys
14+
self.security = {
15+
'consumer_key': 'yis0TYCu7U9V4o7M',
16+
'domain': 'demos.learnosity.com'
17+
}
18+
# WARNING: Normally the consumer secret should not be committed to a public
19+
# repository like this one. Only this specific key is publically available.
20+
self.consumer_secret = '74c5fd430cf1242a527f6223aebd42d30464be22'
21+
self.request = {
22+
# These items should already exist for the demos consumer
23+
'references': ['item_2', 'item_3'],
24+
'limit': 1
25+
}
26+
self.action = 'get'
27+
self.endpoint = 'https://data.learnosity.com/v1/itembank/items'
28+
self.dummy_responses = [{
29+
'meta': {
30+
'status': True,
31+
'timestamp': 1514874527,
32+
'records': 2,
33+
'next': '1'
34+
},
35+
'data': [{'id': 'a'}]
36+
}, {
37+
'meta': {
38+
'status': True,
39+
'timestamp': 1514874527,
40+
'records': 2
41+
},
42+
'data': [{'id': 'b'}]
43+
}]
44+
self.invalid_json = "This is not valid JSON!"
45+
4446
@responses.activate
4547
def test_request(self):
4648
"""
4749
Verify that `request` sends a request after it has been signed
4850
"""
49-
for dummy in dummy_responses:
50-
responses.add(responses.POST, endpoint, json=dummy)
51+
for dummy in self.dummy_responses:
52+
responses.add(responses.POST, self.endpoint, json=dummy)
5153
client = DataApi()
52-
res = client.request(endpoint, security, consumer_secret, request,
53-
action)
54-
assert res.json() == dummy_responses[0]
55-
assert responses.calls[0].request.url == endpoint
54+
res = client.request(self.endpoint, self.security, self.consumer_secret, self.request,
55+
self.action)
56+
assert res.json() == self.dummy_responses[0]
57+
assert responses.calls[0].request.url == self.endpoint
5658
assert 'signature' in responses.calls[0].request.body
5759

5860
@responses.activate
5961
def test_request_iter(self):
6062
"""Verify that `request_iter` returns an iterator of pages"""
61-
for dummy in dummy_responses:
62-
responses.add(responses.POST, endpoint, json=dummy)
63+
for dummy in self.dummy_responses:
64+
responses.add(responses.POST, self.endpoint, json=dummy)
6365
client = DataApi()
64-
pages = client.request_iter(endpoint, security, consumer_secret,
65-
request, action)
66+
pages = client.request_iter(self.endpoint, self.security, self.consumer_secret,
67+
self.request, self.action)
6668
results = []
6769
for page in pages:
6870
results.append(page)
@@ -74,13 +76,47 @@ def test_request_iter(self):
7476
@responses.activate
7577
def test_results_iter(self):
7678
"""Verify that `result_iter` returns an iterator of results"""
77-
for dummy in dummy_responses:
78-
responses.add(responses.POST, endpoint, json=dummy)
79+
self.dummy_responses[1]['data'] = {'id': 'b'}
80+
for dummy in self.dummy_responses:
81+
responses.add(responses.POST, self.endpoint, json=dummy)
7982
client = DataApi()
80-
result_iter = client.results_iter(endpoint, security, consumer_secret,
81-
request, action)
83+
result_iter = client.results_iter(self.endpoint, self.security, self.consumer_secret,
84+
self.request, self.action)
8285
results = list(result_iter)
8386

8487
assert len(results) == 2
8588
assert results[0]['id'] == 'a'
8689
assert results[1]['id'] == 'b'
90+
91+
@responses.activate
92+
def test_results_iter_error_status(self):
93+
"""Verify that a DataApiException is raised http status is not ok"""
94+
for dummy in self.dummy_responses:
95+
responses.add(responses.POST, self.endpoint, json={}, status=500)
96+
client = DataApi()
97+
with self.assertRaisesRegex(DataApiException, "server returned HTTP status 500"):
98+
list(client.results_iter(self.endpoint, self.security, self.consumer_secret,
99+
self.request, self.action))
100+
101+
@responses.activate
102+
def test_results_iter_no_meta_status(self):
103+
"""Verify that a DataApiException is raised when 'meta' 'status' is None"""
104+
for response in self.dummy_responses:
105+
response['meta']['status'] = None
106+
107+
for dummy in self.dummy_responses:
108+
responses.add(responses.POST, self.endpoint, json=dummy)
109+
client = DataApi()
110+
with self.assertRaisesRegex(DataApiException, "server returned unsuccessful status:"):
111+
list(client.results_iter(self.endpoint, self.security, self.consumer_secret,
112+
self.request, self.action))
113+
114+
@responses.activate
115+
def test_results_iter_invalid_response_data(self):
116+
"""Verify that a DataApiException is raised response data isn't valid JSON"""
117+
for dummy in self.dummy_responses:
118+
responses.add(responses.POST, self.endpoint, json=None)
119+
client = DataApi()
120+
with self.assertRaisesRegex(DataApiException, "server returned invalid json: "):
121+
list(client.results_iter(self.endpoint, self.security, self.consumer_secret,
122+
self.request, self.action))

0 commit comments

Comments
 (0)