Skip to content

Commit 3b617f3

Browse files
[FEATURE] Adds type validation GitHub action (#96)
- Adds type checking to github CI, updates typing & fixes some MyPy issues
1 parent 4f029d8 commit 3b617f3

File tree

11 files changed

+74
-43
lines changed

11 files changed

+74
-43
lines changed

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ jobs:
2323
- name: Run Pre-commit Hooks
2424
run: pre-commit run --all-files
2525

26+
type_check:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: Checkout code
30+
uses: actions/checkout@v4
31+
32+
- name: Set up Python
33+
uses: actions/setup-python@v5
34+
with:
35+
python-version: '3.9'
36+
37+
- name: Install Test Dependencies
38+
run: pip install .[test]
39+
40+
- name: Type Checking with Mypy
41+
run: mypy
42+
2643
tests:
2744
runs-on: ubuntu-latest
2845
strategy:

ChangeLog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
88
## [unreleased] - 2024-11-01
99
### Added
1010
- Added pre-commit hooks and Github CI action for code formatting and linting.
11+
- Added MyPy with strict settings to enforce type hints (and Github CI action).
1112

1213
## [v0.3.11] - 2024-11-01
1314
### Fixed

docs/quickstart/assessment/standalone_assessment.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,15 +322,15 @@
322322
# Set up the HTML page template, for serving to the built-in Python web server
323323
class LearnosityServer(BaseHTTPRequestHandler):
324324

325-
def createResponse(self,response):
325+
def createResponse(self, response: str) -> None:
326326
# Send headers and data back to the client.
327327
self.send_response(200)
328328
self.send_header("Content-type", "text/html")
329329
self.end_headers()
330330
# Send the response to the client.
331331
self.wfile.write(response.encode("utf-8"))
332332

333-
def do_GET(self):
333+
def do_GET(self) -> None:
334334

335335
if self.path.endswith("/"):
336336

@@ -542,7 +542,7 @@ def do_GET(self):
542542

543543
self.createResponse(response)
544544

545-
def main():
545+
def main() -> None:
546546
web_server = HTTPServer((host, port), LearnosityServer)
547547
print("Server started http://%s:%s. Press Ctrl-c to quit." % (host, port))
548548
try:

learnosity_sdk/request/init.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ class Init(object):
3535

3636
def __init__(
3737
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
38+
request: Optional[Union[Dict[str, Any], str]] = None, action:Optional[str] = None) -> None:
4039
self.service = service
4140
self.security = security.copy()
4241
self.secret = secret
4342
self.request = request
44-
if hasattr(request, 'copy'):
43+
# TODO: Fix improper handling when request is a string
44+
if isinstance(request, dict):
4545
self.request = request.copy()
4646
self.action = action
4747

@@ -193,7 +193,7 @@ def set_service_options(self) -> None:
193193
elif self.service == 'assess':
194194
self.sign_request_data = False
195195

196-
if 'questionsApiActivity' in self.request:
196+
if self.request is not None and 'questionsApiActivity' in self.request:
197197
questionsApi = self.request['questionsApiActivity']
198198

199199
if 'domain' in self.security:
@@ -223,14 +223,14 @@ def set_service_options(self) -> None:
223223
self.request['questionsApiActivity'].update(questionsApi)
224224

225225
elif self.service == 'items' or self.service == 'reports':
226-
if 'user_id' not in self.security and \
227-
'user_id' in self.request:
226+
if self.request is not None and ('user_id' not in self.security and 'user_id' in self.request):
228227
self.security['user_id'] = self.request['user_id']
229228

230229
elif self.service == 'events':
231230
self.sign_request_data = False
232231
hashed_users = {}
233-
for user in self.request.get('users', []):
232+
users = self.request.get('users', []) if self.request is not None else []
233+
for user in users:
234234
concat = "{}{}".format(user, self.secret)
235235
hashed_users[user] = hashlib.sha256(concat.encode('utf-8')).hexdigest()
236236

@@ -244,7 +244,7 @@ def hash_list(self, l: Iterable[Any]) -> str:
244244
return '$02$' + signature
245245

246246
def add_telemetry_data(self) -> None:
247-
if self.__telemetry_enabled:
247+
if self.request is not None and self.__telemetry_enabled:
248248
if 'meta' in self.request:
249249
self.request['meta']['sdk'] = self.get_sdk_meta()
250250
else:

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[tool.mypy]
2+
strict = true
3+
files = ["learnosity_sdk", "docs", "tests"]

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
'pytest-cov >=2.8.1',
2525
'pytest-subtests',
2626
'responses >=0.8.1',
27+
'types-requests',
28+
'types-Jinja2',
29+
'mypy',
2730
]
2831

2932
# Extract the markdown content of the README to be sent to Pypi as the project description page.

tests/integration/test_dataapi.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Any, Dict, List, cast
12
import unittest
23
import os
34
from learnosity_sdk.request import DataApi
@@ -33,7 +34,7 @@
3334
class IntegrationTestDataApiClient(unittest.TestCase):
3435

3536
@staticmethod
36-
def __build_base_url():
37+
def __build_base_url() -> str:
3738
env = os.environ
3839
env_domain = ''
3940
region_domain = '.learnosity.com'
@@ -52,7 +53,7 @@ def __build_base_url():
5253

5354
return base_url
5455

55-
def test_real_request(self):
56+
def test_real_request(self) -> None:
5657
"""Make a request against Data Api to ensure the SDK works"""
5758
client = DataApi()
5859
res = client.request(self.__build_base_url() + items_endpoint, security, consumer_secret, items_request,
@@ -62,9 +63,10 @@ def test_real_request(self):
6263
assert len(returned_json['data']) > 0
6364

6465
returned_ref = returned_json['data'][0]['reference']
65-
assert returned_ref in items_request['references']
66+
references: List[str] = cast(List[str], items_request['references'])
67+
assert returned_ref in references
6668

67-
def test_paging(self):
69+
def test_paging(self) -> None:
6870
"""Verify that paging works"""
6971
client = DataApi()
7072
pages = client.request_iter(self.__build_base_url() + items_endpoint, security, consumer_secret,
@@ -78,14 +80,14 @@ def test_paging(self):
7880
assert len(results) == 2
7981
assert results == {'item_2', 'item_3'}
8082

81-
def test_real_request_with_special_characters(self):
83+
def test_real_request_with_special_characters(self) -> None:
8284
"""Make a request against Data Api to ensure the SDK works"""
8385
client = DataApi()
8486

8587
# Add a reference containing special characters to ensure
8688
# signature creation works with special characters in the request
87-
local_items_request = items_request.copy() # prevent modifying the base fixture
88-
local_items_request['references'] = items_request['references'].copy() # prevent modifying the base fixture's list
89+
local_items_request: Dict[str, Any] = items_request.copy() # prevent modifying the base fixture
90+
local_items_request['references'] = cast(List[str], items_request['references'])[:] # prevent modifying the base fixture's list
8991
local_items_request['references'].append('тест')
9092

9193
res = client.request(self.__build_base_url() + items_endpoint, security, consumer_secret, items_request,
@@ -95,9 +97,9 @@ def test_real_request_with_special_characters(self):
9597
assert len(returned_json['data']) > 0
9698

9799
returned_ref = returned_json['data'][0]['reference']
98-
assert returned_ref in items_request['references']
100+
assert returned_ref in cast(List[str], items_request['references'])
99101

100-
def test_real_question_request(self):
102+
def test_real_question_request(self) -> None:
101103
"""Make a request against Data Api to ensure the SDK works"""
102104
client = DataApi()
103105

@@ -114,7 +116,7 @@ def test_real_question_request(self):
114116

115117
assert keys == {'py-sdk-test-2019-1', 'py-sdk-test-2019-2'}
116118

117-
def test_question_paging(self):
119+
def test_question_paging(self) -> None:
118120
"""Verify that paging works"""
119121
client = DataApi()
120122

tests/unit/test_dataapi.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Any, Dict, cast
12
import unittest
23
import responses
34
from learnosity_sdk.request import DataApi
@@ -8,7 +9,7 @@ class UnitTestDataApiClient(unittest.TestCase):
89
Tests to ensure that the Data API client functions correctly.
910
"""
1011

11-
def setUp(self):
12+
def setUp(self) -> None:
1213
# This test uses the consumer key and secret for the demos consumer
1314
# this is the only consumer with publicly available keys
1415
self.security = {
@@ -44,7 +45,7 @@ def setUp(self):
4445
self.invalid_json = "This is not valid JSON!"
4546

4647
@responses.activate
47-
def test_request(self):
48+
def test_request(self) -> None:
4849
"""
4950
Verify that `request` sends a request after it has been signed
5051
"""
@@ -55,10 +56,10 @@ def test_request(self):
5556
self.action)
5657
assert res.json() == self.dummy_responses[0]
5758
assert responses.calls[0].request.url == self.endpoint
58-
assert 'signature' in responses.calls[0].request.body
59+
assert 'signature' in cast(Dict[str, Any], responses.calls[0].request.body)
5960

6061
@responses.activate
61-
def test_request_iter(self):
62+
def test_request_iter(self) -> None:
6263
"""Verify that `request_iter` returns an iterator of pages"""
6364
for dummy in self.dummy_responses:
6465
responses.add(responses.POST, self.endpoint, json=dummy)
@@ -74,7 +75,7 @@ def test_request_iter(self):
7475
assert results[1]['data'][0]['id'] == 'b'
7576

7677
@responses.activate
77-
def test_results_iter(self):
78+
def test_results_iter(self) -> None:
7879
"""Verify that `result_iter` returns an iterator of results"""
7980
self.dummy_responses[1]['data'] = {'id': 'b'}
8081
for dummy in self.dummy_responses:
@@ -89,7 +90,7 @@ def test_results_iter(self):
8990
assert results[1]['id'] == 'b'
9091

9192
@responses.activate
92-
def test_results_iter_error_status(self):
93+
def test_results_iter_error_status(self) -> None:
9394
"""Verify that a DataApiException is raised http status is not ok"""
9495
for dummy in self.dummy_responses:
9596
responses.add(responses.POST, self.endpoint, json={}, status=500)
@@ -99,10 +100,12 @@ def test_results_iter_error_status(self):
99100
self.request, self.action))
100101

101102
@responses.activate
102-
def test_results_iter_no_meta_status(self):
103+
def test_results_iter_no_meta_status(self) -> None:
103104
"""Verify that a DataApiException is raised when 'meta' 'status' is None"""
104105
for response in self.dummy_responses:
105-
response['meta']['status'] = None
106+
# This is for typing purposes only, and should always be True
107+
if isinstance(response['meta'], dict):
108+
response['meta']['status'] = None
106109

107110
for dummy in self.dummy_responses:
108111
responses.add(responses.POST, self.endpoint, json=dummy)
@@ -112,7 +115,7 @@ def test_results_iter_no_meta_status(self):
112115
self.request, self.action))
113116

114117
@responses.activate
115-
def test_results_iter_invalid_response_data(self):
118+
def test_results_iter_invalid_response_data(self) -> None:
116119
"""Verify that a DataApiException is raised response data isn't valid JSON"""
117120
for dummy in self.dummy_responses:
118121
responses.add(responses.POST, self.endpoint, json=None)

tests/unit/test_init.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import collections
2+
from typing import Dict, Optional
23
import unittest
34

45
import learnosity_sdk.request
56

67
ServiceTestSpec = collections.namedtuple(
7-
"TestSpec", [
8+
"ServiceTestSpec", [
89
"service",
910
"valid",
1011
"security", # security can be None to use the default, or an Dict to extend the default
@@ -111,7 +112,7 @@ class TestServiceRequests(unittest.TestCase):
111112
domain = 'localhost'
112113
timestamp = '20140626-0528'
113114

114-
def test_init_generate(self):
115+
def test_init_generate(self) -> None:
115116
"""
116117
Test that Init.generate() generates the desired initOptions
117118
"""
@@ -125,7 +126,7 @@ def test_init_generate(self):
125126
self.assertFalse(init.is_telemetry_enabled(), 'Telemetry still enabled')
126127
self.assertEqual(t.signature, init.generate_signature(), 'Signature mismatch')
127128

128-
def test_no_parameter_mangling(self):
129+
def test_no_parameter_mangling(self) -> None:
129130
""" Test that Init.generate() does not modify its parameters """
130131
learnosity_sdk.request.Init.enable_telemetry()
131132
for t in ServiceTests:
@@ -143,7 +144,7 @@ def test_no_parameter_mangling(self):
143144
self.assertEqual(security, security_copy, 'Original security modified by SDK')
144145
self.assertEqual(t.request, request_copy, 'Original request modified by SDK')
145146

146-
def _prepare_security(self, add_security=None):
147+
def _prepare_security(self, add_security: Optional[Dict[str, str]]=None) -> Dict[str, str]:
147148
# TODO(cera): Much more validation
148149
security = {
149150
'consumer_key': self.key,

tests/unit/test_sdk.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import collections
2+
from typing import Any, Dict
23
import unittest
34

45
SdkTestSpec = collections.namedtuple(
5-
"TestSpec", ["import_line", "object"])
6+
"SdkTestSpec", ["import_line", "object"])
67

78
SdkImportTests = [
89
SdkTestSpec(
@@ -34,9 +35,9 @@
3435
),
3536
]
3637

37-
def _run_test(t):
38-
globals = {}
39-
locals = {}
38+
def _run_test(t: Any) -> None:
39+
globals: Dict[str, Any] = {}
40+
locals: Dict[str, Any] = {}
4041
exec(t.import_line, globals, locals)
4142
eval(t.object, globals, locals)
4243

@@ -45,15 +46,15 @@ class TestSdkImport(unittest.TestCase):
4546
Tests importing the SDK
4647
"""
4748

48-
def test_sdk_imports(self):
49+
def test_sdk_imports(self) -> None:
4950
for t in SdkImportTests:
5051
_run_test(t)
5152

52-
class TestSdkImport(unittest.TestCase):
53+
class TestSdkModuleImport(unittest.TestCase):
5354
"""
5455
Tests importing the modules
5556
"""
5657

57-
def test_sdk_imports(self):
58+
def test_sdk_imports(self) -> None:
5859
for t in SdkModuleTests:
5960
_run_test(t)

0 commit comments

Comments
 (0)