diff --git a/.travis.yml b/.travis.yml index ddfd0de..71b2c8e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ language: python sudo: false cache: pip -install: pip install tox matrix: include: - python: 2.7 @@ -20,7 +19,14 @@ matrix: - python: 3.8 env: - TOX_ENV=py38 -script: tox -e $TOX_ENV + +install: + - pip install tox + - "if ! [[ $TRAVIS_PYTHON_VERSION = '2.7' ]]; then pip install mypy; fi" + +script: + - "if ! [[ $TRAVIS_PYTHON_VERSION = '2.7' ]]; then mypy pylti1p3/ && mypy --py2 pylti1p3/; fi" + - tox -e $TOX_ENV notifications: email: diff --git a/pylintrc b/pylintrc index 36227dd..7d6f70a 100644 --- a/pylintrc +++ b/pylintrc @@ -72,6 +72,10 @@ disable= # python2-compatible useless-object-inheritance, +# Conditional imports for type checking + unused-import, + import-error, + [REPORTS] # Set the output format. Available formats are text, parseable, colorized, msvs diff --git a/pylti1p3/actions.py b/pylti1p3/actions.py index bfd13bb..73d2da0 100644 --- a/pylti1p3/actions.py +++ b/pylti1p3/actions.py @@ -1,3 +1,9 @@ +import typing as t + +if t.TYPE_CHECKING: + from typing_extensions import Final + + class Action(object): - OIDC_LOGIN = 'oidc_login' - MESSAGE_LAUNCH = 'message_launch' + OIDC_LOGIN = 'oidc_login' # type: Final + MESSAGE_LAUNCH = 'message_launch' # type: Final diff --git a/pylti1p3/assignments_grades.py b/pylti1p3/assignments_grades.py index 785c241..8bc9de6 100644 --- a/pylti1p3/assignments_grades.py +++ b/pylti1p3/assignments_grades.py @@ -1,16 +1,34 @@ +import typing as t + from .exception import LtiException from .lineitem import LineItem +if t.TYPE_CHECKING: + from .service_connector import ServiceConnector, _ServiceConnectorResponse + from .grade import Grade + from mypy_extensions import TypedDict + from typing_extensions import Literal + + _AssignmentsGradersData = TypedDict('_AssignmentsGradersData', { + 'scope': t.List[Literal['https://purl.imsglobal.org/spec/lti-ags/scope/score', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly']], + 'lineitems': str, + 'lineitem': str, + }, total=False) + class AssignmentsGradesService(object): - _service_connector = None - _service_data = None + _service_connector = None # type: ServiceConnector + _service_data = None # type: _AssignmentsGradersData def __init__(self, service_connector, service_data): + # type: (ServiceConnector, _AssignmentsGradersData) -> None self._service_connector = service_connector self._service_data = service_data def put_grade(self, grade, line_item=None): + # type: (Grade, t.Optional[LineItem]) -> _ServiceConnectorResponse if "https://purl.imsglobal.org/spec/lti-ags/scope/score" not in self._service_data['scope']: raise LtiException('Missing required scope') @@ -27,6 +45,7 @@ def put_grade(self, grade, line_item=None): line_item = self.find_or_create_lineitem(line_item) score_url = line_item.get_id() + assert score_url is not None score_url = self._add_url_path_ending(score_url, 'scores') return self._service_connector.make_service_request( self._service_data['scope'], @@ -37,6 +56,7 @@ def put_grade(self, grade, line_item=None): ) def get_lineitems(self): + # type: () -> list if "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem" not in self._service_data['scope']: raise LtiException('Missing required scope') @@ -45,9 +65,12 @@ def get_lineitems(self): self._service_data['lineitems'], accept='application/vnd.ims.lis.v2.lineitemcontainer+json' ) + if not isinstance(line_items['body'], list): + raise LtiException('Unknown response type received for line items') return line_items['body'] def find_lineitem_by_id(self, ln_id): + # type: (t.Optional[str]) -> t.Optional[LineItem] line_items = self.get_lineitems() for line_item in line_items: @@ -57,6 +80,7 @@ def find_lineitem_by_id(self, ln_id): return None def find_lineitem_by_tag(self, tag): + # type: (t.Optional[str]) -> t.Optional[LineItem] line_items = self.get_lineitems() for line_item in line_items: @@ -66,10 +90,13 @@ def find_lineitem_by_tag(self, tag): return None def find_or_create_lineitem(self, new_line_item, find_by='tag'): + # type: (LineItem, Literal['tag', 'id']) -> LineItem if find_by == 'tag': - line_item = self.find_lineitem_by_tag(new_line_item.get_tag()) + tag = new_line_item.get_tag() + line_item = self.find_lineitem_by_tag(tag) elif find_by == 'id': - line_item = self.find_lineitem_by_id(new_line_item.get_id()) + line_id = new_line_item.get_id() + line_item = self.find_lineitem_by_id(line_id) else: raise LtiException('Invalid "find_by" value: ' + str(find_by)) @@ -84,28 +111,38 @@ def find_or_create_lineitem(self, new_line_item, find_by='tag'): content_type='application/vnd.ims.lis.v2.lineitem+json', accept='application/vnd.ims.lis.v2.lineitem+json' ) + if not isinstance(created_line_item['body'], dict): + raise LtiException('Unknown response type received for create line item') return LineItem(created_line_item['body']) def get_grades(self, line_item): + # type: (LineItem) -> list line_item_id = line_item.get_id() line_item_tag = line_item.get_tag() - find_by = None + find_by = None # type: t.Optional[Literal['id', 'tag']] if line_item_id: find_by = 'id' elif line_item_tag: find_by = 'tag' + else: + raise LtiException('Received LineItem did not contain a tag or id') line_item = self.find_or_create_lineitem(line_item, find_by=find_by) - results_url = self._add_url_path_ending(line_item.get_id(), 'results') + line_item_id = line_item.get_id() + assert line_item_id is not None + results_url = self._add_url_path_ending(line_item_id, 'results') scores = self._service_connector.make_service_request( self._service_data['scope'], results_url, accept='application/vnd.ims.lis.v2.resultcontainer+json' ) + if not isinstance(scores['body'], list): + raise LtiException('Unknown response type received for results') return scores['body'] def _add_url_path_ending(self, url, url_path_ending): + # type: (str, str) -> str if '?' in url: url_parts = url.split('?') new_url = url_parts[0] diff --git a/pylti1p3/contrib/django/cookie.py b/pylti1p3/contrib/django/cookie.py index 16e1e4e..66db5d5 100644 --- a/pylti1p3/contrib/django/cookie.py +++ b/pylti1p3/contrib/django/cookie.py @@ -1,15 +1,18 @@ -import django +import sys +import typing as t +import django # type: ignore from pylti1p3.cookie import CookieService -try: - import Cookie -except ImportError: - import http.cookies as Cookie + +if sys.version_info[0] > 2: + import http.cookies as Cookie # type: ignore +else: + import Cookie # type: ignore # Add support for the SameSite attribute (obsolete when PY37 is unsupported). # pylint: disable=protected-access -if 'samesite' not in Cookie.Morsel._reserved: - Cookie.Morsel._reserved.setdefault('samesite', 'SameSite') +if 'samesite' not in Cookie.Morsel._reserved: # type: ignore + Cookie.Morsel._reserved.setdefault('samesite', 'SameSite') # type: ignore class DjangoCookieService(CookieService): diff --git a/pylti1p3/contrib/django/launch_data_storage/cache.py b/pylti1p3/contrib/django/launch_data_storage/cache.py index e1c44f1..8fdf1f8 100644 --- a/pylti1p3/contrib/django/launch_data_storage/cache.py +++ b/pylti1p3/contrib/django/launch_data_storage/cache.py @@ -1,4 +1,4 @@ -from django.core.cache import caches +from django.core.cache import caches # type: ignore from pylti1p3.launch_data_storage.cache import CacheDataStorage diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/admin.py b/pylti1p3/contrib/django/lti1p3_tool_config/admin.py index 8ae8eac..ce15552 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/admin.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/admin.py @@ -1,4 +1,6 @@ +# mypy: ignore-errors from django.contrib import admin + from .models import LtiTool, LtiToolKey diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/apps.py b/pylti1p3/contrib/django/lti1p3_tool_config/apps.py index 876627f..b7402ce 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/apps.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/apps.py @@ -1,4 +1,4 @@ -from django.apps import AppConfig +from django.apps import AppConfig # type: ignore class PyLTI1p3ToolConfig(AppConfig): diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py b/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py index f38ddcd..bd04c68 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py @@ -1,6 +1,7 @@ +# mypy: ignore-errors import django.core.validators -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/models.py b/pylti1p3/contrib/django/lti1p3_tool_config/models.py index 35dac46..d840c77 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/models.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/models.py @@ -1,9 +1,10 @@ +# mypy: ignore-errors import json -from django.core.validators import URLValidator from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ +from django.core.validators import URLValidator from django.db import models +from django.utils.translation import ugettext_lazy as _ from pylti1p3.registration import Registration diff --git a/pylti1p3/contrib/django/oidc_login.py b/pylti1p3/contrib/django/oidc_login.py index 5b4c487..df83e20 100644 --- a/pylti1p3/contrib/django/oidc_login.py +++ b/pylti1p3/contrib/django/oidc_login.py @@ -1,6 +1,7 @@ -from django.http import HttpResponse +from django.http import HttpResponse # type: ignore from pylti1p3.oidc_login import OIDCLogin from pylti1p3.request import Request + from .cookie import DjangoCookieService from .redirect import DjangoRedirect from .request import DjangoRequest diff --git a/pylti1p3/contrib/django/redirect.py b/pylti1p3/contrib/django/redirect.py index 058c40c..038b612 100644 --- a/pylti1p3/contrib/django/redirect.py +++ b/pylti1p3/contrib/django/redirect.py @@ -1,5 +1,5 @@ -from django.http import HttpResponse -from django.shortcuts import redirect +from django.http import HttpResponse # type: ignore +from django.shortcuts import redirect # type: ignore from pylti1p3.redirect import Redirect @@ -8,6 +8,7 @@ class DjangoRedirect(Redirect): _cookie_service = None def __init__(self, location, cookie_service=None): + super(DjangoRedirect, self).__init__() self._location = location self._cookie_service = cookie_service diff --git a/pylti1p3/contrib/django/request.py b/pylti1p3/contrib/django/request.py index 1f9794f..266a61a 100644 --- a/pylti1p3/contrib/django/request.py +++ b/pylti1p3/contrib/django/request.py @@ -6,6 +6,10 @@ class DjangoRequest(Request): _post_only = False _default_params = None + @property + def session(self): + return self._request.session + def __init__(self, request, post_only=False, default_params=None): self.set_request(request) self._post_only = post_only diff --git a/pylti1p3/contrib/flask/redirect.py b/pylti1p3/contrib/flask/redirect.py index 083960b..a92b927 100644 --- a/pylti1p3/contrib/flask/redirect.py +++ b/pylti1p3/contrib/flask/redirect.py @@ -8,6 +8,7 @@ class FlaskRedirect(Redirect): _cookie_service = None def __init__(self, location, cookie_service=None): + super(FlaskRedirect, self).__init__() self._location = location self._cookie_service = cookie_service diff --git a/pylti1p3/contrib/flask/request.py b/pylti1p3/contrib/flask/request.py index b342eeb..b12831c 100644 --- a/pylti1p3/contrib/flask/request.py +++ b/pylti1p3/contrib/flask/request.py @@ -1,15 +1,21 @@ -from flask import request, session as flask_session +import typing as t +from flask import request +from flask import session as flask_session from pylti1p3.request import Request +if t.TYPE_CHECKING: + from pylti1p3.request import SessionLike + class FlaskRequest(Request): - session = None + session = None # type: SessionLike _cookies = None _request_data = None _request_is_secure = None def __init__(self, cookies=None, session=None, request_data=None, request_is_secure=None): + super(FlaskRequest, self).__init__() self._cookies = request.cookies if cookies is None else cookies self.session = flask_session if session is None else session self._request_is_secure = request.is_secure if request_is_secure is None else request_is_secure diff --git a/pylti1p3/contrib/py.typed b/pylti1p3/contrib/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pylti1p3/cookie.py b/pylti1p3/cookie.py index 161f476..5b94da8 100644 --- a/pylti1p3/cookie.py +++ b/pylti1p3/cookie.py @@ -1,14 +1,17 @@ +import typing as t from abc import ABCMeta, abstractmethod class CookieService(object): __metaclass__ = ABCMeta - _cookie_prefix = 'lti1p3' + _cookie_prefix = 'lti1p3' # type: str @abstractmethod def get_cookie(self, name): + # type: (str) -> t.Optional[str] raise NotImplementedError @abstractmethod def set_cookie(self, name, value, exp=3600): + # type: (str, str, int) -> None raise NotImplementedError diff --git a/pylti1p3/cookies_allowed_check.py b/pylti1p3/cookies_allowed_check.py index 314ff49..7b09d57 100644 --- a/pylti1p3/cookies_allowed_check.py +++ b/pylti1p3/cookies_allowed_check.py @@ -1,14 +1,16 @@ import json +import typing as t class CookiesAllowedCheckPage(object): - _params = {} - _protocol = 'http' - _main_text = '' - _click_text = '' - _loading_text = '' + _params = {} # type: t.Mapping[str, str] + _protocol = 'http' # type: str + _main_text = '' # type: str + _click_text = '' # type: str + _loading_text = '' # type: str def __init__(self, params, protocol, main_text, click_text, loading_text, *args, **kwargs): + # type: (t.Mapping[str, str], str, str, str, str, *None, **None) -> None # pylint: disable=unused-argument self._params = params self._protocol = protocol @@ -17,6 +19,7 @@ def __init__(self, params, protocol, main_text, click_text, loading_text, *args, self._loading_text = loading_text def get_css_block(self): + # type: () -> str css_block = """\ body { font-family: Geneva, Arial, Helvetica, sans-serif; @@ -25,6 +28,7 @@ def get_css_block(self): return css_block def get_js_block(self): + # type: () -> str js_block = """\ var siteProtocol = '%s'; var urlParams = %s; @@ -84,9 +88,11 @@ def get_js_block(self): return js_block def get_header_block(self): + # type: () -> str return '' def get_html(self): + # type: () -> str html = """\ diff --git a/pylti1p3/deep_link.py b/pylti1p3/deep_link.py index 5bcd304..419d6a2 100644 --- a/pylti1p3/deep_link.py +++ b/pylti1p3/deep_link.py @@ -1,15 +1,44 @@ import sys import time +import typing as t import uuid -import jwt + +import jwt # type: ignore + +if t.TYPE_CHECKING: + from .deep_link_resource import DeepLinkResource + from .registration import Registration + from .deployment import Deployment + from typing_extensions import Literal + from mypy_extensions import TypedDict + + _DeepLinkData = TypedDict( + '_DeepLinkData', + { + # Required data: + 'deep_link_return_url': str, + 'accept_types': t.List[Literal['link', 'ltiResourceLink']], + 'accept_presentation_document_targets': t.List[ + Literal['iframe', 'window', 'embed']], + + # Optional data + 'accept_multiple': t.Union[bool, Literal['true', 'false']], + 'auto_create': t.Union[bool, Literal['true', 'false']], + 'title': str, + 'text': str, + 'data': object, + }, + total=False, + ) class DeepLink(object): - _registration = None - _deployment_id = None - _deep_link_settings = None + _registration = None # type: Registration + _deployment_id = None # type: str + _deep_link_settings = None # type: _DeepLinkData def __init__(self, registration, deployment_id, deep_link_settings): + # type: (Registration, str, _DeepLinkData) -> None self._registration = registration self._deployment_id = deployment_id self._deep_link_settings = deep_link_settings @@ -18,6 +47,7 @@ def _generate_nonce(self): return uuid.uuid4().hex + uuid.uuid1().hex def get_message_jwt(self, resources): + # type: (t.Sequence[DeepLinkResource]) -> t.Dict[str, object] message_jwt = { 'iss': self._registration.get_client_id(), 'aud': [self._registration.get_issuer()], @@ -42,10 +72,12 @@ def encode_jwt(self, message): return encoded_jwt.decode('utf-8') if sys.version_info[0] > 2 else encoded_jwt def get_response_jwt(self, resources): + # type: (t.Sequence[DeepLinkResource]) -> str message_jwt = self.get_message_jwt(resources) return self.encode_jwt(message_jwt) def get_response_form_html(self, jwt_val): + # type: (str) -> str html = '
' \ '