Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [unreleased] - 2025-12-11
### Added
- Added Data API configuration to request signatures
- Added Data API Demo to standalone demos

### Fixed
- Fixed mypy errors failing lint check on ci build
- Fixed missing import on setuptools types for testing

## [v0.3.13] - 2026-01-08
### Added
- Added SDK language and version to request metadata
Expand Down
14 changes: 7 additions & 7 deletions learnosity_sdk/request/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ def generate(self, encode: bool = True) -> Union[str, Dict[str, Any]]:
del output['domain']

# Stringify the request packet if necessary
if self.request is not None:
if isinstance(self.request, dict):
output.update(self.request)

elif self.service == 'events':
output['security'] = self.security
output['config'] = self.request
elif self.service == 'assess':
if self.request is not None:
if isinstance(self.request, dict):
output.update(self.request)
elif self.service == 'data':
# We ignore the encode param for data API
Expand Down Expand Up @@ -193,7 +193,7 @@ def set_service_options(self) -> None:
elif self.service == 'assess':
self.sign_request_data = False

if self.request is not None and 'questionsApiActivity' in self.request:
if isinstance(self.request, dict) and 'questionsApiActivity' in self.request and isinstance(self.request['questionsApiActivity'], dict):
Comment thread
michael-linnane-lrn marked this conversation as resolved.
questionsApi = self.request['questionsApiActivity']

if 'domain' in self.security:
Expand Down Expand Up @@ -223,13 +223,13 @@ def set_service_options(self) -> None:
self.request['questionsApiActivity'].update(questionsApi)

elif self.service == 'items' or self.service == 'reports':
if self.request is not None and ('user_id' not in self.security and 'user_id' in self.request):
if isinstance(self.request, dict) and ('user_id' not in self.security and 'user_id' in self.request):
self.security['user_id'] = self.request['user_id']

elif self.service == 'events':
self.sign_request_data = False
hashed_users = {}
users = self.request.get('users', []) if self.request is not None else []
users = self.request.get('users', []) if isinstance(self.request, dict) else []
for user in users:
concat = "{}{}".format(user, self.secret)
hashed_users[user] = hashlib.sha256(concat.encode('utf-8')).hexdigest()
Expand All @@ -244,8 +244,8 @@ def hash_list(self, l: Iterable[Any]) -> str:
return '$02$' + signature

def add_telemetry_data(self) -> None:
if self.request is not None and self.__telemetry_enabled:
if 'meta' in self.request:
if isinstance(self.request, dict) and self.__telemetry_enabled:
if 'meta' in self.request and isinstance(self.request['meta'], dict):
self.request['meta']['sdk'] = self.get_sdk_meta()
else:
self.request['meta'] = {
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import setuptools

# Loads __version__ using exec as setup.py can't import its own package
version = {}
version: dict[str, str] = {}
version_file = 'learnosity_sdk/_version.py'
exec(open(version_file).read(), { '__builtins__': None }, version)
if '__version__' not in version:
Expand All @@ -27,6 +27,7 @@
'responses >=0.8.1',
'types-requests',
'types-Jinja2',
'types-setuptools',
'mypy',
]

Expand Down
270 changes: 269 additions & 1 deletion tests/unit/test_init.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import collections
from typing import Dict, Optional
import json
from typing import Any, Dict, List, Optional, Tuple
import unittest

import learnosity_sdk.request


def as_dict(value: object) -> Dict[str, Any]:
"""
Narrow ``value`` to ``Dict[str, Any]`` for both mypy and the test runner.

Unlike ``typing.cast``, ``isinstance`` here is a real, always-executed
check: mismatches fail the test with a clear message instead of being
silently trusted.
"""
if not isinstance(value, dict):
raise AssertionError(f'Expected a dict, got {type(value).__name__}: {value!r}')
return value

ServiceTestSpec = collections.namedtuple(
"ServiceTestSpec", [
"service",
Expand Down Expand Up @@ -154,3 +168,257 @@ def _prepare_security(self, add_security: Optional[Dict[str, str]]=None) -> Dict
if add_security is not None:
security.update(add_security)
return security


class TestRequestTypeHandling(unittest.TestCase):
"""
Tests for the ``isinstance(self.request, dict)`` guards added to ``Init``.

These guards make request handling robust when ``self.request`` is ``None``
or a non-dict value (e.g. a JSON array passed as a string), and when nested
keys such as ``questionsApiActivity`` are not dicts. Before the guards these
inputs raised at runtime:
* an events request that parsed to a list raised
``AttributeError: 'list' object has no attribute 'get'``
* a non-dict ``questionsApiActivity`` raised
``AttributeError: 'str' object has no attribute 'keys'``
The tests below lock in the defensive behaviour so it cannot regress.
"""

key = 'yis0TYCu7U9V4o7M'
secret = '74c5fd430cf1242a527f6223aebd42d30464be22'
domain = 'localhost'
timestamp = '20140626-0528'

def setUp(self) -> None:
# Behaviour under test is independent of telemetry. Disable it for
# determinism and restore the default (enabled) afterwards so test
# ordering (pytest-randomly) cannot leak state between tests.
learnosity_sdk.request.Init.disable_telemetry()
self.addCleanup(learnosity_sdk.request.Init.enable_telemetry)

def _security(self, add: Optional[Dict[str, str]] = None) -> Dict[str, str]:
security = {
'consumer_key': self.key,
'domain': self.domain,
'timestamp': self.timestamp,
}
if add is not None:
security.update(add)
return security

def test_none_request_does_not_raise(self) -> None:
"""A ``None`` request must be handled by every service without raising."""
cases: List[Tuple[str, Dict[str, str]]] = [
('items', self._security()),
('reports', self._security()),
('events', self._security()),
('assess', self._security({'user_id': '$ANONYMIZED_USER_ID'})),
('questions', self._security({'user_id': '$ANONYMIZED_USER_ID'})),
]
for service, security in cases:
with self.subTest(service=service):
init = learnosity_sdk.request.Init(
service, security, self.secret, request=None)
# A signature must still be produced and be well-formed.
self.assertTrue(init.generate_signature().startswith('$02$'))

def test_events_non_dict_request_is_ignored(self) -> None:
"""
An events request that parses to a non-dict (JSON array) must not add
hashed users to the security packet and must not raise. Regression for
``AttributeError: 'list' object has no attribute 'get'``.
"""
init = learnosity_sdk.request.Init(
'events', self._security(), self.secret, request='[1, 2, 3]')
self.assertNotIn('users', init.security)

def test_non_dict_request_is_ignored_in_generate(self) -> None:
"""
For 'questions' and 'assess', a request that parses to a non-dict must
be skipped by ``generate()`` rather than merged into the output.
"""
for service in ('questions', 'assess'):
with self.subTest(service=service):
init = learnosity_sdk.request.Init(
service, self._security({'user_id': '$ANONYMIZED_USER_ID'}),
self.secret, request='[1, 2, 3]')
output = init.generate(encode=False)
# A request passed as a string is always JSON-encoded on output.
parsed = json.loads(output) if isinstance(output, str) else output
self.assertIsInstance(parsed, dict)
self.assertNotIn(1, parsed.values())

def test_questions_generate_merges_dict_request(self) -> None:
"""
'questions' ``generate()`` must merge a dict request into the output
(and strip ``domain`` from the security packet).
"""
init = learnosity_sdk.request.Init(
'questions', self._security({'user_id': '$ANONYMIZED_USER_ID'}),
self.secret, request={'foo': 'bar'})
output = as_dict(init.generate(encode=False))
self.assertEqual(output['foo'], 'bar')
self.assertNotIn('domain', output)

def test_assess_generate_merges_dict_request(self) -> None:
"""'assess' ``generate()`` must merge a dict request into the output."""
init = learnosity_sdk.request.Init(
'assess', self._security({'user_id': '$ANONYMIZED_USER_ID'}),
self.secret, request={'foo': 'bar'})
output = as_dict(init.generate(encode=False))
self.assertEqual(output['foo'], 'bar')

def test_assess_questions_api_activity_is_signed(self) -> None:
"""
When an 'assess' request contains a ``questionsApiActivity`` dict, it
must be replaced with a signed activity (consumer_key, timestamp,
user_id, signature) while preserving any additional keys.
"""
init = learnosity_sdk.request.Init(
'assess', self._security({'user_id': '$ANONYMIZED_USER_ID'}),
self.secret, request={'questionsApiActivity': {'foo': 'bar'}})

request = as_dict(init.request)
activity = request['questionsApiActivity']
self.assertEqual(activity['consumer_key'], self.key)
self.assertEqual(activity['user_id'], '$ANONYMIZED_USER_ID')
self.assertEqual(activity['timestamp'], self.timestamp)
self.assertTrue(activity['signature'].startswith('$02$'))
# Extra keys supplied by the caller must be retained.
self.assertEqual(activity['foo'], 'bar')

def test_assess_questions_api_activity_non_dict_is_ignored(self) -> None:
"""
A non-dict ``questionsApiActivity`` must be left untouched and must not
raise. Regression for ``AttributeError: 'str' object has no attribute
'keys'``.
"""
init = learnosity_sdk.request.Init(
'assess', self._security({'user_id': '$ANONYMIZED_USER_ID'}),
self.secret, request={'questionsApiActivity': 'not-a-dict'})

request = as_dict(init.request)
self.assertEqual(request['questionsApiActivity'], 'not-a-dict')

def test_assess_activity_uses_activity_domain_and_strips_stale_keys(self) -> None:
"""
When the security packet has no ``domain``, the domain from
``questionsApiActivity`` is used for signing; stale identity keys
supplied by the caller are stripped and replaced, while other keys are
preserved.
"""
security = {
'consumer_key': self.key,
'timestamp': self.timestamp,
'user_id': '$ANONYMIZED_USER_ID',
}
init = learnosity_sdk.request.Init(
'assess', security, self.secret,
request={'questionsApiActivity': {
'domain': 'custom.learnosity.com',
'consumer_key': 'STALE',
'signature': 'STALE_SIG',
'extra': 'keep-me',
}})

request = as_dict(init.request)
activity = request['questionsApiActivity']
# Stale identity keys are replaced with freshly generated values.
self.assertEqual(activity['consumer_key'], self.key)
self.assertTrue(activity['signature'].startswith('$02$'))
self.assertNotEqual(activity['signature'], 'STALE_SIG')
# Non-identity keys supplied by the caller survive.
self.assertEqual(activity['extra'], 'keep-me')

def test_assess_activity_falls_back_to_default_domain(self) -> None:
"""
When neither the security packet nor the activity supplies a domain,
the activity is still signed using the default assess domain.
"""
security = {
'consumer_key': self.key,
'timestamp': self.timestamp,
'user_id': '$ANONYMIZED_USER_ID',
}
init = learnosity_sdk.request.Init(
'assess', security, self.secret,
request={'questionsApiActivity': {'extra': 'keep-me'}})

request = as_dict(init.request)
activity = request['questionsApiActivity']
self.assertTrue(activity['signature'].startswith('$02$'))
self.assertEqual(activity['extra'], 'keep-me')

def test_items_user_id_copied_from_request(self) -> None:
"""
For 'items'/'reports', when security lacks ``user_id`` but the request
provides one, it must be copied into the security packet.
"""
for service in ('items', 'reports'):
with self.subTest(service=service):
init = learnosity_sdk.request.Init(
service, self._security(), self.secret,
request={'user_id': 'req-user', 'items': ['item_1']})
self.assertEqual(init.security['user_id'], 'req-user')

def test_items_user_id_in_security_not_overwritten(self) -> None:
"""An existing security ``user_id`` must take precedence over the request."""
init = learnosity_sdk.request.Init(
'items', self._security({'user_id': 'sec-user'}), self.secret,
request={'user_id': 'req-user', 'items': ['item_1']})
self.assertEqual(init.security['user_id'], 'sec-user')


class TestTelemetryMetaHandling(unittest.TestCase):
"""
Tests for the ``meta`` guards in ``Init.add_telemetry_data()``.

The SDK meta block must attach safely whether or not the request already
contains a ``meta`` key and regardless of that key's type. A non-dict
``meta`` previously raised ``TypeError: 'str' object does not support item
assignment``.
"""

key = 'yis0TYCu7U9V4o7M'
secret = '74c5fd430cf1242a527f6223aebd42d30464be22'

def setUp(self) -> None:
learnosity_sdk.request.Init.enable_telemetry()
self.addCleanup(learnosity_sdk.request.Init.enable_telemetry)

def _security(self) -> Dict[str, str]:
return {
'consumer_key': self.key,
'domain': 'localhost',
'timestamp': '20140626-0528',
}

def test_sdk_meta_added_when_no_meta_present(self) -> None:
"""When telemetry is enabled and no ``meta`` exists, one is created."""
init = learnosity_sdk.request.Init(
'items', self._security(), self.secret, request={'items': ['item_1']})
request = as_dict(init.request)
self.assertIn('sdk', request['meta'])

def test_existing_meta_dict_is_preserved(self) -> None:
"""An existing ``meta`` dict must keep its keys and gain the sdk block."""
init = learnosity_sdk.request.Init(
'items', self._security(), self.secret,
request={'items': ['item_1'], 'meta': {'existing': 'value'}})
request = as_dict(init.request)
self.assertEqual(request['meta']['existing'], 'value')
self.assertIn('sdk', request['meta'])

def test_non_dict_meta_is_replaced(self) -> None:
"""
A non-dict ``meta`` must be replaced with a dict holding the sdk block.
Regression for ``TypeError: 'str' object does not support item
assignment``.
"""
init = learnosity_sdk.request.Init(
'items', self._security(), self.secret,
request={'items': ['item_1'], 'meta': 'not-a-dict'})
request = as_dict(init.request)
self.assertIsInstance(request['meta'], dict)
self.assertIn('sdk', request['meta'])
Loading