diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 875408c..f7180cd 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -14,5 +14,5 @@ jobs: with: python-version: ${{ matrix.python }} - run: pip install --upgrade pip - - run: pip install pylint==2.8.1 "tox<4" + - run: pip install "tox<4" - run: tox -e py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..60f0637 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +# File introduces automated checks triggered on git events +# to enable run `pip install pre-commit && pre-commit install` + +repos: + - repo: local + hooks: + - id: black + name: black + description: "Black: The uncompromising Python code formatter" + entry: black + language: python + minimum_pre_commit_version: 2.9.2 + require_serial: true + types_or: [ python, pyi ] diff --git a/pylintrc b/pylintrc index 4a48565..948bbe4 100644 --- a/pylintrc +++ b/pylintrc @@ -1,287 +1,631 @@ -[MASTER] - -# Specify a configuration file. -#rcfile= +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\' represents the directory delimiter on Windows systems, it +# can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). -#init-hook='' +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=settings, wsgi.py +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= # Pickle collected data for later comparisons. persistent=yes -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -#load-plugins=pylint_django +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.6 -[MESSAGES CONTROL] +# Discover python modules and packages in the file system subtree. +recursive=no -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. -#enable= +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). -disable= -# Never going to use these -# I0011: Locally disabling W0232 -# W0141: Used builtin function 'map' -# W0142: Used * or ** magic -# R0921: Abstract class not referenced -# R0922: Abstract class is only referenced 1 times - I0011,W0141,W0142,R0921,R0922, - -# Django makes classes that trigger these -# W0232: Class has no __init__ method - W0232, - -# Might use these when the code is in better shape -# C0302: Too many lines in module -# R0201: Method could be a function -# R0901: Too many ancestors -# R0902: Too many instance attributes -# R0903: Too few public methods (1/2) -# R0904: Too many public methods -# R0911: Too many return statements -# R0912: Too many branches -# R0913: Too many arguments -# R0914: Too many local variables - C0302,R0201,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914, -# W0511: TODOs etc - W0511, -# E1103: maybe no member - E1103, -# C0111: missing docstring (handled by pep257) - C0111, - - duplicate-code, - import-outside-toplevel, - no-else-return, - too-many-boolean-expressions, - consider-using-with, - -# We can decide if names are invalid on our own - invalid-name, - -# Conditional imports for type checking - unused-import, - import-error, - -# Compatibility with Python 2 - useless-object-inheritance, - super-with-arguments, - raise-missing-from, +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no -[REPORTS] +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html -output-format=text -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no +[BASIC] -# Tells whether to display a full report or only the messages -reports=no +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= -[TYPECHECK] +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). -ignored-classes=SQLObject +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E0201 when accessed. Python regular -# expressions are accepted. -generated-members= - REQUEST, - acl_users, - aq_parent, - objects, - DoesNotExist, - can_read, - can_write, - get_url, - size, - content, - status_code, -# For factory_boy factories - create +# Naming style matching correct class names. +class-naming-style=PascalCase +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= -[BASIC] +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= -# Regular expression which should only match correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 -# Regular expression which should only match correct module level names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ +# Naming style matching correct function names. +function-naming-style=snake_case -# Regular expression which should only match correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= -# Regular expression which should only match correct function names -function-rgx=[a-z_][a-z0-9_]{2,30}$ +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ -# Regular expression which should only match correct method names -method-rgx=([a-z_][a-z0-9_]{2,60}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*)$ +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= -# Regular expression which should only match correct instance attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no -# Regular expression which should only match correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any -# Regular expression which should only match correct variable names -variable-rgx=[a-z_][a-z0-9_]{2,30}$ +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= -# Regular expression which should only match correct list comprehension / -# generator expression variable names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ +# Naming style matching correct method names. +method-naming-style=snake_case -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata +# Naming style matching correct module names. +module-naming-style=snake_case -# Regular expression which should only match functions or classes name which do -# not require a docstring -no-docstring-rgx=__.*__|test_.*|setUp|tearDown +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= -[MISCELLANEOUS] +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=BaseException, + Exception [FORMAT] +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + # Maximum number of characters on a single line. max-line-length=120 -# Maximum number of lines in a module +# Maximum number of lines in a module. max-module-lines=1000 -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=bad-inline-option, + deprecated-pragma, + duplicate-code, + file-ignored, + invalid-name, + locally-disabled, + missing-class-docstring, + missing-function-docstring, + missing-module-docstring, + no-self-use, + raw-checker-failed, + suppressed-message, + too-few-public-methods, + too-many-arguments, + too-many-instance-attributes, + too-many-locals, + too-many-public-methods, + use-symbolic-message-instead, + useless-option-value, + useless-suppression, + + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes [SIMILARITIES] +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + # Minimum lines number of a similarity. min-similarity-lines=4 -# Ignore comments when computing similarities. -ignore-comments=yes -# Ignore docstrings when computing similarities. -ignore-docstrings=yes +[SPELLING] +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 -[VARIABLES] +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the 'python-enchant' package. +spelling-dict= -# Tells whether we should check for unused import in __init__ files. -init-import=no +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: -# A regular expression matching the beginning of the name of dummy variables -# (i.e. not used). -dummy-variables-rgx=_|dummy|unused|.*_unused +# List of comma separated words that should not be checked. +spelling-ignore-words= -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no -[IMPORTS] -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,string,TERMIOS,Bastion,rexec +[STRING] -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= +[TYPECHECK] -[DESIGN] +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager -# Maximum number of arguments for function / method -max-args=5 +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes -# Maximum number of locals for function / method body -max-locals=15 +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes -# Maximum number of return / yield for function / method body -max-returns=6 +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init -# Maximum number of branch for function / method body -max-branchs=12 +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace -# Maximum number of statements in function / method body -max-statements=50 +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes -# Maximum number of parents for a class (see R0901). -max-parents=7 +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 -# Maximum number of attributes for a class (see R0902). -max-attributes=7 +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 +# List of decorators that change the signature of a decorated function. +signature-mutators= -[CLASSES] +[VARIABLES] -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes +# List of names allowed to shadow builtins +allowed-redefined-builtins= -[EXCEPTIONS] +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/pylti1p3/__init__.py b/pylti1p3/__init__.py index 96ddfeb..8c0d5d5 100644 --- a/pylti1p3/__init__.py +++ b/pylti1p3/__init__.py @@ -1 +1 @@ -__version__ = '1.12.2' +__version__ = "2.0.0" diff --git a/pylti1p3/actions.py b/pylti1p3/actions.py index 73d2da0..6d78520 100644 --- a/pylti1p3/actions.py +++ b/pylti1p3/actions.py @@ -1,9 +1,6 @@ -import typing as t +import typing_extensions as te -if t.TYPE_CHECKING: - from typing_extensions import Final - -class Action(object): - OIDC_LOGIN = 'oidc_login' # type: Final - MESSAGE_LAUNCH = 'message_launch' # type: Final +class Action: + OIDC_LOGIN: te.Final = "oidc_login" + MESSAGE_LAUNCH: te.Final = "message_launch" diff --git a/pylti1p3/assignments_grades.py b/pylti1p3/assignments_grades.py index db648df..d0db759 100644 --- a/pylti1p3/assignments_grades.py +++ b/pylti1p3/assignments_grades.py @@ -1,57 +1,74 @@ import typing as t - +import typing_extensions as te 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/result.readonly', - '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 # type: ServiceConnector - _service_data = None # type: _AssignmentsGradersData - - def __init__(self, service_connector, service_data): - # type: (ServiceConnector, _AssignmentsGradersData) -> None +from .grade import Grade +from .lineitem import TLineItem +from .service_connector import ServiceConnector, TServiceConnectorResponse + + +TAssignmentsGradersData = te.TypedDict( + "TAssignmentsGradersData", + { + "scope": t.List[ + te.Literal[ + "https://purl.imsglobal.org/spec/lti-ags/scope/score", + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", + "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: + _service_connector: ServiceConnector + _service_data: TAssignmentsGradersData + + def __init__( + self, service_connector: ServiceConnector, service_data: TAssignmentsGradersData + ): self._service_connector = service_connector self._service_data = service_data - def can_read_lineitem(self): + def can_read_lineitem(self) -> bool: return ( - "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly" in self._service_data['scope'] - or "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem" in self._service_data['scope'] + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly" + in self._service_data["scope"] + or "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem" + in self._service_data["scope"] ) - def can_create_lineitem(self): - return "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem" in self._service_data['scope'] + def can_create_lineitem(self) -> bool: + return ( + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem" + in self._service_data["scope"] + ) - def can_read_grades(self): - return 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly' in self._service_data['scope'] + def can_read_grades(self) -> bool: + return ( + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly" + in self._service_data["scope"] + ) - def can_put_grade(self): - return "https://purl.imsglobal.org/spec/lti-ags/scope/score" in self._service_data['scope'] + def can_put_grade(self) -> bool: + return ( + "https://purl.imsglobal.org/spec/lti-ags/scope/score" + in self._service_data["scope"] + ) - def put_grade(self, grade, lineitem=None): - # type: (Grade, t.Optional[LineItem]) -> _ServiceConnectorResponse + def put_grade( + self, grade: Grade, lineitem: t.Optional[LineItem] = None + ) -> TServiceConnectorResponse: """ Send grade to the LTI platform. :param grade: Grade instance :param lineitem: LineItem instance - :param create_default_lineitem: create default lineitem if nothing was found - :param default_lineitem_tag: str :return: dict with HTTP response body and headers """ @@ -62,22 +79,22 @@ def put_grade(self, grade, lineitem=None): if not lineitem.get_id(): lineitem = self.find_or_create_lineitem(lineitem) score_url = lineitem.get_id() - elif not lineitem and self._service_data.get('lineitem'): - score_url = self._service_data.get('lineitem') + elif not lineitem and self._service_data.get("lineitem"): + score_url = self._service_data.get("lineitem") else: raise LtiException("Can't find lineitem to put grade") assert score_url is not None - score_url = self._add_url_path_ending(score_url, 'scores') + score_url = self._add_url_path_ending(score_url, "scores") return self._service_connector.make_service_request( - self._service_data['scope'], + self._service_data["scope"], score_url, is_post=True, data=grade.get_value(), - content_type='application/vnd.ims.lis.v1.score+json' + content_type="application/vnd.ims.lis.v1.score+json", ) - def get_lineitem(self, lineitem_url=None): + def get_lineitem(self, lineitem_url: t.Optional[str] = None): """ Retrieves an individual lineitem. By default retrieves the lineitem associated with the LTI message. @@ -89,17 +106,18 @@ def get_lineitem(self, lineitem_url=None): raise LtiException("Can't read lineitem: Missing required scope") if lineitem_url is None: - lineitem_url = self._service_data['lineitem'] + lineitem_url = self._service_data["lineitem"] lineitem_response = self._service_connector.make_service_request( - self._service_data['scope'], + self._service_data["scope"], lineitem_url, - accept='application/vnd.ims.lis.v2.lineitem+json', + accept="application/vnd.ims.lis.v2.lineitem+json", ) - return LineItem(lineitem_response['body']) + return LineItem(t.cast(TLineItem, lineitem_response["body"])) - def get_lineitems_page(self, lineitems_url=None): - # type: (t.Optional[str]) -> t.Tuple[list, t.Optional[str]] + def get_lineitems_page( + self, lineitems_url: t.Optional[str] = None + ) -> t.Tuple[list, t.Optional[str]]: """ Get one page with line items. @@ -110,26 +128,25 @@ def get_lineitems_page(self, lineitems_url=None): raise LtiException("Can't read lineitem: Missing required scope") if not lineitems_url: - lineitems_url = self._service_data['lineitems'] + lineitems_url = self._service_data["lineitems"] lineitems = self._service_connector.make_service_request( - self._service_data['scope'], + self._service_data["scope"], lineitems_url, - accept='application/vnd.ims.lis.v2.lineitemcontainer+json' + accept="application/vnd.ims.lis.v2.lineitemcontainer+json", ) - if not isinstance(lineitems['body'], list): - raise LtiException('Unknown response type received for line items') - return lineitems['body'], lineitems['next_page_url'] + if not isinstance(lineitems["body"], list): + raise LtiException("Unknown response type received for line items") + return lineitems["body"], lineitems["next_page_url"] - def get_lineitems(self): - # type: () -> list + def get_lineitems(self) -> list: """ Get list of all available line items. :return: list """ lineitems_res_lst = [] - lineitems_url = self._service_data['lineitems'] # type: t.Optional[str] + lineitems_url: t.Optional[str] = self._service_data["lineitems"] while lineitems_url: lineitems, lineitems_url = self.get_lineitems_page(lineitems_url) @@ -137,8 +154,7 @@ def get_lineitems(self): return lineitems_res_lst - def find_lineitem(self, prop_name, prop_value): - # type: (str, t.Any) -> t.Optional[LineItem] + def find_lineitem(self, prop_name: str, prop_value: t.Any) -> t.Optional[LineItem]: """ Find line item by some property (ID/Tag). @@ -146,7 +162,7 @@ def find_lineitem(self, prop_name, prop_value): :param prop_value: property value :return: LineItem instance or None """ - lineitems_url = self._service_data['lineitems'] # type: t.Optional[str] + lineitems_url: t.Optional[str] = self._service_data["lineitems"] while lineitems_url: lineitems, lineitems_url = self.get_lineitems_page(lineitems_url) @@ -156,48 +172,47 @@ def find_lineitem(self, prop_name, prop_value): return LineItem(lineitem) return None - def find_lineitem_by_id(self, ln_id): - # type: (str) -> t.Optional[LineItem] + def find_lineitem_by_id(self, ln_id: str) -> t.Optional[LineItem]: """ Find line item by ID. :param ln_id: str :return: LineItem instance or None """ - return self.find_lineitem('id', ln_id) + return self.find_lineitem("id", ln_id) - def find_lineitem_by_tag(self, tag): - # type: (str) -> t.Optional[LineItem] + def find_lineitem_by_tag(self, tag: str) -> t.Optional[LineItem]: """ Find line item by Tag. :param tag: str :return: LineItem instance or None """ - return self.find_lineitem('tag', tag) + return self.find_lineitem("tag", tag) - def find_lineitem_by_resource_link_id(self, resource_link_id): - # type: (str) -> t.Optional[LineItem] + def find_lineitem_by_resource_link_id( + self, resource_link_id: str + ) -> t.Optional[LineItem]: """ Find line item by Resource LinkID. :param resource_link_id: str :return: LineItem instance or None """ - return self.find_lineitem('resourceLinkId', resource_link_id) + return self.find_lineitem("resourceLinkId", resource_link_id) - def find_lineitem_by_resource_id(self, resource_id): - # type: (str) -> t.Optional[LineItem] + def find_lineitem_by_resource_id(self, resource_id: str) -> t.Optional[LineItem]: """ Find line item by Resource ID. :param resource_id: str :return: LineItem instance or None """ - return self.find_lineitem('resourceId', resource_id) + return self.find_lineitem("resourceId", resource_id) - def find_or_create_lineitem(self, new_lineitem, find_by='tag'): - # type: (LineItem, Literal['tag', 'resource_link_id', 'resource_id', 'id']) -> LineItem + def find_or_create_lineitem( + self, new_lineitem: LineItem, find_by: str = "tag" + ) -> LineItem: """ Try to find line item using ID or Tag. New lime item will be created if nothing is found. @@ -205,25 +220,25 @@ def find_or_create_lineitem(self, new_lineitem, find_by='tag'): :param find_by: str ("tag"/"id") :return: LineItem instance (based on response from the LTI platform) """ - if find_by == 'tag': + if find_by == "tag": tag = new_lineitem.get_tag() if not tag: - raise LtiException('Tag value is not specified') + raise LtiException("Tag value is not specified") lineitem = self.find_lineitem_by_tag(tag) - elif find_by == 'id': + elif find_by == "id": line_id = new_lineitem.get_id() if not line_id: - raise LtiException('ID value is not specified') + raise LtiException("ID value is not specified") lineitem = self.find_lineitem_by_id(line_id) - elif find_by == 'resource_link_id': + elif find_by == "resource_link_id": resource_link_id = new_lineitem.get_resource_link_id() if not resource_link_id: - raise LtiException('Resource Link ID value is not specified') + raise LtiException("Resource Link ID value is not specified") lineitem = self.find_lineitem_by_resource_link_id(resource_link_id) - elif find_by == 'resource_id': + elif find_by == "resource_id": resource_id = new_lineitem.get_resource_id() if not resource_id: - raise LtiException('Resource ID value is not specified') + raise LtiException("Resource ID value is not specified") lineitem = self.find_lineitem_by_resource_id(resource_id) else: raise LtiException('Invalid "find_by" value: ' + str(find_by)) @@ -235,19 +250,18 @@ def find_or_create_lineitem(self, new_lineitem, find_by='tag'): raise LtiException("Can't create lineitem: Missing required scope") created_lineitem = self._service_connector.make_service_request( - self._service_data['scope'], - self._service_data['lineitems'], + self._service_data["scope"], + self._service_data["lineitems"], is_post=True, data=new_lineitem.get_value(), - content_type='application/vnd.ims.lis.v2.lineitem+json', - accept='application/vnd.ims.lis.v2.lineitem+json' + content_type="application/vnd.ims.lis.v2.lineitem+json", + accept="application/vnd.ims.lis.v2.lineitem+json", ) - if not isinstance(created_lineitem['body'], dict): - raise LtiException('Unknown response type received for create line item') - return LineItem(created_lineitem['body']) + if not isinstance(created_lineitem["body"], dict): + raise LtiException("Unknown response type received for create line item") + return LineItem(t.cast(TLineItem, created_lineitem["body"])) - def get_grades(self, lineitem=None): - # type: (t.Optional[LineItem]) -> list + def get_grades(self, lineitem: t.Optional[LineItem] = None) -> list: """ Return all grades for the passed line item (across all users enrolled in the line item's context). @@ -260,28 +274,27 @@ def get_grades(self, lineitem=None): if lineitem: lineitem_id = lineitem.get_id() else: - lineitem_id = self._service_data.get('lineitem') + lineitem_id = self._service_data.get("lineitem") if not lineitem_id: return [] - results_url = self._add_url_path_ending(lineitem_id, 'results') + results_url = self._add_url_path_ending(lineitem_id, "results") scores = self._service_connector.make_service_request( - self._service_data['scope'], + self._service_data["scope"], results_url, - accept='application/vnd.ims.lis.v2.resultcontainer+json' + 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('?') + if not isinstance(scores["body"], list): + raise LtiException("Unknown response type received for results") + return scores["body"] + + @staticmethod + def _add_url_path_ending(url: str, url_path_ending: str) -> str: + if "?" in url: + url_parts = url.split("?") new_url = url_parts[0] - new_url += '' if new_url.endswith('/') else '/' - return new_url + url_path_ending + '?' + url_parts[1] - else: - url += '' if url.endswith('/') else '/' - return url + url_path_ending + new_url += "" if new_url.endswith("/") else "/" + return new_url + url_path_ending + "?" + url_parts[1] + url += "" if url.endswith("/") else "/" + return url + url_path_ending diff --git a/pylti1p3/contrib/django/__init__.py b/pylti1p3/contrib/django/__init__.py index 18be284..e000b8a 100644 --- a/pylti1p3/contrib/django/__init__.py +++ b/pylti1p3/contrib/django/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .message_launch import DjangoMessageLaunch from .oidc_login import DjangoOIDCLogin from .launch_data_storage.cache import DjangoCacheDataStorage diff --git a/pylti1p3/contrib/django/cookie.py b/pylti1p3/contrib/django/cookie.py index 940e3a7..d8d99ee 100644 --- a/pylti1p3/contrib/django/cookie.py +++ b/pylti1p3/contrib/django/cookie.py @@ -1,17 +1,11 @@ -import sys - +import http.cookies as Cookie # type: ignore import django # type: ignore from pylti1p3.cookie import CookieService -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: # type: ignore - Cookie.Morsel._reserved.setdefault('samesite', 'SameSite') # type: ignore +if "samesite" not in Cookie.Morsel._reserved: # type: ignore + Cookie.Morsel._reserved.setdefault("samesite", "SameSite") # type: ignore class DjangoCookieService(CookieService): @@ -23,39 +17,40 @@ def __init__(self, request): self._cookie_data_to_set = {} def _get_key(self, key): - return self._cookie_prefix + '-' + key + return self._cookie_prefix + "-" + key def get_cookie(self, name): return self._request.get_cookie(self._get_key(name)) def set_cookie(self, name, value, exp=3600): self._cookie_data_to_set[self._get_key(name)] = { - 'value': value, - 'exp': exp, + "value": value, + "exp": exp, } def update_response(self, response): for key, cookie_data in self._cookie_data_to_set.items(): kwargs = { - 'value': cookie_data['value'], - 'max_age': cookie_data['exp'], - 'secure': self._request.is_secure(), - 'httponly': True, - 'path': '/' + "value": cookie_data["value"], + "max_age": cookie_data["exp"], + "secure": self._request.is_secure(), + "httponly": True, + "path": "/", } if self._request.is_secure(): # samesite argument was added in Django 2.1, but samesite could be set as None only from Django 3.1 # https://github.com/django/django/pull/11894 - django_support_samesite_none = django.VERSION[0] > 3 \ - or (django.VERSION[0] == 3 and django.VERSION[1] >= 1) + django_support_samesite_none = django.VERSION[0] > 3 or ( + django.VERSION[0] == 3 and django.VERSION[1] >= 1 + ) # SameSite=None and Secure=True are required to work inside iframes if django_support_samesite_none: - kwargs['samesite'] = 'None' + kwargs["samesite"] = "None" response.set_cookie(key, **kwargs) else: response.set_cookie(key, **kwargs) - response.cookies[key]['samesite'] = 'None' + response.cookies[key]["samesite"] = "None" else: response.set_cookie(key, **kwargs) diff --git a/pylti1p3/contrib/django/launch_data_storage/cache.py b/pylti1p3/contrib/django/launch_data_storage/cache.py index 8fdf1f8..b2cbf18 100644 --- a/pylti1p3/contrib/django/launch_data_storage/cache.py +++ b/pylti1p3/contrib/django/launch_data_storage/cache.py @@ -5,6 +5,6 @@ class DjangoCacheDataStorage(CacheDataStorage): _cache = None - def __init__(self, cache_name='default', **kwargs): + def __init__(self, cache_name="default", **kwargs): self._cache = caches[cache_name] - super(DjangoCacheDataStorage, self).__init__(cache_name, **kwargs) + super().__init__(cache_name, **kwargs) diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py b/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py index a242bad..ed5a3bc 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py @@ -6,7 +6,9 @@ from pylti1p3.tool_config.abstract import ToolConfAbstract -default_app_config = 'pylti1p3.contrib.django.lti1p3_tool_config.apps.PyLTI1p3ToolConfig' +default_app_config = ( + "pylti1p3.contrib.django.lti1p3_tool_config.apps.PyLTI1p3ToolConfig" +) class DjangoDbToolConf(ToolConfAbstract): @@ -15,27 +17,42 @@ class DjangoDbToolConf(ToolConfAbstract): _keys_cls = None def __init__(self): + # pylint: disable=import-outside-toplevel from .models import LtiTool, LtiToolKey - super(DjangoDbToolConf, self).__init__() + + super().__init__() self._lti_tools = {} self._tools_cls = LtiTool self._keys_cls = LtiToolKey def get_lti_tool(self, iss, client_id): - lti_tool = self._lti_tools.get(iss) if client_id is None else self._lti_tools.get(iss, {}).get(client_id) + # pylint: disable=no-member + lti_tool = ( + self._lti_tools.get(iss) + if client_id is None + else self._lti_tools.get(iss, {}).get(client_id) + ) if lti_tool: return lti_tool if client_id is None: - lti_tool = self._tools_cls.objects.filter(issuer=iss, is_active=True).order_by('use_by_default').first() + lti_tool = ( + self._tools_cls.objects.filter(issuer=iss, is_active=True) + .order_by("use_by_default") + .first() + ) else: try: - lti_tool = self._tools_cls.objects.get(issuer=iss, client_id=client_id, is_active=True) + lti_tool = self._tools_cls.objects.get( + issuer=iss, client_id=client_id, is_active=True + ) except self._tools_cls.DoesNotExist: pass if lti_tool is None: - raise LtiException('iss %s [client_id=%s] not found in settings' % (iss, client_id)) + raise LtiException( + f"iss {iss} [client_id={client_id}] not found in settings" + ) if client_id is None: self._lti_tools[iss] = lti_tool @@ -60,18 +77,26 @@ def find_registration_by_params(self, iss, client_id, *args, **kwargs): auth_audience = lti_tool.auth_audience if lti_tool.auth_audience else None key_set = json.loads(lti_tool.key_set) if lti_tool.key_set else None key_set_url = lti_tool.key_set_url if lti_tool.key_set_url else None - tool_public_key = lti_tool.tool_key.public_key if lti_tool.tool_key.public_key else None + tool_public_key = ( + lti_tool.tool_key.public_key if lti_tool.tool_key.public_key else None + ) reg = Registration() - reg.set_auth_login_url(lti_tool.auth_login_url) \ - .set_auth_token_url(lti_tool.auth_token_url) \ - .set_auth_audience(auth_audience) \ - .set_client_id(lti_tool.client_id) \ - .set_key_set(key_set) \ - .set_key_set_url(key_set_url) \ - .set_issuer(lti_tool.issuer) \ - .set_tool_private_key(lti_tool.tool_key.private_key) \ - .set_tool_public_key(tool_public_key) + reg.set_auth_login_url(lti_tool.auth_login_url).set_auth_token_url( + lti_tool.auth_token_url + ).set_auth_audience(auth_audience).set_client_id( + lti_tool.client_id + ).set_key_set( + key_set + ).set_key_set_url( + key_set_url + ).set_issuer( + lti_tool.issuer + ).set_tool_private_key( + lti_tool.tool_key.private_key + ).set_tool_public_key( + tool_public_key + ) return reg def find_deployment(self, iss, deployment_id): @@ -79,21 +104,24 @@ def find_deployment(self, iss, deployment_id): def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwargs): lti_tool = self.get_lti_tool(iss, client_id) - deployment_ids = json.loads(lti_tool.deployment_ids) if lti_tool.deployment_ids else [] + deployment_ids = ( + json.loads(lti_tool.deployment_ids) if lti_tool.deployment_ids else [] + ) if deployment_id not in deployment_ids: return None d = Deployment() return d.set_deployment_id(deployment_id) def get_jwks(self, iss=None, client_id=None, **kwargs): + # pylint: disable=no-member search_kwargs = {} if iss: - search_kwargs['lti_tools__issuer'] = iss + search_kwargs["lti_tools__issuer"] = iss if client_id: - search_kwargs['lti_tools__client_id'] = client_id + search_kwargs["lti_tools__client_id"] = client_id if search_kwargs: - search_kwargs['lti_tools__is_active'] = True + search_kwargs["lti_tools__is_active"] = True qs = self._keys_cls.objects.filter(**search_kwargs) else: qs = self._keys_cls.objects.all() @@ -108,6 +136,4 @@ def get_jwks(self, iss=None, client_id=None, **kwargs): else: jwks.append(Registration.get_jwk(key.public_key)) public_key_lst.append(key.public_key) - return { - 'keys': jwks - } + return {"keys": jwks} diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/admin.py b/pylti1p3/contrib/django/lti1p3_tool_config/admin.py index 4177a73..7dd8257 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/admin.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/admin.py @@ -6,34 +6,42 @@ class LtiToolKeyAdmin(admin.ModelAdmin): """Admin for LTI Tool Key""" - list_display = ('id', 'name') - add_fieldsets = ( - (None, {'fields': ('name', 'private_key', 'public_key')}), - ) + list_display = ("id", "name") + + add_fieldsets = ((None, {"fields": ("name", "private_key", "public_key")}),) change_fieldsets = ( - (None, {'fields': ('name', 'private_key', 'public_key', 'public_jwk')}), + (None, {"fields": ("name", "private_key", "public_key", "public_jwk")}), ) - readonly_fields = ('public_jwk',) + readonly_fields = ("public_jwk",) def get_form(self, request, obj=None, **kwargs): # pylint: disable=arguments-differ - help_texts = {'public_key_jwk_json': "Tool's generated Public key presented as JWK."} - kwargs.update({'help_texts': help_texts}) - return super(LtiToolKeyAdmin, self).get_form(request, obj, **kwargs) + help_texts = { + "public_key_jwk_json": "Tool's generated Public key presented as JWK." + } + kwargs.update({"help_texts": help_texts}) + return super().get_form(request, obj, **kwargs) def get_fieldsets(self, request, obj=None): # pylint: disable=unused-argument if not obj: return self.add_fieldsets - else: - return self.change_fieldsets + return self.change_fieldsets class LtiToolAdmin(admin.ModelAdmin): """Admin for LTI Tool""" - search_fields = ('title', 'issuer', 'client_id', 'auth_login_url', 'auth_token_url', 'key_set_url') - list_display = ('id', 'title', 'is_active', 'issuer', 'client_id', 'deployment_ids') + + search_fields = ( + "title", + "issuer", + "client_id", + "auth_login_url", + "auth_token_url", + "key_set_url", + ) + list_display = ("id", "title", "is_active", "issuer", "client_id", "deployment_ids") admin.site.register(LtiToolKey, LtiToolKeyAdmin) diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/apps.py b/pylti1p3/contrib/django/lti1p3_tool_config/apps.py index b7402ce..116298b 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/apps.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/apps.py @@ -2,5 +2,5 @@ class PyLTI1p3ToolConfig(AppConfig): - name = 'pylti1p3.contrib.django.lti1p3_tool_config' + name = "pylti1p3.contrib.django.lti1p3_tool_config" verbose_name = "PyLTI 1.3 Tool Config" 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 bd04c68..625e129 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py @@ -8,65 +8,161 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='LtiToolKey', + name="LtiToolKey", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Key name', max_length=255, unique=True)), - ('private_key', models.TextField(help_text="Tool's generated Private key. " - "Keep this value in secret")), - ('public_key', models.TextField(blank=True, help_text="Tool's generated Public key", null=True)), - ('public_jwk', models.TextField(blank=True, help_text="Tool's generated Public key (from the " - "field above) presented as JWK.", null=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(help_text="Key name", max_length=255, unique=True), + ), + ( + "private_key", + models.TextField( + help_text="Tool's generated Private key. " + "Keep this value in secret" + ), + ), + ( + "public_key", + models.TextField( + blank=True, help_text="Tool's generated Public key", null=True + ), + ), + ( + "public_jwk", + models.TextField( + blank=True, + help_text="Tool's generated Public key (from the " + "field above) presented as JWK.", + null=True, + ), + ), ], options={ - 'verbose_name': 'lti 1.3 tool key', - 'verbose_name_plural': 'lti 1.3 tool keys', - 'db_table': 'lti1p3_tool_key', + "verbose_name": "lti 1.3 tool key", + "verbose_name_plural": "lti 1.3 tool keys", + "db_table": "lti1p3_tool_key", }, ), migrations.CreateModel( - name='LtiTool', + name="LtiTool", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=255)), - ('is_active', models.BooleanField(default=True)), - ('issuer', models.CharField(help_text="This will usually look something like 'http://example.com'. " - "Value provided by LTI 1.3 Platform", max_length=255)), - ('client_id', models.CharField(help_text='Value provided by LTI 1.3 Platform', max_length=255)), - ('use_by_default', models.BooleanField(default=False, help_text='This iss config will be used in case ' - 'if client-id was not passed')), - ('auth_login_url', models.CharField(help_text="The platform's OIDC login endpoint. " - "Value provided by LTI 1.3 Platform", max_length=1024, - validators=[django.core.validators.URLValidator()])), - ('auth_token_url', models.CharField(help_text="The platform's service authorization endpoint. " - "Value provided by LTI 1.3 Platform", max_length=1024, - validators=[django.core.validators.URLValidator()])), - ('auth_audience', models.CharField(blank=True, help_text="The platform's OAuth2 Audience (aud). " - "Usually could be skipped", max_length=1024, - null=True)), - ('key_set_url', models.CharField(blank=True, help_text="The platform's JWKS endpoint. " - "Value provided by LTI 1.3 Platform", - max_length=1024, null=True, - validators=[django.core.validators.URLValidator()])), - ('key_set', models.TextField(blank=True, help_text="In case if platform's JWKS endpoint somehow " - "unavailable you may paste JWKS here. Value " - "provided by LTI 1.3 Platform", null=True)), - ('deployment_ids', models.TextField(help_text='List of Deployment IDs. Example: ' - '["test-id-1", "test-id-2", ...] Each value ' - 'is provided by LTI 1.3 Platform. ')), - ('tool_key', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='lti_tools', - to='lti1p3_tool_config.LtiToolKey')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ( + "issuer", + models.CharField( + help_text="This will usually look something like 'http://example.com'. " + "Value provided by LTI 1.3 Platform", + max_length=255, + ), + ), + ( + "client_id", + models.CharField( + help_text="Value provided by LTI 1.3 Platform", max_length=255 + ), + ), + ( + "use_by_default", + models.BooleanField( + default=False, + help_text="This iss config will be used in case " + "if client-id was not passed", + ), + ), + ( + "auth_login_url", + models.CharField( + help_text="The platform's OIDC login endpoint. " + "Value provided by LTI 1.3 Platform", + max_length=1024, + validators=[django.core.validators.URLValidator()], + ), + ), + ( + "auth_token_url", + models.CharField( + help_text="The platform's service authorization endpoint. " + "Value provided by LTI 1.3 Platform", + max_length=1024, + validators=[django.core.validators.URLValidator()], + ), + ), + ( + "auth_audience", + models.CharField( + blank=True, + help_text="The platform's OAuth2 Audience (aud). " + "Usually could be skipped", + max_length=1024, + null=True, + ), + ), + ( + "key_set_url", + models.CharField( + blank=True, + help_text="The platform's JWKS endpoint. " + "Value provided by LTI 1.3 Platform", + max_length=1024, + null=True, + validators=[django.core.validators.URLValidator()], + ), + ), + ( + "key_set", + models.TextField( + blank=True, + help_text="In case if platform's JWKS endpoint somehow " + "unavailable you may paste JWKS here. Value " + "provided by LTI 1.3 Platform", + null=True, + ), + ), + ( + "deployment_ids", + models.TextField( + help_text="List of Deployment IDs. Example: " + '["test-id-1", "test-id-2", ...] Each value ' + "is provided by LTI 1.3 Platform. " + ), + ), + ( + "tool_key", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="lti_tools", + to="lti1p3_tool_config.LtiToolKey", + ), + ), ], options={ - 'verbose_name': 'lti 1.3 tool', - 'verbose_name_plural': 'lti 1.3 tools', - 'db_table': 'lti1p3_tool', - 'unique_together': {('issuer', 'client_id')}, + "verbose_name": "lti 1.3 tool", + "verbose_name_plural": "lti 1.3 tools", + "db_table": "lti1p3_tool", + "unique_together": {("issuer", "client_id")}, }, ), ] diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/models.py b/pylti1p3/contrib/django/lti1p3_tool_config/models.py index e58e447..6e5ff73 100644 --- a/pylti1p3/contrib/django/lti1p3_tool_config/models.py +++ b/pylti1p3/contrib/django/lti1p3_tool_config/models.py @@ -4,35 +4,46 @@ from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.db import models -try: - from django.utils.translation import ugettext_lazy as _ -except ImportError: - # Django 4.0 - from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from pylti1p3.registration import Registration class LtiToolKey(models.Model): - name = models.CharField(max_length=255, null=False, blank=False, unique=True, help_text=_("Key name")) - private_key = models.TextField(null=False, blank=False, help_text=_("Tool's generated Private key. " - "Keep this value in secret")) - public_key = models.TextField(null=True, blank=True, help_text=_("Tool's generated Public key")) - public_jwk = models.TextField(null=True, blank=True, help_text=_("Tool's generated Public key (from the field" - " above) presented as JWK.")) + name = models.CharField( + max_length=255, null=False, blank=False, unique=True, help_text=_("Key name") + ) + private_key = models.TextField( + null=False, + blank=False, + help_text=_("Tool's generated Private key. Keep this value in secret"), + ) + public_key = models.TextField( + null=True, blank=True, help_text=_("Tool's generated Public key") + ) + public_jwk = models.TextField( + null=True, + blank=True, + help_text=_( + "Tool's generated Public key (from the field above) presented as JWK." + ), + ) - def save(self, *args, **kwargs): # pylint: disable=arguments-differ,signature-differs + def save( + self, *args, **kwargs + ): # pylint: disable=arguments-differ,signature-differs if self.public_key: public_jwk_dict = Registration.get_jwk(self.public_key) self.public_jwk = json.dumps(public_jwk_dict) else: self.public_key = None self.public_jwk = None - super(LtiToolKey, self).save(*args, **kwargs) + super().save(*args, **kwargs) def __str__(self): - return '' % (self.id, self.name) + # pylint: disable=no-member + return f"" - class Meta(object): + class Meta: db_table = "lti1p3_tool_key" verbose_name = "lti 1.3 tool key" verbose_name_plural = "lti 1.3 tool keys" @@ -41,41 +52,87 @@ class Meta(object): class LtiTool(models.Model): title = models.CharField(max_length=255) is_active = models.BooleanField(default=True) - issuer = models.CharField(max_length=255, - help_text=_("This will usually look something like 'http://example.com'. " - "Value provided by LTI 1.3 Platform")) - client_id = models.CharField(max_length=255, null=False, blank=False, - help_text=_("Value provided by LTI 1.3 Platform")) - use_by_default = models.BooleanField(default=False, help_text=_("This iss config will be used in case " - "if client-id was not passed")) - auth_login_url = models.CharField(max_length=1024, null=False, blank=False, - help_text=_("The platform's OIDC login endpoint. " - "Value provided by LTI 1.3 Platform"), - validators=[URLValidator()]) - auth_token_url = models.CharField(max_length=1024, null=False, blank=False, - help_text=_("The platform's service authorization " - "endpoint. Value provided by " - "LTI 1.3 Platform"), - validators=[URLValidator()]) - auth_audience = models.CharField(max_length=1024, null=True, blank=True, - help_text=_("The platform's OAuth2 Audience (aud). " - "Usually could be skipped")) - key_set_url = models.CharField(max_length=1024, null=True, blank=True, - help_text=_("The platform's JWKS endpoint. " - "Value provided by LTI 1.3 Platform"), - validators=[URLValidator()]) - key_set = models.TextField(null=True, blank=True, help_text=_("In case if platform's JWKS endpoint somehow " - "unavailable you may paste JWKS here. " - "Value provided by LTI 1.3 Platform")) - tool_key = models.ForeignKey(LtiToolKey, on_delete=models.PROTECT, related_name="lti_tools") - deployment_ids = models.TextField(null=False, blank=False, - help_text=_("List of Deployment IDs. " - "Example: [\"test-id-1\", \"test-id-2\", ...] " - "Each value is provided by LTI 1.3 Platform. ")) + issuer = models.CharField( + max_length=255, + help_text=_( + "This will usually look something like 'http://example.com'. " + "Value provided by LTI 1.3 Platform" + ), + ) + client_id = models.CharField( + max_length=255, + null=False, + blank=False, + help_text=_("Value provided by LTI 1.3 Platform"), + ) + use_by_default = models.BooleanField( + default=False, + help_text=_("This iss config will be used in case if client-id was not passed"), + ) + auth_login_url = models.CharField( + max_length=1024, + null=False, + blank=False, + help_text=_( + "The platform's OIDC login endpoint. Value provided by LTI 1.3 Platform" + ), + validators=[URLValidator()], + ) + auth_token_url = models.CharField( + max_length=1024, + null=False, + blank=False, + help_text=_( + "The platform's service authorization " + "endpoint. Value provided by " + "LTI 1.3 Platform" + ), + validators=[URLValidator()], + ) + auth_audience = models.CharField( + max_length=1024, + null=True, + blank=True, + help_text=_("The platform's OAuth2 Audience (aud). Usually could be skipped"), + ) + key_set_url = models.CharField( + max_length=1024, + null=True, + blank=True, + help_text=_("The platform's JWKS endpoint. Value provided by LTI 1.3 Platform"), + validators=[URLValidator()], + ) + key_set = models.TextField( + null=True, + blank=True, + help_text=_( + "In case if platform's JWKS endpoint somehow " + "unavailable you may paste JWKS here. " + "Value provided by LTI 1.3 Platform" + ), + ) + tool_key = models.ForeignKey( + LtiToolKey, on_delete=models.PROTECT, related_name="lti_tools" + ) + deployment_ids = models.TextField( + null=False, + blank=False, + help_text=_( + "List of Deployment IDs. " + 'Example: ["test-id-1", "test-id-2", ...] ' + "Each value is provided by LTI 1.3 Platform. " + ), + ) def clean(self): if not self.key_set_url and not self.key_set: - raise ValidationError({'key_set_url': _('Even one of "key_set_url" or "key_set" should be set')}) + raise ValidationError( + { + "key_set_url": _( + 'Even one of "key_set_url" or "key_set" should be set' + ) + } + ) if self.key_set: key_set_valid = False @@ -86,7 +143,7 @@ def clean(self): except ValueError: pass if not key_set_valid: - raise ValidationError({'key_set': _('Should be a dict')}) + raise ValidationError({"key_set": _("Should be a dict")}) deployment_ids_valid = False try: @@ -96,7 +153,13 @@ def clean(self): except ValueError: pass if not deployment_ids_valid: - raise ValidationError({'deployment_ids': _('Should be a list. Example: ["test-id-1", "test-id-2", ...]')}) + raise ValidationError( + { + "deployment_ids": _( + 'Should be a list. Example: ["test-id-1", "test-id-2", ...]' + ) + } + ) def to_dict(self): data = { @@ -107,13 +170,15 @@ def to_dict(self): "auth_audience": self.auth_audience, "key_set_url": self.key_set_url, "key_set": json.loads(self.key_set) if self.key_set else None, - "deployment_ids": json.loads(self.deployment_ids) if self.deployment_ids else [] + "deployment_ids": json.loads(self.deployment_ids) + if self.deployment_ids + else [], } return data - class Meta(object): + class Meta: unique_together = [ - ['issuer', 'client_id'], + ["issuer", "client_id"], ] db_table = "lti1p3_tool" verbose_name = "lti 1.3 tool" diff --git a/pylti1p3/contrib/django/message_launch.py b/pylti1p3/contrib/django/message_launch.py index 1e87643..06c4cd1 100644 --- a/pylti1p3/contrib/django/message_launch.py +++ b/pylti1p3/contrib/django/message_launch.py @@ -6,14 +6,34 @@ class DjangoMessageLaunch(MessageLaunch): - - def __init__(self, request, tool_config, session_service=None, cookie_service=None, launch_data_storage=None, - requests_session=None): - django_request = request if isinstance(request, Request) else DjangoRequest(request, post_only=True) - cookie_service = cookie_service if cookie_service else DjangoCookieService(django_request) - session_service = session_service if session_service else DjangoSessionService(request) - super(DjangoMessageLaunch, self).__init__(django_request, tool_config, session_service, cookie_service, - launch_data_storage, requests_session) + def __init__( + self, + request, + tool_config, + session_service=None, + cookie_service=None, + launch_data_storage=None, + requests_session=None, + ): + django_request = ( + request + if isinstance(request, Request) + else DjangoRequest(request, post_only=True) + ) + cookie_service = ( + cookie_service if cookie_service else DjangoCookieService(django_request) + ) + session_service = ( + session_service if session_service else DjangoSessionService(request) + ) + super().__init__( + django_request, + tool_config, + session_service, + cookie_service, + launch_data_storage, + requests_session, + ) def _get_request_param(self, key): return self._request.get_param(key) diff --git a/pylti1p3/contrib/django/oidc_login.py b/pylti1p3/contrib/django/oidc_login.py index df83e20..fd6a828 100644 --- a/pylti1p3/contrib/django/oidc_login.py +++ b/pylti1p3/contrib/django/oidc_login.py @@ -9,13 +9,30 @@ class DjangoOIDCLogin(OIDCLogin): - - def __init__(self, request, tool_config, session_service=None, cookie_service=None, launch_data_storage=None): - django_request = request if isinstance(request, Request) else DjangoRequest(request) - cookie_service = cookie_service if cookie_service else DjangoCookieService(django_request) - session_service = session_service if session_service else DjangoSessionService(request) - super(DjangoOIDCLogin, self).__init__(django_request, tool_config, session_service, cookie_service, - launch_data_storage) + def __init__( + self, + request, + tool_config, + session_service=None, + cookie_service=None, + launch_data_storage=None, + ): + django_request = ( + request if isinstance(request, Request) else DjangoRequest(request) + ) + cookie_service = ( + cookie_service if cookie_service else DjangoCookieService(django_request) + ) + session_service = ( + session_service if session_service else DjangoSessionService(request) + ) + super().__init__( + django_request, + tool_config, + session_service, + cookie_service, + launch_data_storage, + ) def get_redirect(self, url): return DjangoRedirect(url, self._cookie_service) diff --git a/pylti1p3/contrib/django/redirect.py b/pylti1p3/contrib/django/redirect.py index 038b612..3666711 100644 --- a/pylti1p3/contrib/django/redirect.py +++ b/pylti1p3/contrib/django/redirect.py @@ -8,7 +8,7 @@ class DjangoRedirect(Redirect): _cookie_service = None def __init__(self, location, cookie_service=None): - super(DjangoRedirect, self).__init__() + super().__init__() self._location = location self._cookie_service = cookie_service @@ -17,7 +17,10 @@ def do_redirect(self): def do_js_redirect(self): return self._process_response( - HttpResponse('' % self._location)) + HttpResponse( + f'' + ) + ) def set_redirect_url(self, location): self._location = location diff --git a/pylti1p3/contrib/django/request.py b/pylti1p3/contrib/django/request.py index 266a61a..bc314c3 100644 --- a/pylti1p3/contrib/django/request.py +++ b/pylti1p3/contrib/django/request.py @@ -21,7 +21,9 @@ def set_request(self, request): def get_param(self, key): if self._post_only: return self._request.POST.get(key, self._default_params.get(key)) - return self._request.GET.get(key, self._request.POST.get(key, self._default_params.get(key))) + return self._request.GET.get( + key, self._request.POST.get(key, self._default_params.get(key)) + ) def get_cookie(self, key): return self._request.COOKIES.get(key) diff --git a/pylti1p3/contrib/flask/__init__.py b/pylti1p3/contrib/flask/__init__.py index 6a49b2e..9a225b2 100644 --- a/pylti1p3/contrib/flask/__init__.py +++ b/pylti1p3/contrib/flask/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .cookie import FlaskCookieService from .oidc_login import FlaskOIDCLogin from .message_launch import FlaskMessageLaunch diff --git a/pylti1p3/contrib/flask/cookie.py b/pylti1p3/contrib/flask/cookie.py index cdd5e3f..775855f 100644 --- a/pylti1p3/contrib/flask/cookie.py +++ b/pylti1p3/contrib/flask/cookie.py @@ -1,6 +1,3 @@ -import warnings -import werkzeug - from pylti1p3.cookie import CookieService @@ -13,37 +10,25 @@ def __init__(self, request): self._cookie_data_to_set = {} def _get_key(self, key): - return self._cookie_prefix + '-' + key + return self._cookie_prefix + "-" + key def get_cookie(self, name): return self._request.get_cookie(self._get_key(name)) def set_cookie(self, name, value, exp=3600): - self._cookie_data_to_set[self._get_key(name)] = { - 'value': value, - 'exp': exp - } + self._cookie_data_to_set[self._get_key(name)] = {"value": value, "exp": exp} def update_response(self, response): - warning_raised = False - werkzeug_version = int(werkzeug.__version__.split('.')[0]) - for key, cookie_data in self._cookie_data_to_set.items(): cookie_kwargs = dict( key=key, - value=cookie_data['value'], - max_age=cookie_data['exp'], + value=cookie_data["value"], + max_age=cookie_data["exp"], secure=self._request.is_secure(), - path='/', + path="/", httponly=True, ) - if werkzeug_version >= 1 and self._request.is_secure(): - cookie_kwargs['samesite'] = 'None' - elif werkzeug_version < 1 and not warning_raised: - warnings.warn("The samesite argument is not allowed for werkzeug less than 1.0. " - "So there is no guarantee that PyLTI1p3 cookies will be set inside the iframes. " - "Please update werkzeug", Warning) - warning_raised = True - + if self._request.is_secure(): + cookie_kwargs["samesite"] = "None" response.set_cookie(**cookie_kwargs) diff --git a/pylti1p3/contrib/flask/launch_data_storage/cache.py b/pylti1p3/contrib/flask/launch_data_storage/cache.py index a214ea6..ce77b7c 100644 --- a/pylti1p3/contrib/flask/launch_data_storage/cache.py +++ b/pylti1p3/contrib/flask/launch_data_storage/cache.py @@ -6,4 +6,4 @@ class FlaskCacheDataStorage(CacheDataStorage): def __init__(self, cache, **kwargs): self._cache = cache - super(FlaskCacheDataStorage, self).__init__(cache, **kwargs) + super().__init__(cache, **kwargs) diff --git a/pylti1p3/contrib/flask/message_launch.py b/pylti1p3/contrib/flask/message_launch.py index 347de2c..b811995 100644 --- a/pylti1p3/contrib/flask/message_launch.py +++ b/pylti1p3/contrib/flask/message_launch.py @@ -4,13 +4,29 @@ class FlaskMessageLaunch(MessageLaunch): - - def __init__(self, request, tool_config, session_service=None, cookie_service=None, launch_data_storage=None, - requests_session=None): - cookie_service = cookie_service if cookie_service else FlaskCookieService(request) - session_service = session_service if session_service else FlaskSessionService(request) - super(FlaskMessageLaunch, self).__init__(request, tool_config, session_service, cookie_service, - launch_data_storage, requests_session) + def __init__( + self, + request, + tool_config, + session_service=None, + cookie_service=None, + launch_data_storage=None, + requests_session=None, + ): + cookie_service = ( + cookie_service if cookie_service else FlaskCookieService(request) + ) + session_service = ( + session_service if session_service else FlaskSessionService(request) + ) + super().__init__( + request, + tool_config, + session_service, + cookie_service, + launch_data_storage, + requests_session, + ) def _get_request_param(self, key): return self._request.get_param(key) diff --git a/pylti1p3/contrib/flask/oidc_login.py b/pylti1p3/contrib/flask/oidc_login.py index 58767b0..b0fee37 100644 --- a/pylti1p3/contrib/flask/oidc_login.py +++ b/pylti1p3/contrib/flask/oidc_login.py @@ -1,4 +1,4 @@ -from flask import make_response +from flask import make_response # type: ignore from pylti1p3.oidc_login import OIDCLogin from .cookie import FlaskCookieService from .session import FlaskSessionService @@ -6,11 +6,23 @@ class FlaskOIDCLogin(OIDCLogin): - - def __init__(self, request, tool_config, session_service=None, cookie_service=None, launch_data_storage=None): - cookie_service = cookie_service if cookie_service else FlaskCookieService(request) - session_service = session_service if session_service else FlaskSessionService(request) - super(FlaskOIDCLogin, self).__init__(request, tool_config, session_service, cookie_service, launch_data_storage) + def __init__( + self, + request, + tool_config, + session_service=None, + cookie_service=None, + launch_data_storage=None, + ): + cookie_service = ( + cookie_service if cookie_service else FlaskCookieService(request) + ) + session_service = ( + session_service if session_service else FlaskSessionService(request) + ) + super().__init__( + request, tool_config, session_service, cookie_service, launch_data_storage + ) def get_redirect(self, url): return FlaskRedirect(url, self._cookie_service) diff --git a/pylti1p3/contrib/flask/redirect.py b/pylti1p3/contrib/flask/redirect.py index a92b927..7b25943 100644 --- a/pylti1p3/contrib/flask/redirect.py +++ b/pylti1p3/contrib/flask/redirect.py @@ -1,4 +1,4 @@ -from flask import make_response, redirect +from flask import make_response, redirect # type: ignore from pylti1p3.redirect import Redirect @@ -8,7 +8,7 @@ class FlaskRedirect(Redirect): _cookie_service = None def __init__(self, location, cookie_service=None): - super(FlaskRedirect, self).__init__() + super().__init__() self._location = location self._cookie_service = cookie_service @@ -17,8 +17,9 @@ def do_redirect(self): def do_js_redirect(self): return self._process_response( - make_response(''.format(self._location)) + make_response( + f'' + ) ) def set_redirect_url(self, location): diff --git a/pylti1p3/contrib/flask/request.py b/pylti1p3/contrib/flask/request.py index b12831c..706319d 100644 --- a/pylti1p3/contrib/flask/request.py +++ b/pylti1p3/contrib/flask/request.py @@ -1,36 +1,37 @@ -import typing as t - -from flask import request +from flask import request # type: ignore 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 # type: SessionLike _cookies = None _request_data = None _request_is_secure = None + _session = None - def __init__(self, cookies=None, session=None, request_data=None, request_is_secure=None): - super(FlaskRequest, self).__init__() + def __init__( + self, cookies=None, session=None, request_data=None, request_is_secure=None + ): + super().__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 + 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 + ) if request_data: self._request_data = request_data + @property + def session(self): + return self._session + def get_param(self, key): if self._request_data: return self._request_data.get(key) - else: - if request.method == 'GET': - return request.args.get(key, None) - else: - return request.form.get(key, None) + if request.method == "GET": + return request.args.get(key, None) + return request.form.get(key, None) def get_cookie(self, key): return self._cookies.get(key) diff --git a/pylti1p3/cookie.py b/pylti1p3/cookie.py index 5b94da8..1c5a95e 100644 --- a/pylti1p3/cookie.py +++ b/pylti1p3/cookie.py @@ -2,16 +2,16 @@ from abc import ABCMeta, abstractmethod -class CookieService(object): +class CookieService: __metaclass__ = ABCMeta - _cookie_prefix = 'lti1p3' # type: str + _cookie_prefix: str = "lti1p3" @abstractmethod - def get_cookie(self, name): - # type: (str) -> t.Optional[str] + def get_cookie(self, name: str) -> t.Optional[str]: raise NotImplementedError @abstractmethod - def set_cookie(self, name, value, exp=3600): - # type: (str, str, int) -> None + def set_cookie( + self, name: str, value: t.Union[str, int], exp: t.Optional[int] = 3600 + ): raise NotImplementedError diff --git a/pylti1p3/cookies_allowed_check.py b/pylti1p3/cookies_allowed_check.py index 9f517c2..388d924 100644 --- a/pylti1p3/cookies_allowed_check.py +++ b/pylti1p3/cookies_allowed_check.py @@ -1,20 +1,25 @@ -try: - from html import escape # type: ignore -except ImportError: - from cgi import escape # type: ignore +from html import escape # type: ignore import json import typing as t -class CookiesAllowedCheckPage(object): - _params = {} # type: t.Mapping[str, str] - _protocol = 'http' # type: str - _main_text = '' # type: str - _click_text = '' # type: str - _loading_text = '' # type: str +class CookiesAllowedCheckPage: + _params: t.Mapping[str, str] = {} + _protocol: str = "http" + _main_text: str = "" + _click_text: str = "" + _loading_text: 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 + def __init__( + self, + params: t.Mapping[str, str], + protocol: str, + main_text: str, + click_text: str, + loading_text: str, + *args, + **kwargs + ): # pylint: disable=unused-argument self._params = params self._protocol = protocol @@ -22,8 +27,7 @@ def __init__(self, params, protocol, main_text, click_text, loading_text, *args, self._click_text = click_text self._loading_text = loading_text - def get_css_block(self): - # type: () -> str + def get_css_block(self) -> str: css_block = """\ body { font-family: Geneva, Arial, Helvetica, sans-serif; @@ -31,8 +35,7 @@ def get_css_block(self): """ return css_block - def get_js_block(self): - # type: () -> str + def get_js_block(self) -> str: js_block = """\ var siteProtocol = '%s'; var urlParams = %s; @@ -103,15 +106,16 @@ def get_js_block(self): document.addEventListener("DOMContentLoaded", checkCookiesAllowed); """ # pylint: disable=deprecated-method - js_block = js_block % (self._protocol, json.dumps({k: escape(v, True) for k, v in self._params.items()})) + js_block = js_block % ( + self._protocol, + json.dumps({k: escape(v, True) for k, v in self._params.items()}), + ) return js_block - def get_header_block(self): - # type: () -> str - return '' + def get_header_block(self) -> str: + return "" - def get_html(self): - # type: () -> str + def get_html(self) -> str: html = """\ @@ -142,5 +146,6 @@ def get_html(self): loading_text=self._loading_text, header_block=self.get_header_block(), main_text=self._main_text, - click_text=self._click_text) + click_text=self._click_text, + ) return html diff --git a/pylti1p3/course_groups.py b/pylti1p3/course_groups.py index 2f3fb83..1cc62e5 100644 --- a/pylti1p3/course_groups.py +++ b/pylti1p3/course_groups.py @@ -1,54 +1,66 @@ import typing as t - +import typing_extensions as te from .utils import add_param_to_url +from .service_connector import ServiceConnector - -if t.TYPE_CHECKING: - from .service_connector import ServiceConnector - from mypy_extensions import TypedDict - from typing_extensions import Literal - - _GroupsServiceData = TypedDict('_GroupsServiceData', { +TGroupsServiceData = te.TypedDict( + "TGroupsServiceData", + { # Required data - 'context_groups_url': str, - 'scope': t.List[Literal['https://purl.imsglobal.org/spec/lti-gs/scope/contextgroup.readonly']], - 'service_versions': t.List[str], - + "context_groups_url": str, + "scope": t.List[ + te.Literal[ + "https://purl.imsglobal.org/spec/lti-gs/scope/contextgroup.readonly" + ] + ], + "service_versions": t.List[str], # Optional data - 'context_group_sets_url': str - }, total=False) - - _Group = TypedDict('_Group', { + "context_group_sets_url": str, + }, + total=False, +) + +TGroup = te.TypedDict( + "TGroup", + { # Required data - 'id': t.Union[str, int], - 'name': str, - + "id": t.Union[str, int], + "name": str, # Optional data - 'tag': str, - 'set_id': t.Union[str, int] - }, total=False) - - _Set = TypedDict('_Set', { + "tag": str, + "set_id": t.Union[str, int], + }, + total=False, +) + +TSet = te.TypedDict( + "TSet", + { # Required data - 'id': t.Union[str, int], - 'name': str, - + "id": t.Union[str, int], + "name": str, # Optional data - 'groups': t.List[_Group] - }, total=False) + "groups": t.List[TGroup], + }, + total=False, +) -class CourseGroupsService(object): - _service_connector = None # type: ServiceConnector - _service_data = None # type: _GroupsServiceData +class CourseGroupsService: + _service_connector: ServiceConnector + _service_data: TGroupsServiceData - def __init__(self, service_connector, groups_service_data): - # type: (ServiceConnector, _GroupsServiceData) -> None + def __init__( + self, + service_connector: ServiceConnector, + groups_service_data: TGroupsServiceData, + ): self._service_connector = service_connector self._service_data = groups_service_data - def get_page(self, data_url, data_key='groups'): - # type: (str, t.Optional[str]) -> t.Tuple[list, t.Optional[str]] + def get_page( + self, data_url: str, data_key: str = "groups" + ) -> t.Tuple[list, t.Optional[str]]: """ Get one page with the groups/sets. @@ -57,47 +69,47 @@ def get_page(self, data_url, data_key='groups'): :return: tuple in format: (list with data items, next page url) """ data = self._service_connector.make_service_request( - self._service_data['scope'], + self._service_data["scope"], data_url, - accept='application/vnd.ims.lti-gs.v1.contextgroupcontainer+json', + accept="application/vnd.ims.lti-gs.v1.contextgroupcontainer+json", ) - data_body = t.cast(t.Any, data.get('body', {})) - return data_body.get(data_key, []), data['next_page_url'] + data_body = t.cast(t.Any, data.get("body", {})) + return data_body.get(data_key, []), data["next_page_url"] def get_groups(self, user_id=None): - groups_res_lst = [] # type: t.List[_Group] - groups_url = self._service_data.get('context_groups_url') # type: t.Optional[str] + groups_res_lst = [] + groups_url = self._service_data.get("context_groups_url") if user_id: - groups_url = add_param_to_url(groups_url, 'user_id', user_id) + groups_url = add_param_to_url(groups_url, "user_id", user_id) while groups_url: - groups, groups_url = self.get_page(groups_url, data_key='groups') + groups, groups_url = self.get_page(groups_url, data_key="groups") groups_res_lst.extend(groups) return groups_res_lst def has_sets(self): - return 'context_group_sets_url' in self._service_data + return "context_group_sets_url" in self._service_data def get_sets(self, include_groups=False): - sets_res_lst = [] # type: t.List[_Set] - sets_url = self._service_data.get('context_group_sets_url') # type: t.Optional[str] + sets_res_lst = [] + sets_url = self._service_data.get("context_group_sets_url") while sets_url: - sets, sets_url = self.get_page(sets_url, data_key='sets') + sets, sets_url = self.get_page(sets_url, data_key="sets") sets_res_lst.extend(sets) if include_groups and sets_res_lst: set_id_to_index = {} for i, s in enumerate(sets_res_lst): - set_id_to_index[s['id']] = i - sets_res_lst[i]['groups'] = [] + set_id_to_index[s["id"]] = i + sets_res_lst[i]["groups"] = [] groups = self.get_groups() for group in groups: - set_id = group.get('set_id') + set_id = group.get("set_id") if set_id and set_id in set_id_to_index: index = set_id_to_index[set_id] - sets_res_lst[index]['groups'].append(group) + sets_res_lst[index]["groups"].append(group) return sets_res_lst diff --git a/pylti1p3/deep_link.py b/pylti1p3/deep_link.py index 42e572d..fdce52c 100644 --- a/pylti1p3/deep_link.py +++ b/pylti1p3/deep_link.py @@ -1,43 +1,43 @@ -import sys import time import typing as t import uuid import jwt # type: ignore +import typing_extensions as te +from .deep_link_resource import DeepLinkResource +from .registration import Registration -if t.TYPE_CHECKING: - from .deep_link_resource import DeepLinkResource - from .registration import Registration - from typing_extensions import Literal - from mypy_extensions import TypedDict +TDeepLinkData = te.TypedDict( + "TDeepLinkData", + { + # Required data: + "deep_link_return_url": str, + "accept_types": t.List[te.Literal["link", "ltiResourceLink"]], + "accept_presentation_document_targets": t.List[ + te.Literal["iframe", "window", "embed"] + ], + # Optional data + "accept_multiple": t.Union[bool, te.Literal["true", "false"]], + "auto_create": t.Union[bool, te.Literal["true", "false"]], + "title": str, + "text": str, + "data": object, + }, + total=False, +) - _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: + _registration: Registration + _deployment_id: str + _deep_link_settings: TDeepLinkData - -class DeepLink(object): - _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 + def __init__( + self, + registration: Registration, + deployment_id: str, + deep_link_settings: TDeepLinkData, + ): self._registration = registration self._deployment_id = deployment_id self._deep_link_settings = deep_link_settings @@ -45,19 +45,24 @@ def __init__(self, registration, deployment_id, deep_link_settings): 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] + def get_message_jwt( + self, resources: t.Sequence[DeepLinkResource] + ) -> t.Dict[str, object]: message_jwt = { - 'iss': self._registration.get_client_id(), - 'aud': [self._registration.get_issuer()], - 'exp': int(time.time()) + 600, - 'iat': int(time.time()), - 'nonce': 'nonce-' + self._generate_nonce(), - 'https://purl.imsglobal.org/spec/lti/claim/deployment_id': self._deployment_id, - 'https://purl.imsglobal.org/spec/lti/claim/message_type': 'LtiDeepLinkingResponse', - 'https://purl.imsglobal.org/spec/lti/claim/version': '1.3.0', - 'https://purl.imsglobal.org/spec/lti-dl/claim/content_items': [r.to_dict() for r in resources], - 'https://purl.imsglobal.org/spec/lti-dl/claim/data': self._deep_link_settings.get('data') + "iss": self._registration.get_client_id(), + "aud": [self._registration.get_issuer()], + "exp": int(time.time()) + 600, + "iat": int(time.time()), + "nonce": "nonce-" + self._generate_nonce(), + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": self._deployment_id, + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiDeepLinkingResponse", + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "https://purl.imsglobal.org/spec/lti-dl/claim/content_items": [ + r.to_dict() for r in resources + ], + "https://purl.imsglobal.org/spec/lti-dl/claim/data": self._deep_link_settings.get( + "data" + ), } return message_jwt @@ -65,27 +70,31 @@ def encode_jwt(self, message): headers = None kid = self._registration.get_kid() if kid: - headers = {'kid': kid} - encoded_jwt = jwt.encode(message, self._registration.get_tool_private_key(), algorithm='RS256', - headers=headers) - if sys.version_info[0] > 2 and isinstance(encoded_jwt, bytes): - return encoded_jwt.decode('utf-8') + headers = {"kid": kid} + encoded_jwt = jwt.encode( + message, + self._registration.get_tool_private_key(), + algorithm="RS256", + headers=headers, + ) + if isinstance(encoded_jwt, bytes): + return encoded_jwt.decode("utf-8") return encoded_jwt - def get_response_jwt(self, resources): - # type: (t.Sequence[DeepLinkResource]) -> str + def get_response_jwt(self, resources: 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 = '' \ - '' % (self._deep_link_settings['deep_link_return_url'], jwt_val) + def get_response_form_html(self, jwt_val: str) -> str: + deep_link_return_url = self._deep_link_settings["deep_link_return_url"] + html = ( + f'' + f"" + ) return html - def output_response_form(self, resources): - # type: (t.List[DeepLinkResource]) -> str + def output_response_form(self, resources: t.List[DeepLinkResource]) -> str: jwt_val = self.get_response_jwt(resources) return self.get_response_form_html(jwt_val) diff --git a/pylti1p3/deep_link_resource.py b/pylti1p3/deep_link_resource.py index e210fad..131606f 100644 --- a/pylti1p3/deep_link_resource.py +++ b/pylti1p3/deep_link_resource.py @@ -1,116 +1,96 @@ import typing as t +from .lineitem import LineItem -if t.TYPE_CHECKING: - from .lineitem import LineItem - T_SELF = t.TypeVar('T_SELF', bound='DeepLinkResource') - -class DeepLinkResource(object): - _type = 'ltiResourceLink' # type: str - _title = None # type: t.Optional[str] - _url = None # type: t.Optional[str] - _lineitem = None # type: t.Optional[LineItem] - _custom_params = {} # type: t.Mapping[str, str] - _target = 'iframe' # type: str - _icon_url = None # type: t.Optional[str] +class DeepLinkResource: + _type: str = "ltiResourceLink" + _title: t.Optional[str] = None + _url: t.Optional[str] = None + _lineitem: t.Optional[LineItem] = None + _custom_params: t.Mapping[str, str] = {} + _target: str = "iframe" + _icon_url: t.Optional[str] = None def get_type(self): - # type: () -> str return self._type - def set_type(self, value): - # type: (T_SELF, str) -> T_SELF + def set_type(self, value: str) -> "DeepLinkResource": self._type = value return self - def get_title(self): - # type: () -> t.Optional[str] + def get_title(self) -> t.Optional[str]: return self._title - def set_title(self, value): - # type: (T_SELF, str) -> T_SELF + def set_title(self, value: str) -> "DeepLinkResource": self._title = value return self - def get_url(self): - # type: () -> t.Optional[str] + def get_url(self) -> t.Optional[str]: return self._url - def set_url(self, value): - # type: (T_SELF, str) -> T_SELF + def set_url(self, value: str) -> "DeepLinkResource": self._url = value return self - def get_lineitem(self): - # type: () -> t.Optional[LineItem] + def get_lineitem(self) -> t.Optional[LineItem]: return self._lineitem - def set_lineitem(self, value): - # type: (T_SELF, LineItem) -> T_SELF + def set_lineitem(self, value: LineItem) -> "DeepLinkResource": self._lineitem = value return self - def get_custom_params(self): - # type: () -> t.Mapping[str, str] + def get_custom_params(self) -> t.Mapping[str, str]: return self._custom_params - def set_custom_params(self, value): - # type: (T_SELF, t.Mapping[str, str]) -> T_SELF + def set_custom_params(self, value: t.Mapping[str, str]) -> "DeepLinkResource": self._custom_params = value return self - def get_target(self): - # type: () -> str + def get_target(self) -> str: return self._target - def set_target(self, value): - # type: (T_SELF, str) -> T_SELF + def set_target(self, value: str) -> "DeepLinkResource": self._target = value return self - def get_icon_url(self): - # type: () -> t.Optional[str] + def get_icon_url(self) -> t.Optional[str]: return self._icon_url - def set_icon_url(self, value): - # type: (T_SELF, str) -> T_SELF + def set_icon_url(self, value: str) -> "DeepLinkResource": self._icon_url = value return self - def to_dict(self): - # type: () -> t.Dict[str, object] - res = { - 'type': self._type, - 'title': self._title, - 'url': self._url, - 'custom': self._custom_params - } # type: t.Dict[str, object] + def to_dict(self) -> t.Dict[str, object]: + res: t.Dict[str, object] = { + "type": self._type, + "title": self._title, + "url": self._url, + "custom": self._custom_params, + } if self._lineitem: - line_item = { - 'scoreMaximum': self._lineitem.get_score_maximum(), - } # type: t.Dict[str, object] + line_item: t.Dict[str, object] = { + "scoreMaximum": self._lineitem.get_score_maximum(), + } label = self._lineitem.get_label() if label: - line_item['label'] = label + line_item["label"] = label resource_id = self._lineitem.get_resource_id() if resource_id: - line_item['resourceId'] = resource_id + line_item["resourceId"] = resource_id tag = self._lineitem.get_tag() if tag: - line_item['tag'] = tag + line_item["tag"] = tag submission_review = self._lineitem.get_submission_review() if submission_review: - line_item['submissionReview'] = submission_review + line_item["submissionReview"] = submission_review - res['lineItem'] = line_item + res["lineItem"] = line_item if self._icon_url: - res['icon'] = { - 'url': self._icon_url - } + res["icon"] = {"url": self._icon_url} return res diff --git a/pylti1p3/deployment.py b/pylti1p3/deployment.py index 6489ce8..cfbbf9b 100644 --- a/pylti1p3/deployment.py +++ b/pylti1p3/deployment.py @@ -1,18 +1,13 @@ import typing as t -if t.TYPE_CHECKING: - T_SELF = t.TypeVar('T_SELF', bound='Deployment') +class Deployment: -class Deployment(object): + _deployment_id: t.Optional[str] = None - _deployment_id = None # type: t.Optional[str] - - def get_deployment_id(self): - # type: () -> t.Optional[str] + def get_deployment_id(self) -> t.Optional[str]: return self._deployment_id - def set_deployment_id(self, deployment_id): - # type: (T_SELF, str) -> T_SELF + def set_deployment_id(self, deployment_id: str) -> "Deployment": self._deployment_id = deployment_id return self diff --git a/pylti1p3/exception.py b/pylti1p3/exception.py index 4648704..e8fccfa 100644 --- a/pylti1p3/exception.py +++ b/pylti1p3/exception.py @@ -1,8 +1,4 @@ -import typing as t - -if t.TYPE_CHECKING: - # pylint: disable=unused-import - import requests +import requests class LtiException(Exception): @@ -14,12 +10,7 @@ class OIDCException(Exception): class LtiServiceException(LtiException): - def __init__(self, response): - # type: (requests.Response) -> None - msg = 'HTTP response [%s]: %s - %s' % ( - response.url, - str(response.status_code), - response.text, - ) - super(LtiServiceException, self).__init__(msg) + def __init__(self, response: requests.Response): + msg = f"HTTP response [{response.url}]: {str(response.status_code)} - {response.text}" + super().__init__(msg) self.response = response diff --git a/pylti1p3/grade.py b/pylti1p3/grade.py index b967769..cc36636 100644 --- a/pylti1p3/grade.py +++ b/pylti1p3/grade.py @@ -2,159 +2,140 @@ import typing as t from .exception import LtiException -if t.TYPE_CHECKING: - T_SELF = t.TypeVar('T_SELF', bound='Grade') - EXTRA_CLAIMS = t.Mapping[str, t.Any] - - -class Grade(object): - _score_given = None # type: t.Optional[float] - _score_maximum = None # type: t.Optional[float] - _activity_progress = None # type: t.Optional[str] - _grading_progress = None # type: t.Optional[str] - _timestamp = None # type: t.Optional[str] - _user_id = None # type: t.Optional[str] - _comment = None # type: t.Optional[str] - _extra_claims = None # type: t.Optional[EXTRA_CLAIMS] - - def _validate_score(self, score_value): - # type: (T_SELF, t.Any) -> t.Optional[str] + +TExtaClaims = t.Mapping[str, t.Any] + + +class Grade: + _score_given: t.Optional[float] = None + _score_maximum: t.Optional[float] = None + _activity_progress: t.Optional[str] = None + _grading_progress: t.Optional[str] = None + _timestamp: t.Optional[str] = None + _user_id: t.Optional[str] = None + _comment: t.Optional[str] = None + _extra_claims: t.Optional[TExtaClaims] = None + + def _validate_score(self, score_value) -> t.Optional[str]: if not isinstance(score_value, (int, float)): - return 'score must be integer or float' + return "score must be integer or float" if score_value < 0: - return 'score must be positive number (including 0)' + return "score must be positive number (including 0)" return None - def get_score_given(self): - # type: () -> t.Optional[float] + def get_score_given(self) -> t.Optional[float]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#scoregiven-and-scoremaximum """ return self._score_given - def set_score_given(self, value): - # type: (T_SELF, float) -> T_SELF + def set_score_given(self, value: float) -> "Grade": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#scoregiven-and-scoremaximum """ err_msg = self._validate_score(value) if err_msg is not None: - raise LtiException('Invalid scoreGiven value: ' + err_msg) + raise LtiException("Invalid scoreGiven value: " + err_msg) self._score_given = value return self - def get_score_maximum(self): - # type: () -> t.Optional[float] + def get_score_maximum(self) -> t.Optional[float]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#scoregiven-and-scoremaximum """ return self._score_maximum - def set_score_maximum(self, value): - # type: (T_SELF, float) -> T_SELF + def set_score_maximum(self, value: float) -> "Grade": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#scoregiven-and-scoremaximum """ err_msg = self._validate_score(value) if err_msg is not None: - raise LtiException('Invalid scoreMaximum value: ' + err_msg) + raise LtiException("Invalid scoreMaximum value: " + err_msg) self._score_maximum = value return self - def get_activity_progress(self): - # type: () -> t.Optional[str] + def get_activity_progress(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#activityprogress """ return self._activity_progress - def set_activity_progress(self, value): - # type: (T_SELF, str) -> T_SELF + def set_activity_progress(self, value: str) -> "Grade": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#activityprogress """ self._activity_progress = value return self - def get_grading_progress(self): - # type: () -> t.Optional[str] + def get_grading_progress(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#gradingprogress """ return self._grading_progress - def set_grading_progress(self, value): - # type: (T_SELF, str) -> T_SELF + def set_grading_progress(self, value: str) -> "Grade": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#gradingprogress """ self._grading_progress = value return self - def get_timestamp(self): - # type: () -> t.Optional[str] + def get_timestamp(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#timestamp """ return self._timestamp - def set_timestamp(self, value): - # type: (T_SELF, str) -> T_SELF + def set_timestamp(self, value: str) -> "Grade": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#timestamp """ self._timestamp = value return self - def get_user_id(self): - # type: () -> t.Optional[str] + def get_user_id(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#userid-0 """ return self._user_id - def set_user_id(self, value): - # type: (T_SELF, str) -> T_SELF + def set_user_id(self, value: str) -> "Grade": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#userid-0 """ self._user_id = value return self - def get_comment(self): - # type: () -> t.Optional[str] + def get_comment(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#comment-0 """ return self._comment - def set_comment(self, value): - # type: (T_SELF, str) -> T_SELF + def set_comment(self, value: str) -> "Grade": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#comment-0 """ self._comment = value return self - def set_extra_claims(self, value): - # type: (T_SELF, EXTRA_CLAIMS) -> T_SELF + def set_extra_claims(self, value: TExtaClaims) -> "Grade": self._extra_claims = value return self - def get_extra_claims(self): - # type: () -> t.Optional[EXTRA_CLAIMS] + def get_extra_claims(self) -> t.Optional[TExtaClaims]: return self._extra_claims - def get_value(self): - # type: () -> str + def get_value(self) -> str: data = { - 'scoreGiven': self._score_given, - 'scoreMaximum': self._score_maximum, - 'activityProgress': self._activity_progress, - 'gradingProgress': self._grading_progress, - 'timestamp': self._timestamp, - 'userId': self._user_id, - 'comment': self._comment + "scoreGiven": self._score_given, + "scoreMaximum": self._score_maximum, + "activityProgress": self._activity_progress, + "gradingProgress": self._grading_progress, + "timestamp": self._timestamp, + "userId": self._user_id, + "comment": self._comment, } if self._extra_claims is not None: data.update(self._extra_claims) diff --git a/pylti1p3/launch_data_storage/base.py b/pylti1p3/launch_data_storage/base.py index 7897d25..ebbef22 100644 --- a/pylti1p3/launch_data_storage/base.py +++ b/pylti1p3/launch_data_storage/base.py @@ -1,93 +1,75 @@ import typing as t from abc import ABCMeta, abstractmethod +from ..request import Request -if t.TYPE_CHECKING: - from ..request import Request - -_T_DISABLED_SESSION_ID = t.TypeVar('_T_DISABLED_SESSION_ID', bound='DisableSessionId') -T = t.TypeVar('T') +T = t.TypeVar("T") class LaunchDataStorage(t.Generic[T]): __metaclass__ = ABCMeta - _request = None # type: t.Optional[Request] - _session_id = None # type: t.Optional[str] - _session_cookie_name = 'session-id' # type: str - _prefix = 'lti1p3-' # type: str + _request: t.Optional[Request] = None + _session_id: t.Optional[str] = None + _session_cookie_name: str = "session-id" + _prefix: str = "lti1p3-" - def __init__(self, *args, **kwargs): - # type: (*t.Any, **t.Any) -> None + def __init__(self, *args, **kwargs) -> None: pass - def set_request(self, request): - # type: (Request) -> None + def set_request(self, request: Request) -> None: self._request = request - def get_session_cookie_name(self): - # type: () -> t.Optional[str] + def get_session_cookie_name(self) -> t.Optional[str]: return self._session_cookie_name - def get_session_id(self): - # type: () -> t.Optional[str] + def get_session_id(self) -> t.Optional[str]: return self._session_id - def set_session_id(self, session_id): - # type: (str) -> None + def set_session_id(self, session_id: str) -> None: self._session_id = session_id - def remove_session_id(self): - # type: () -> None + def remove_session_id(self) -> None: self._session_id = None - def _prepare_key(self, key): - # type: (str) -> str + def _prepare_key(self, key: str) -> str: if self._session_id: if key.startswith(self._prefix): - key = key[len(self._prefix):] - return self._prefix + self._session_id + '-' + key - else: - if not key.startswith(self._prefix): - key = self._prefix + key - return key + key = key[len(self._prefix) :] + return self._prefix + self._session_id + "-" + key + if not key.startswith(self._prefix): + key = self._prefix + key + return key @abstractmethod - def can_set_keys_expiration(self): - # type: () -> bool + def can_set_keys_expiration(self) -> bool: raise NotImplementedError @abstractmethod - def get_value(self, key): - # type: (str) -> T + def get_value(self, key: str) -> T: raise NotImplementedError @abstractmethod - def set_value(self, key, value, exp=None): - # type: (str, T, t.Optional[int]) -> None + def set_value(self, key: str, value: T, exp: t.Optional[int] = None) -> None: raise NotImplementedError @abstractmethod - def check_value(self, key): - # type: (str) -> bool + def check_value(self, key: str) -> bool: raise NotImplementedError -class DisableSessionId(object): - _session_id = None # type: t.Optional[str] - _launch_data_storage = None # type: t.Optional[LaunchDataStorage] +class DisableSessionId: + _session_id: t.Optional[str] = None + _launch_data_storage: t.Optional[LaunchDataStorage] = None - def __init__(self, launch_data_storage): - # type: (t.Optional[LaunchDataStorage]) -> None + def __init__(self, launch_data_storage: t.Optional[LaunchDataStorage]) -> None: self._launch_data_storage = launch_data_storage if launch_data_storage: self._session_id = launch_data_storage.get_session_id() - def __enter__(self): - # type: (_T_DISABLED_SESSION_ID) -> _T_DISABLED_SESSION_ID + def __enter__(self) -> "DisableSessionId": if self._launch_data_storage: self._launch_data_storage.remove_session_id() return self - def __exit__(self, *args): - # type: (*t.Any) -> None + def __exit__(self, *args) -> None: if self._launch_data_storage and self._session_id: self._launch_data_storage.set_session_id(self._session_id) diff --git a/pylti1p3/launch_data_storage/cache.py b/pylti1p3/launch_data_storage/cache.py index 993df5a..f97953a 100644 --- a/pylti1p3/launch_data_storage/cache.py +++ b/pylti1p3/launch_data_storage/cache.py @@ -2,15 +2,13 @@ from .base import LaunchDataStorage -T = t.TypeVar('T') +T = t.TypeVar("T") class CacheDataStorage(LaunchDataStorage[T], t.Generic[T]): - # This seems a bit strange, I have no idea when this is ever set. - _cache = None # type: t.Any + _cache = None - def get_session_cookie_name(self): - # type: () -> t.Optional[str] + def get_session_cookie_name(self) -> t.Optional[str]: """ Workaround for the local non-HTTP usage. There is odd situation that all cookies become unavailable from some time @@ -20,26 +18,26 @@ def get_session_cookie_name(self): It is less secure because if you know unique launch_id you may get access to launch data, but unfortunately there is no other way. So please use HTTPS on production :-) """ - assert self._request is not None, 'Request should be set at this point' + assert self._request is not None, "Request should be set at this point" if not self._request.is_secure(): return None - return super(CacheDataStorage, self).get_session_cookie_name() + return super().get_session_cookie_name() - def get_value(self, key): - # type: (str) -> T + def _get_cache(self): + assert self._cache is not None, "Cache is not set" + return self._cache + + def get_value(self, key) -> T: key = self._prepare_key(key) - return self._cache.get(key) + return self._get_cache().get(key) - def set_value(self, key, value, exp=None): - # type: (str, T, t.Optional[int]) -> None + def set_value(self, key: str, value: T, exp: t.Optional[int] = None) -> None: key = self._prepare_key(key) - self._cache.set(key, value, exp) + self._get_cache().set(key, value, exp) - def check_value(self, key): - # type: (str) -> bool + def check_value(self, key: str) -> bool: key = self._prepare_key(key) - return self._cache.get(key) is not None + return self._get_cache().get(key) is not None - def can_set_keys_expiration(self): - # type: () -> bool + def can_set_keys_expiration(self) -> bool: return True diff --git a/pylti1p3/launch_data_storage/session.py b/pylti1p3/launch_data_storage/session.py index 89a7ec1..ac59bea 100644 --- a/pylti1p3/launch_data_storage/session.py +++ b/pylti1p3/launch_data_storage/session.py @@ -2,34 +2,28 @@ from .base import LaunchDataStorage -T = t.TypeVar('T') +T = t.TypeVar("T") class SessionDataStorage(LaunchDataStorage[T], t.Generic[T]): - def get_session_cookie_name(self): - # type: () -> None + def get_session_cookie_name(self) -> None: return None - def set_session_id(self, session_id): - # type: (str) -> None + def set_session_id(self, session_id: str) -> None: pass - def get_value(self, key): - # type: (str) -> T - assert self._request is not None, 'Request should be set at this point' + def get_value(self, key: str) -> T: + assert self._request is not None, "Request should be set at this point" return self._request.session.get(key, None) - def set_value(self, key, value, exp=None): - # type: (str, T, t.Optional[int]) -> None + def set_value(self, key: str, value: T, exp: t.Optional[int] = None) -> None: # pylint: disable=unused-argument - assert self._request is not None, 'Request should be set at this point' + assert self._request is not None, "Request should be set at this point" self._request.session[key] = value - def check_value(self, key): - # type: (str) -> bool - assert self._request is not None, 'Request should be set at this point' + def check_value(self, key: str) -> bool: + assert self._request is not None, "Request should be set at this point" return key in self._request.session - def can_set_keys_expiration(self): - # type: () -> bool + def can_set_keys_expiration(self) -> bool: return False diff --git a/pylti1p3/lineitem.py b/pylti1p3/lineitem.py index 911c1b5..a9d77b3 100644 --- a/pylti1p3/lineitem.py +++ b/pylti1p3/lineitem.py @@ -1,37 +1,51 @@ import json import typing as t - +import typing_extensions as te from .exception import LtiException -if t.TYPE_CHECKING: - from mypy_extensions import TypedDict - - T_SELF = t.TypeVar('T_SELF', bound='LineItem') - _SubmissionReview = TypedDict('_SubmissionReview', { +TSubmissionReview = te.TypedDict( + "TSubmissionReview", + { # Required data - 'reviewableStatus': list, - + "reviewableStatus": list, # Optional data - 'label': str, - 'url': str, - 'custom': t.Dict[str, str], - }, total=False) - - -class LineItem(object): - _id = None # type: t.Optional[str] - _score_maximum = None # type: t.Optional[float] - _label = None # type: t.Optional[str] - _resource_id = None # type: t.Optional[str] - _resource_link_id = None # type: t.Optional[str] - _tag = None # type: t.Optional[str] - _start_date_time = None # type: t.Optional[str] - _end_date_time = None # type: t.Optional[str] - _submission_review = None # type: t.Optional[_SubmissionReview] - - def __init__(self, lineitem=None): - # type: (t.Optional[t.Mapping[str, t.Any]]) -> None + "label": str, + "url": str, + "custom": t.Dict[str, str], + }, + total=False, +) + +TLineItem = te.TypedDict( + "TLineItem", + { + "id": str, + "scoreMaximum": int, + "label": str, + "resourceId": str, + "tag": str, + "resourceLinkId": str, + "startDateTime": str, + "endDateTime": str, + "submissionReview": TSubmissionReview, + }, + total=False, +) + + +class LineItem: + _id: t.Optional[str] = None + _score_maximum: t.Optional[float] = None + _label: t.Optional[str] = None + _resource_id: t.Optional[str] = None + _resource_link_id: t.Optional[str] = None + _tag: t.Optional[str] = None + _start_date_time: t.Optional[str] = None + _end_date_time: t.Optional[str] = None + _submission_review: t.Optional[TSubmissionReview] = None + + def __init__(self, lineitem: t.Optional[TLineItem] = None): if not lineitem: lineitem = {} self._id = lineitem.get("id") @@ -44,155 +58,148 @@ def __init__(self, lineitem=None): self._end_date_time = lineitem.get("endDateTime") self._submission_review = lineitem.get("submissionReview") - def get_id(self): - # type: () -> t.Optional[str] + def get_id(self) -> t.Optional[str]: return self._id - def set_id(self, value): - # type: (T_SELF, str) -> T_SELF + def set_id(self, value: str) -> "LineItem": self._id = value return self - def get_label(self): - # type: () -> t.Optional[str] + def get_label(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#label """ return self._label - def set_label(self, value): - # type: (T_SELF, str) -> T_SELF + def set_label(self, value: str) -> "LineItem": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#label """ self._label = value return self - def get_score_maximum(self): - # type: () -> t.Optional[float] + def get_score_maximum(self) -> t.Optional[float]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#scoremaximum """ return self._score_maximum - def set_score_maximum(self, value): - # type: (T_SELF, float) -> T_SELF + def set_score_maximum(self, value: float) -> "LineItem": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#scoremaximum """ if not isinstance(value, (int, float)): - raise LtiException('Invalid scoreMaximum value: score must be integer or float') + raise LtiException( + "Invalid scoreMaximum value: score must be integer or float" + ) if value <= 0: - raise LtiException('Invalid scoreMaximum value: score must be non null value, strictly greater than 0') + raise LtiException( + "Invalid scoreMaximum value: score must be non null value, strictly greater than 0" + ) self._score_maximum = value return self - def get_resource_id(self): - # type: () -> t.Optional[str] + def get_resource_id(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#tool-resource-identifier-resourceid """ return self._resource_id - def set_resource_id(self, value): - # type: (T_SELF, str) -> T_SELF + def set_resource_id(self, value: str) -> "LineItem": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#tool-resource-identifier-resourceid """ self._resource_id = value return self - def get_resource_link_id(self): - # type: () -> t.Optional[str] + def get_resource_link_id(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0#resourcelinkid-and-binding-a-line-item-to-a-resource-link """ return self._resource_link_id - def set_resource_link_id(self, value): - # type: (T_SELF, str) -> T_SELF + def set_resource_link_id(self, value: str) -> "LineItem": """ https://www.imsglobal.org/spec/lti-ags/v2p0#resourcelinkid-and-binding-a-line-item-to-a-resource-link """ self._resource_link_id = value return self - def get_tag(self): - # type: () -> t.Optional[str] + def get_tag(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#tag """ return self._tag - def set_tag(self, value): - # type: (T_SELF, str) -> T_SELF + def set_tag(self, value: str) -> "LineItem": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#tag """ self._tag = value return self - def get_start_date_time(self): - # type: () -> t.Optional[str] + def get_start_date_time(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#startdatetime """ return self._start_date_time - def set_start_date_time(self, value): - # type: (T_SELF, str) -> T_SELF + def set_start_date_time(self, value: str) -> "LineItem": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#startdatetime """ self._start_date_time = value return self - def get_end_date_time(self): - # type: () -> t.Optional[str] + def get_end_date_time(self) -> t.Optional[str]: """ https://www.imsglobal.org/spec/lti-ags/v2p0/#enddatetime """ return self._end_date_time - def set_end_date_time(self, value): - # type: (T_SELF, str) -> T_SELF + def set_end_date_time(self, value: str) -> "LineItem": """ https://www.imsglobal.org/spec/lti-ags/v2p0/#enddatetime """ self._end_date_time = value return self - def get_submission_review(self): - # type: () -> t.Optional[_SubmissionReview] + def get_submission_review(self) -> t.Optional[TSubmissionReview]: return self._submission_review - def set_submission_review(self, reviewable_status, label=None, url=None, custom=None): - # type: (T_SELF, list, t.Optional[str], t.Optional[str], t.Optional[t.Dict[str, str]]) -> T_SELF + def set_submission_review( + self, + reviewable_status: t.List, + label: t.Optional[str] = None, + url: t.Optional[str] = None, + custom: t.Optional[t.Dict[str, str]] = None, + ) -> "LineItem": if not isinstance(reviewable_status, list): raise Exception('Invalid "reviewable_status" argument') - self._submission_review = {'reviewableStatus': reviewable_status} # type: _SubmissionReview + self._submission_review: TSubmissionReview = { + "reviewableStatus": reviewable_status + } if label: - self._submission_review['label'] = label + self._submission_review["label"] = label if url: - self._submission_review['url'] = url + self._submission_review["url"] = url if custom: - self._submission_review['custom'] = custom + self._submission_review["custom"] = custom return self - def get_value(self): - # type: () -> str + def get_value(self) -> str: data = { - 'id': self._id if self._id else None, - 'scoreMaximum': self._score_maximum, - 'label': self._label, - 'resourceId': self._resource_id, - 'resourceLinkId': self._resource_link_id, - 'tag': self._tag, - 'startDateTime': self._start_date_time, - 'endDateTime': self._end_date_time, - 'submissionReview': self._submission_review, + "id": self._id if self._id else None, + "scoreMaximum": self._score_maximum, + "label": self._label, + "resourceId": self._resource_id, + "resourceLinkId": self._resource_link_id, + "tag": self._tag, + "startDateTime": self._start_date_time, + "endDateTime": self._end_date_time, + "submissionReview": self._submission_review, } return json.dumps({k: v for k, v in data.items() if v}) diff --git a/pylti1p3/message_launch.py b/pylti1p3/message_launch.py index fb34dde..4143900 100644 --- a/pylti1p3/message_launch.py +++ b/pylti1p3/message_launch.py @@ -1,217 +1,225 @@ import base64 import hashlib import json -import string # pylint: disable=deprecated-module import typing as t import uuid from abc import ABCMeta, abstractmethod import jwt # type: ignore import requests +import typing_extensions as te from jwcrypto.jwk import JWK # type: ignore from .actions import Action -from .assignments_grades import AssignmentsGradesService -from .course_groups import CourseGroupsService -from .deep_link import DeepLink +from .assignments_grades import AssignmentsGradesService, TAssignmentsGradersData +from .cookie import CookieService +from .course_groups import CourseGroupsService, TGroupsServiceData +from .deep_link import DeepLink, TDeepLinkData from .exception import LtiException -from .launch_data_storage.base import DisableSessionId +from .launch_data_storage.base import DisableSessionId, LaunchDataStorage from .message_validators import get_validators from .message_validators.deep_link import DeepLinkMessageValidator from .message_validators.privacy_launch import PrivacyLaunchValidator from .message_validators.resource_message import ResourceMessageValidator from .message_validators.submission_review import SubmissionReviewLaunchValidator -from .names_roles import NamesRolesProvisioningService -from .roles import StaffRole, StudentRole, TeacherRole, TeachingAssistantRole, DesignerRole, ObserverRole, \ - TransientRole +from .names_roles import NamesRolesProvisioningService, TNamesAndRolesData +from .roles import ( + StaffRole, + StudentRole, + TeacherRole, + TeachingAssistantRole, + DesignerRole, + ObserverRole, + TransientRole, +) +from .registration import Registration, TKeySet +from .request import Request +from .session import SessionService from .service_connector import ServiceConnector, REQUESTS_USER_AGENT -from .utils import encode_on_py3 - -if t.TYPE_CHECKING: - from .launch_data_storage.base import LaunchDataStorage - from .registration import Registration, _KeySet - from .request import Request # pylint: disable=unused-import - from .tool_config import ToolConfAbstract # pylint: disable=cyclic-import - from .session import SessionService # pylint: disable=unused-import - from .cookie import CookieService # pylint: disable=unused-import - from .course_groups import _GroupsServiceData - from .deep_link import _DeepLinkData - from .names_roles import _NamesAndRolesData - from .assignments_grades import _AssignmentsGradersData - - from mypy_extensions import TypedDict - from typing_extensions import Literal - - _ResourceLinkClaim = TypedDict('_ResourceLinkClaim', { - # Required data - 'id': str, +from .tool_config import ToolConfAbstract - # Optional data - 'description': str, - 'title': str, - }, total=False) - _ContextClaim = TypedDict('_ContextClaim', { +TResourceLinkClaim = te.TypedDict( + "TResourceLinkClaim", + { # Required data - 'id': str, - + "id": str, # Optional data - 'label': str, - 'title': str, - 'type': t.List[str], - }, total=False) - - _ToolPlatformClaim = TypedDict('_ToolPlatformClaim', { + "description": str, + "title": str, + }, + total=False, +) + +TContextClaim = te.TypedDict( + "TContextClaim", + { # Required data - 'guid': str, - + "id": str, + # Optional data + "label": str, + "title": str, + "type": t.List[str], + }, + total=False, +) + +TToolPlatformClaim = te.TypedDict( + "TToolPlatformClaim", + { + # Required data + "guid": str, + # Optional data + "contact_email": str, + "description": str, + "name": str, + "url": str, + "product_family_code": str, + "version": str, + }, + total=False, +) + +TLearningInformationServicesClaim = te.TypedDict( + "TLearningInformationServicesClaim", + { + "person_sourcedid": str, + "course_offering_sourcedid": str, + "course_section_sourcedid": str, + }, + total=False, +) + +TMigrationClaim = te.TypedDict( + "TMigrationClaim", + { + # Required data + "oauth_consumer_key": str, + # Optional data + "oauth_consumer_key_sign": str, + "user_id": str, + "context_id": str, + "tool_consumer_instance_guid ": str, + "resource_link_id": str, + }, + total=False, +) + +TForUserClaim = te.TypedDict( + "TForUserClaim", + { + # Required data + "user_id": str, + # Optional data + "person_sourcedId": str, + "given_name": str, + "family_name": str, + "name": str, + "email": str, + "roles": t.List[str], + }, +) + +TLaunchData = te.TypedDict( + "TLaunchData", + { + # Required data + "iss": str, + "nonce": str, + "aud": t.Union[t.List[str], str], + "https://purl.imsglobal.org/spec/lti/claim/message_type": te.Literal[ + "LtiResourceLinkRequest", + "LtiDeepLinkingRequest", + "DataPrivacyLaunchRequest", + "LtiSubmissionReviewRequest", + ], + "https://purl.imsglobal.org/spec/lti/claim/version": te.Literal["1.3.0"], + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": str, + "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": str, + "https://purl.imsglobal.org/spec/lti/claim/resource_link": TResourceLinkClaim, + "https://purl.imsglobal.org/spec/lti/claim/roles": t.List[str], + "sub": str, # Optional data - 'contact_email': str, - 'description': str, - 'name': str, - 'url': str, - 'product_family_code': str, - 'version': str, - }, total=False) - - _LearningInformationServicesClaim = TypedDict('_LearningInformationServicesClaim', { - 'person_sourcedid': str, - 'course_offering_sourcedid': str, - 'course_section_sourcedid': str, - }, total=False) - - _MigrationClaim = TypedDict( - '_MigrationClaim', { - # Required data - 'oauth_consumer_key': str, - - # Optional data - 'oauth_consumer_key_sign': str, - 'user_id': str, - 'context_id': str, - 'tool_consumer_instance_guid ': str, - 'resource_link_id': str, - }, - total=False, - ) - - _ForUserClaim = TypedDict( - '_ForUserClaim', { - # Required data - 'user_id': str, - - # Optional data - 'person_sourcedId': str, - 'given_name': str, - 'family_name': str, - 'name': str, - 'email': str, - 'roles': t.List[str] - } - ) - - _LaunchData = TypedDict( - '_LaunchData', { - # Required data - 'iss': str, - 'nonce': str, - 'aud': t.Union[t.List[str], str], - 'https://purl.imsglobal.org/spec/lti/claim/message_type': - Literal[ - 'LtiResourceLinkRequest', - 'LtiDeepLinkingRequest', - 'DataPrivacyLaunchRequest', - 'LtiSubmissionReviewRequest', - ], - 'https://purl.imsglobal.org/spec/lti/claim/version': Literal['1.3.0'], - 'https://purl.imsglobal.org/spec/lti/claim/deployment_id': str, - 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': str, - 'https://purl.imsglobal.org/spec/lti/claim/resource_link': _ResourceLinkClaim, - 'https://purl.imsglobal.org/spec/lti/claim/roles': t.List[str], - 'sub': str, - - # Optional data - 'given_name': str, - 'family_name': str, - 'name': str, - 'email': str, - 'https://purl.imsglobal.org/spec/lti/claim/context': _ContextClaim, - 'https://purl.imsglobal.org/spec/lti/claim/lis': _LearningInformationServicesClaim, - 'https://purl.imsglobal.org/spec/lti/claim/custom': t.Mapping[str, str], - 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings': _DeepLinkData, - 'https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice': _GroupsServiceData, - 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice': _NamesAndRolesData, - 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': _AssignmentsGradersData, - 'https://purl.imsglobal.org/spec/lti/claim/tool_platform': _ToolPlatformClaim, - 'https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor': t.List[str], - 'https://purl.imsglobal.org/spec/lti/claim/lti1p1': _MigrationClaim, - 'https://purl.imsglobal.org/spec/lti/claim/for_user': _ForUserClaim - }, - total=False - ) - - _JwtHeader = TypedDict( - '_JwtHeader', { - 'kid': str, - 'alg': str, - }, - total=False, - ) - - _JwtData = TypedDict( - '_JwtData', { - 'header': _JwtHeader, - 'body': _LaunchData, - }, - total=False - ) - - -REQ = t.TypeVar('REQ', bound='Request') -TCONF = t.TypeVar('TCONF', bound='ToolConfAbstract') -SES = t.TypeVar('SES', bound='SessionService') -COOK = t.TypeVar('COOK', bound='CookieService') -T_SELF = t.TypeVar('T_SELF', bound='MessageLaunch') + "given_name": str, + "family_name": str, + "name": str, + "email": str, + "https://purl.imsglobal.org/spec/lti/claim/context": TContextClaim, + "https://purl.imsglobal.org/spec/lti/claim/lis": TLearningInformationServicesClaim, + "https://purl.imsglobal.org/spec/lti/claim/custom": t.Mapping[str, str], + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings": TDeepLinkData, + "https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice": TGroupsServiceData, + "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": TNamesAndRolesData, + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": TAssignmentsGradersData, + "https://purl.imsglobal.org/spec/lti/claim/tool_platform": TToolPlatformClaim, + "https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor": t.List[str], + "https://purl.imsglobal.org/spec/lti/claim/lti1p1": TMigrationClaim, + "https://purl.imsglobal.org/spec/lti/claim/for_user": TForUserClaim, + }, + total=False, +) + +TJwtHeader = te.TypedDict( + "TJwtHeader", + { + "kid": str, + "alg": str, + }, + total=False, +) + +TJwtData = te.TypedDict( + "TJwtData", + { + "header": TJwtHeader, + "body": TLaunchData, + }, + total=False, +) + +REQ = t.TypeVar("REQ", bound=Request) +TCONF = t.TypeVar("TCONF", bound=ToolConfAbstract) +SES = t.TypeVar("SES", bound=SessionService) +COOK = t.TypeVar("COOK", bound=CookieService) class MessageLaunch(t.Generic[REQ, TCONF, SES, COOK]): __metaclass__ = ABCMeta - _request = None # type: REQ - _tool_config = None # type: TCONF - _session_service = None # type: SES - _cookie_service = None # type: COOK - _jwt = None # type: _JwtData - _jwt_verify_options = None # type: t.Dict[str, bool] - _registration = None # type: t.Optional[Registration] - _launch_id = None # type: str - _validated = False # type: bool - _auto_validation = True # type: bool - _restored = False # type: bool - _id_token_hash = None # type: t.Optional[str] - _public_key_cache_data_storage = None # type: t.Optional[LaunchDataStorage[t.Any]] - _public_key_cache_lifetime = None # type: t.Optional[int] + _request: REQ + _tool_config: TCONF + _session_service: SES + _cookie_service: COOK + _jwt: TJwtData + _jwt_verify_options: t.Dict[str, bool] + _registration: t.Optional[Registration] + _launch_id: str + _validated: bool = False + _auto_validation: bool = True + _restored: bool = False + _id_token_hash: t.Optional[str] + _public_key_cache_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None + _public_key_cache_lifetime: t.Optional[int] = None def __init__( - self, - request, # type: REQ - tool_config, # type: TCONF - session_service=None, # type: t.Optional[SES] - cookie_service=None, # type: t.Optional[COOK] - launch_data_storage=None, # type: t.Optional[LaunchDataStorage[t.Any]] - requests_session=None # type: t.Optional[requests.Session] + self, + request: REQ, + tool_config: TCONF, + session_service: t.Optional[SES] = None, + cookie_service: t.Optional[COOK] = None, + launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None, + requests_session: t.Optional[requests.Session] = None, ): - # type: (...) -> None self._request = request self._tool_config = tool_config - assert session_service is not None, 'Session Service must be set' - assert cookie_service is not None, 'Cookie Service must be set' + assert session_service is not None, "Session Service must be set" + assert cookie_service is not None, "Cookie Service must be set" self._session_service = session_service self._cookie_service = cookie_service self._launch_id = "lti1p3-launch-" + str(uuid.uuid4()) self._jwt = {} - self._jwt_verify_options = {'verify_aud': False} + self._jwt_verify_options = {"verify_aud": False} self._id_token_hash = None self._validated = False self._auto_validation = True @@ -228,76 +236,74 @@ def __init__( self.set_launch_data_storage(launch_data_storage) @abstractmethod - def _get_request_param(self, key): - # type: (str) -> t.Any + def _get_request_param(self, key: str) -> str: raise NotImplementedError - def set_launch_id(self, launch_id): - # type: (T_SELF, str) -> T_SELF + def set_launch_id(self, launch_id: str) -> "MessageLaunch": self._launch_id = launch_id return self - def set_auto_validation(self, enable): - # type: (T_SELF, bool) -> T_SELF + def set_auto_validation(self, enable: bool) -> "MessageLaunch": self._auto_validation = enable return self - def set_jwt(self, val): - # type: (T_SELF, _JwtData) -> T_SELF + def set_jwt(self, val: TJwtData) -> "MessageLaunch": self._jwt = val return self - def set_jwt_verify_options(self, val): - # type: (T_SELF, t.Dict[str, bool]) -> T_SELF + def set_jwt_verify_options(self, val: t.Dict[str, bool]) -> "MessageLaunch": self._jwt_verify_options = val return self - def set_restored(self): - # type: (T_SELF) -> T_SELF + def set_restored(self) -> "MessageLaunch": self._restored = True return self - def get_session_service(self): - # type: () -> SES + def get_session_service(self) -> SES: return self._session_service - def get_iss(self): - # type: () -> str - iss = self._get_jwt_body().get('iss') + def get_iss(self) -> str: + iss = self._get_jwt_body().get("iss") if not iss: raise LtiException('"iss" is empty') return iss - def get_client_id(self): - # type: () -> str + def get_client_id(self) -> str: jwt_body = self._get_jwt_body() - aud = jwt_body.get('aud') + aud = jwt_body.get("aud") return aud[0] if isinstance(aud, list) else aud # type: ignore @classmethod - def from_cache(cls, - launch_id, # type: str - request, # type: REQ - tool_config, # type: TCONF - session_service=None, # type: SES - cookie_service=None, # type: COOK - launch_data_storage=None, # type: t.Optional[LaunchDataStorage[t.Any]] - requests_session=None # type: t.Optional[requests.Session] - ): - # type: (...) -> MessageLaunch - obj = cls(request, tool_config, session_service=session_service, cookie_service=cookie_service, - launch_data_storage=launch_data_storage, requests_session=requests_session) + def from_cache( + cls, + launch_id: str, + request: REQ, + tool_config: TCONF, + session_service: t.Optional[SES] = None, + cookie_service: t.Optional[COOK] = None, + launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None, + requests_session: t.Optional[requests.Session] = None, + ) -> "MessageLaunch": + obj = cls( + request, + tool_config, + session_service=session_service, + cookie_service=cookie_service, + launch_data_storage=launch_data_storage, + requests_session=requests_session, + ) launch_data = obj.get_session_service().get_launch_data(launch_id) if not launch_data: raise LtiException("Launch data not found") - return obj.set_launch_id(launch_id)\ - .set_auto_validation(enable=False)\ - .set_jwt(t.cast("_JwtData", {'body': launch_data}))\ - .set_restored()\ + return ( + obj.set_launch_id(launch_id) + .set_auto_validation(enable=False) + .set_jwt(t.cast(TJwtData, {"body": launch_data})) + .set_restored() .validate_registration() + ) - def validate(self): - # type: (T_SELF) -> T_SELF + def validate(self) -> "MessageLaunch": """ Validates all aspects of an incoming LTI message launch and caches the launch if successful. """ @@ -305,153 +311,159 @@ def validate(self): raise LtiException("Can't validate restored launch") self._validated = True try: - return self.validate_state()\ - .validate_jwt_format()\ - .validate_nonce()\ - .validate_registration()\ - .validate_jwt_signature()\ - .validate_deployment()\ - .validate_message()\ + return ( + self.validate_state() + .validate_jwt_format() + .validate_nonce() + .validate_registration() + .validate_jwt_signature() + .validate_deployment() + .validate_message() .save_launch_data() + ) except Exception: self._validated = False raise - def _get_jwt_body(self): - # type: () -> _LaunchData + def _get_jwt_body(self) -> TLaunchData: if not self._validated and self._auto_validation: self.validate() - return self._jwt.get('body', {}) + return self._jwt.get("body", {}) - def _get_iss(self): - # type: () -> str - iss = self._get_jwt_body().get('iss') + def _get_iss(self) -> str: + iss = self._get_jwt_body().get("iss") if not iss: raise LtiException('"iss" is empty') return iss - def _get_id_token(self): - # type: () -> str - id_token = self._get_request_param('id_token') + def _get_id_token(self) -> str: + id_token = self._get_request_param("id_token") if not id_token: raise LtiException("Missing id_token") return id_token - def _get_id_token_hash(self): - # type: () -> str + def _get_id_token_hash(self) -> str: if not self._id_token_hash: id_token = self._get_id_token() - id_token_param = encode_on_py3(id_token, 'utf8') + id_token_param = id_token.encode("utf8") self._id_token_hash = hashlib.md5(id_token_param).hexdigest() return self._id_token_hash - def _get_deployment_id(self): - # type: () -> str - deployment_id = self._get_jwt_body().get('https://purl.imsglobal.org/spec/lti/claim/deployment_id') + def _get_deployment_id(self) -> str: + deployment_id = self._get_jwt_body().get( + "https://purl.imsglobal.org/spec/lti/claim/deployment_id" + ) if not deployment_id: raise LtiException("deployment_id is not set in jwt body") return deployment_id - def get_service_connector(self): - # type: () -> ServiceConnector - assert self._registration is not None, 'Registration not yet set' + def get_service_connector(self) -> ServiceConnector: + assert self._registration is not None, "Registration not yet set" return ServiceConnector(self._registration, self._requests_session) - def has_nrps(self): - # type: () -> bool + def has_nrps(self) -> bool: """ Returns whether or not the current launch can use the names and roles service. :return: bool Returns a boolean indicating the availability of names and roles. """ - return self._get_jwt_body().get('https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice', {})\ - .get('context_memberships_url', None) is not None + return ( + self._get_jwt_body() + .get("https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice", {}) + .get("context_memberships_url", None) + is not None + ) - def get_nrps(self): - # type: () -> NamesRolesProvisioningService + def get_nrps(self) -> NamesRolesProvisioningService: """ Fetches an instance of the names and roles service for the current launch. :return: NamesRolesProvisioningService """ - assert self._registration is not None, 'Registration not yet set' + assert self._registration is not None, "Registration not yet set" connector = self.get_service_connector() - names_role_service = self._get_jwt_body()\ - .get('https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice') + names_role_service = self._get_jwt_body().get( + "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice" + ) if not names_role_service: - raise LtiException('namesroleservice is not set in jwt body') + raise LtiException("namesroleservice is not set in jwt body") return NamesRolesProvisioningService(connector, names_role_service) - def has_ags(self): - # type: () -> bool + def has_ags(self) -> bool: """ Returns whether or not the current launch can use the assignments and grades service. :return: bool Returns a boolean indicating the availability of assignments and grades. """ - return self._get_jwt_body().get('https://purl.imsglobal.org/spec/lti-ags/claim/endpoint', None) is not None + return ( + self._get_jwt_body().get( + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint", None + ) + is not None + ) - def get_ags(self): - # type: () -> AssignmentsGradesService + def get_ags(self) -> AssignmentsGradesService: """ Fetches an instance of the assignments and grades service for the current launch. :return: AssignmentsGradesService """ - assert self._registration is not None, 'Registration not yet set' + assert self._registration is not None, "Registration not yet set" connector = self.get_service_connector() - endpoint = self._get_jwt_body() \ - .get('https://purl.imsglobal.org/spec/lti-ags/claim/endpoint') + endpoint = self._get_jwt_body().get( + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" + ) if not endpoint: - raise LtiException('endpoint is not set in jwt body') + raise LtiException("endpoint is not set in jwt body") return AssignmentsGradesService(connector, endpoint) - def has_cgs(self): - # type: () -> bool + def has_cgs(self) -> bool: """ Returns whether or not the current launch can use the course groups service. :return: bool Returns a boolean indicating the availability of groups. """ - groups_service_data = self._get_jwt_body().get('https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice', {}) - return groups_service_data.get('context_groups_url', None) is not None + groups_service_data = self._get_jwt_body().get( + "https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice", {} + ) + return groups_service_data.get("context_groups_url", None) is not None - def get_cgs(self): + def get_cgs(self) -> CourseGroupsService: """ Fetches an instance of the course groups service for the current launch. :return: """ - assert self._registration is not None, 'Registration not yet set' + assert self._registration is not None, "Registration not yet set" connector = self.get_service_connector() - groups_service_data = self._get_jwt_body() \ - .get('https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice') + groups_service_data = self._get_jwt_body().get( + "https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice" + ) if not groups_service_data: - raise LtiException('groupsservice is not set in jwt body') - context_groups_url = groups_service_data.get('context_groups_url', None) + raise LtiException("groupsservice is not set in jwt body") + context_groups_url = groups_service_data.get("context_groups_url", None) if not context_groups_url: - raise LtiException('context_groups_url is not set in groupsservice section') + raise LtiException("context_groups_url is not set in groupsservice section") return CourseGroupsService(connector, groups_service_data) - def get_deep_link(self): - # type: () -> DeepLink + def get_deep_link(self) -> DeepLink: """ Fetches a deep link that can be used to construct a deep linking response. :return: DeepLink """ - assert self._registration is not None, 'Registration not yet set' + assert self._registration is not None, "Registration not yet set" deployment_id = self._get_deployment_id() - deep_linking_settings = self._get_jwt_body() \ - .get('https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings') + deep_linking_settings = self._get_jwt_body().get( + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ) if not deep_linking_settings: - raise LtiException('deep_linking_settings is not set in jwt body') + raise LtiException("deep_linking_settings is not set in jwt body") return DeepLink(self._registration, deployment_id, deep_linking_settings) - def get_data_privacy_launch_user(self): - # type: () -> t.Optional[_ForUserClaim] + def get_data_privacy_launch_user(self) -> t.Optional[TForUserClaim]: """ Applicable for DataPrivacyLaunchRequest only. Returns information about user who's data the launch is intended to action upon, for instance the student @@ -460,10 +472,9 @@ def get_data_privacy_launch_user(self): :return: dict """ jwt_body = self._get_jwt_body() - return jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/for_user') + return jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/for_user") - def get_submission_review_user(self): - # type: () -> t.Optional[_ForUserClaim] + def get_submission_review_user(self) -> t.Optional[TForUserClaim]: """ Applicable for LtiSubmissionReviewRequest only. Returns information about user who's submission should be displayed for review. @@ -471,10 +482,9 @@ def get_submission_review_user(self): :return: dict """ jwt_body = self._get_jwt_body() - return jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/for_user') + return jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/for_user") - def is_deep_link_launch(self): - # type: () -> bool + def is_deep_link_launch(self) -> bool: """ Returns whether or not the current launch is a deep linking launch. @@ -483,8 +493,7 @@ def is_deep_link_launch(self): jwt_body = self._get_jwt_body() return DeepLinkMessageValidator().can_validate(jwt_body) - def is_resource_launch(self): - # type: () -> bool + def is_resource_launch(self) -> bool: """ Returns whether or not the current launch is a resource launch. @@ -493,8 +502,7 @@ def is_resource_launch(self): jwt_body = self._get_jwt_body() return ResourceMessageValidator().can_validate(jwt_body) - def is_data_privacy_launch(self): - # type: () -> bool + def is_data_privacy_launch(self) -> bool: """ Returns whether or not the current launch is a data privacy launch. @@ -503,8 +511,7 @@ def is_data_privacy_launch(self): jwt_body = self._get_jwt_body() return PrivacyLaunchValidator().can_validate(jwt_body) - def is_submission_review_launch(self): - # type: () -> bool + def is_submission_review_launch(self) -> bool: """ Returns whether or not the current launch is a submission review launch. @@ -513,8 +520,7 @@ def is_submission_review_launch(self): jwt_body = self._get_jwt_body() return SubmissionReviewLaunchValidator().can_validate(jwt_body) - def get_launch_data(self): - # type: () -> _LaunchData + def get_launch_data(self) -> TLaunchData: """ Fetches the decoded body of the JWT used in the current launch. @@ -522,8 +528,7 @@ def get_launch_data(self): """ return self._get_jwt_body() - def get_launch_id(self): - # type: () -> str + def get_launch_id(self) -> str: """ Get the unique launch id for the current launch. @@ -531,31 +536,28 @@ def get_launch_id(self): """ return self._launch_id - def get_tool_conf(self): - # type: () -> TCONF + def get_tool_conf(self) -> TCONF: return self._tool_config - def urlsafe_b64decode(self, val): - # type: (str) -> str + @staticmethod + def urlsafe_b64decode(val: str) -> str: remainder = len(val) % 4 if remainder > 0: padlen = 4 - remainder - val = val + ('=' * padlen) - if hasattr(str, 'maketrans'): - tmp = val.translate(str.maketrans('-_', '+/')) # type: ignore - return base64.b64decode(tmp).decode("utf-8") # type: ignore - else: - tmp = str(val).translate(string.maketrans('-_', '+/')) # type: ignore - return base64.b64decode(tmp) # type: ignore + val = val + ("=" * padlen) + tmp = val.translate(str.maketrans("-_", "+/")) # type: ignore + return base64.b64decode(tmp).decode("utf-8") # type: ignore - def set_public_key_caching(self, data_storage, cache_lifetime=7200): - # type: (LaunchDataStorage[t.Any], int) -> None + def set_public_key_caching( + self, data_storage: LaunchDataStorage[t.Any], cache_lifetime: int = 7200 + ): self._public_key_cache_data_storage = data_storage self._public_key_cache_lifetime = cache_lifetime - def fetch_public_key(self, key_set_url): - # type: (str) -> _KeySet - cache_key = 'key-set-url-' + hashlib.md5(encode_on_py3(key_set_url, 'utf-8')).hexdigest() + def fetch_public_key(self, key_set_url: str) -> TKeySet: + cache_key = ( + "key-set-url-" + hashlib.md5(key_set_url.encode("utf-8")).hexdigest() + ) with DisableSessionId(self._public_key_cache_data_storage): if self._public_key_cache_data_storage: @@ -566,63 +568,70 @@ def fetch_public_key(self, key_set_url): try: resp = self._requests_session.get(key_set_url) except requests.exceptions.RequestException as e: - raise LtiException("Error during fetch URL " + key_set_url + ": " + str(e)) + raise LtiException( + f"Error during fetch URL {key_set_url}: {str(e)}" + ) from e try: public_key = resp.json() if self._public_key_cache_data_storage: self._public_key_cache_data_storage.set_value( - cache_key, public_key, self._public_key_cache_lifetime) + cache_key, public_key, self._public_key_cache_lifetime + ) return public_key - except ValueError: - raise LtiException("Invalid response from " + key_set_url + ". Must be JSON: " + resp.text) + except ValueError as e: + raise LtiException( + f"Invalid response from {key_set_url}. Must be JSON: {resp.text}" + ) from e - def get_public_key(self): - # type: () -> t.Tuple[str, str] - assert self._registration is not None, 'Registration not yet set' + def get_public_key(self) -> t.Tuple[str, str]: + assert self._registration is not None, "Registration not yet set" public_key_set = self._registration.get_key_set() key_set_url = self._registration.get_key_set_url() if not public_key_set: - assert key_set_url is not None, 'If public_key_set is not set, public_set_url should be set' - if key_set_url.startswith(('http://', 'https://')): + assert ( + key_set_url is not None + ), "If public_key_set is not set, public_set_url should be set" + if key_set_url.startswith(("http://", "https://")): public_key_set = self.fetch_public_key(key_set_url) self._registration.set_key_set(public_key_set) else: raise LtiException("Invalid URL: " + key_set_url) # Find key used to sign the JWT (matches the KID in the header) - kid = self._jwt.get('header', {}).get('kid', None) - alg = self._jwt.get('header', {}).get('alg', None) + kid = self._jwt.get("header", {}).get("kid", None) + alg = self._jwt.get("header", {}).get("alg", None) if not kid: raise LtiException("JWT KID not found") if not alg: raise LtiException("JWT ALG not found") - for key in public_key_set['keys']: - key_kid = key.get('kid') - key_alg = key.get('alg', 'RS256') + for key in public_key_set["keys"]: + key_kid = key.get("kid") + key_alg = key.get("alg", "RS256") if key_kid and key_kid == kid and key_alg == alg: try: key_json = json.dumps(key) jwk_obj = JWK.from_json(key_json) public_key = jwk_obj.export_to_pem() return public_key, key_alg - except (ValueError, TypeError): - raise LtiException("Can't convert JWT key to PEM format") + except (ValueError, TypeError) as e: + raise LtiException("Can't convert JWT key to PEM format") from e # Could not find public key with a matching kid and alg. raise LtiException("Unable to find public key") - def validate_state(self): - # type: (T_SELF) -> T_SELF + def validate_state(self) -> "MessageLaunch": # Check State for OIDC. - state_from_request = self._get_request_param('state') + state_from_request = self._get_request_param("state") if not state_from_request: raise LtiException("Missing state param") id_token_hash = self._get_id_token_hash() - if not self._session_service.check_state_is_valid(state_from_request, id_token_hash): + if not self._session_service.check_state_is_valid( + state_from_request, id_token_hash + ): state_from_cookie = self._cookie_service.get_cookie(state_from_request) if state_from_request != state_from_cookie: # Error if state doesn't match. @@ -630,10 +639,9 @@ def validate_state(self): return self - def validate_jwt_format(self): - # type: (T_SELF) -> T_SELF + def validate_jwt_format(self) -> "MessageLaunch": id_token = self._get_id_token() - jwt_parts = id_token.split('.') + jwt_parts = id_token.split(".") if len(jwt_parts) != 3: # Invalid number of parts in JWT. @@ -642,19 +650,18 @@ def validate_jwt_format(self): try: # Decode JWT headers. header = self.urlsafe_b64decode(jwt_parts[0]) - self._jwt['header'] = json.loads(header) + self._jwt["header"] = json.loads(header) # Decode JWT body. body = self.urlsafe_b64decode(jwt_parts[1]) - self._jwt['body'] = json.loads(body) - except Exception: - raise LtiException("Invalid JWT format, can't be decoded") + self._jwt["body"] = json.loads(body) + except Exception as e: + raise LtiException("Invalid JWT format, can't be decoded") from e return self - def validate_nonce(self): - # type: (T_SELF) -> T_SELF - nonce = self._get_jwt_body().get('nonce') + def validate_nonce(self) -> "MessageLaunch": + nonce = self._get_jwt_body().get("nonce") if not nonce: raise LtiException('"nonce" is empty') @@ -664,8 +671,7 @@ def validate_nonce(self): return self - def validate_registration(self): - # type: (T_SELF) -> T_SELF + def validate_registration(self) -> "MessageLaunch": iss = self.get_iss() jwt_body = self._get_jwt_body() client_id = self.get_client_id() @@ -673,19 +679,25 @@ def validate_registration(self): # Mypy doesn't support higher kinded types yet so it thinks that all # generic attrs have type `Any`. See issue: # https://github.com/python/mypy/issues/8228 - config = self._tool_config # type: ToolConfAbstract[REQ] - req = self._request # type: REQ + config: ToolConfAbstract[REQ] = self._tool_config + req: REQ = self._request # Find registration if config.check_iss_has_one_client(iss): self._registration = config.find_registration( - iss, action=Action.MESSAGE_LAUNCH, request=req, jwt_body=jwt_body) + iss, action=Action.MESSAGE_LAUNCH, request=req, jwt_body=jwt_body + ) else: self._registration = config.find_registration_by_params( - iss, client_id, action=Action.MESSAGE_LAUNCH, request=req, jwt_body=jwt_body) + iss, + client_id, + action=Action.MESSAGE_LAUNCH, + request=req, + jwt_body=jwt_body, + ) if not self._registration: - raise LtiException('Registration not found.') + raise LtiException("Registration not found.") # Check client id if client_id != self._registration.get_client_id(): @@ -693,41 +705,47 @@ def validate_registration(self): return self - def validate_jwt_signature(self): - # type: (T_SELF) -> T_SELF + def validate_jwt_signature(self) -> "MessageLaunch": id_token = self._get_id_token() # Fetch public key object public_key, key_alg = self.get_public_key() try: - jwt.decode(id_token, public_key, algorithms=[key_alg], options=self._jwt_verify_options) + jwt.decode( + id_token, + public_key, + algorithms=[key_alg], + options=self._jwt_verify_options, + ) except jwt.InvalidTokenError as e: - raise LtiException("Can't decode id_token: " + str(e)) + raise LtiException(f"Can't decode id_token: {str(e)}") from e return self - def validate_deployment(self): - # type: (T_SELF) -> T_SELF + def validate_deployment(self) -> "MessageLaunch": iss = self.get_iss() client_id = self.get_client_id() deployment_id = self._get_deployment_id() - tool_config = self._tool_config # type: ToolConfAbstract + tool_config: ToolConfAbstract = self._tool_config # Find deployment. if tool_config.check_iss_has_one_client(iss): deployment = tool_config.find_deployment(iss, deployment_id) else: - deployment = tool_config.find_deployment_by_params(iss, deployment_id, client_id) + deployment = tool_config.find_deployment_by_params( + iss, deployment_id, client_id + ) if not deployment: raise LtiException("Unable to find deployment") return self - def validate_message(self): - # type: (T_SELF) -> T_SELF + def validate_message(self) -> "MessageLaunch": jwt_body = self._get_jwt_body() - message_type = jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/message_type', None) + message_type = jwt_body.get( + "https://purl.imsglobal.org/spec/lti/claim/message_type", None + ) if not message_type: raise LtiException("Invalid message type") @@ -747,7 +765,9 @@ def validate_message(self): return self - def set_launch_data_storage(self, data_storage): + def set_launch_data_storage( + self, data_storage: LaunchDataStorage[t.Any] + ) -> "MessageLaunch": data_storage.set_request(self._request) session_cookie_name = data_storage.get_session_cookie_name() if session_cookie_name: @@ -755,65 +775,54 @@ def set_launch_data_storage(self, data_storage): if session_id: data_storage.set_session_id(session_id) else: - raise LtiException("Missing %s cookie" % session_cookie_name) + raise LtiException(f"Missing %s cookie {session_cookie_name}") self._session_service.set_data_storage(data_storage) return self - def set_launch_data_lifetime(self, time_sec): - # type: (T_SELF, int) -> T_SELF + def set_launch_data_lifetime(self, time_sec: int) -> "MessageLaunch": self._session_service.set_launch_data_lifetime(time_sec) return self - def save_launch_data(self): - # type: (T_SELF) -> T_SELF - state_from_request = self._get_request_param('state') + def save_launch_data(self) -> "MessageLaunch": + state_from_request = self._get_request_param("state") id_token_hash = self._get_id_token_hash() - self._session_service.save_launch_data(self._launch_id, self._jwt['body']) + self._session_service.save_launch_data(self._launch_id, self._jwt["body"]) self._session_service.set_state_valid(state_from_request, id_token_hash) return self def get_params_from_login(self): - # type: () -> object - state = self._get_request_param('state') + state = self._get_request_param("state") return self._session_service.get_state_params(state) - def check_jwt_body_is_empty(self): - # type: () -> bool + def check_jwt_body_is_empty(self) -> bool: jwt_body = self._get_jwt_body() return not jwt_body - def check_staff_access(self): - # type: () -> bool + def check_staff_access(self) -> bool: jwt_body = self._get_jwt_body() return StaffRole(jwt_body).check() - def check_student_access(self): - # type: () -> bool + def check_student_access(self) -> bool: jwt_body = self._get_jwt_body() return StudentRole(jwt_body).check() - def check_teacher_access(self): - # type: () -> bool + def check_teacher_access(self) -> bool: jwt_body = self._get_jwt_body() return TeacherRole(jwt_body).check() - def check_teaching_assistant_access(self): - # type: () -> bool + def check_teaching_assistant_access(self) -> bool: jwt_body = self._get_jwt_body() return TeachingAssistantRole(jwt_body).check() - def check_designer_access(self): - # type: () -> bool + def check_designer_access(self) -> bool: jwt_body = self._get_jwt_body() return DesignerRole(jwt_body).check() - def check_observer_access(self): - # type: () -> bool + def check_observer_access(self) -> bool: jwt_body = self._get_jwt_body() return ObserverRole(jwt_body).check() - def check_transient(self): - # type: () -> bool + def check_transient(self) -> bool: jwt_body = self._get_jwt_body() return TransientRole(jwt_body).check() diff --git a/pylti1p3/message_validators/abstract.py b/pylti1p3/message_validators/abstract.py index a9babe2..33b1ef7 100644 --- a/pylti1p3/message_validators/abstract.py +++ b/pylti1p3/message_validators/abstract.py @@ -2,24 +2,24 @@ from ..exception import LtiException -class MessageValidatorAbstract(object): +class MessageValidatorAbstract: __metaclass__ = ABCMeta @abstractmethod - def validate(self, jwt_body): + def validate(self, jwt_body) -> bool: raise NotImplementedError @abstractmethod - def can_validate(self, jwt_body): + def can_validate(self, jwt_body) -> bool: raise NotImplementedError - def run_common_validators(self, jwt_body): - if not jwt_body.get('sub'): - raise LtiException('Must have a user (sub)') + def run_common_validators(self, jwt_body) -> None: + if not jwt_body.get("sub"): + raise LtiException("Must have a user (sub)") - if jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/version') != '1.3.0': - raise LtiException('Incorrect version, expected 1.3.0') + if jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/version") != "1.3.0": + raise LtiException("Incorrect version, expected 1.3.0") - roles = jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/roles') + roles = jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/roles") if roles is None: - raise LtiException('Missing Roles Claim') + raise LtiException("Missing Roles Claim") diff --git a/pylti1p3/message_validators/deep_link.py b/pylti1p3/message_validators/deep_link.py index dbcc838..43335d5 100644 --- a/pylti1p3/message_validators/deep_link.py +++ b/pylti1p3/message_validators/deep_link.py @@ -3,26 +3,32 @@ class DeepLinkMessageValidator(MessageValidatorAbstract): - - def validate(self, jwt_body): + def validate(self, jwt_body) -> bool: self.run_common_validators(jwt_body) - if not jwt_body.get('https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'): - raise LtiException('Missing Deep Linking Settings') + if not jwt_body.get( + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ): + raise LtiException("Missing Deep Linking Settings") - deep_link_settings = jwt_body.get('https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings') + deep_link_settings = jwt_body.get( + "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings" + ) if not deep_link_settings: - raise LtiException('Missing Deep Linking Return URL') + raise LtiException("Missing Deep Linking Return URL") - accept_types = deep_link_settings.get('accept_types') + accept_types = deep_link_settings.get("accept_types") - if not isinstance(accept_types, list) or 'ltiResourceLink' not in accept_types: - raise LtiException('Must support resource link placement types') + if not isinstance(accept_types, list) or "ltiResourceLink" not in accept_types: + raise LtiException("Must support resource link placement types") - if not deep_link_settings.get('accept_presentation_document_targets'): - raise LtiException('Must support a presentation type') + if not deep_link_settings.get("accept_presentation_document_targets"): + raise LtiException("Must support a presentation type") return True - def can_validate(self, jwt_body): - return jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/message_type') == 'LtiDeepLinkingRequest' + def can_validate(self, jwt_body) -> bool: + return ( + jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type") + == "LtiDeepLinkingRequest" + ) diff --git a/pylti1p3/message_validators/privacy_launch.py b/pylti1p3/message_validators/privacy_launch.py index 504d787..b51e8cc 100644 --- a/pylti1p3/message_validators/privacy_launch.py +++ b/pylti1p3/message_validators/privacy_launch.py @@ -10,20 +10,31 @@ class PrivacyLaunchValidator(MessageValidatorAbstract): was made on behalf of. """ - def validate(self, jwt_body): + def validate(self, jwt_body) -> bool: self.run_common_validators(jwt_body) - if 'https://purl.imsglobal.org/spec/lti/claim/resource_link' in jwt_body: - raise LtiException('Resource link claim must be omitted from a DataPrivacyLaunchRequest') + if "https://purl.imsglobal.org/spec/lti/claim/resource_link" in jwt_body: + raise LtiException( + "Resource link claim must be omitted from a DataPrivacyLaunchRequest" + ) - if 'https://purl.imsglobal.org/spec/lti/claim/context' in jwt_body: - raise LtiException('Context claim must be omitted from a DataPrivacyLaunchRequest') + if "https://purl.imsglobal.org/spec/lti/claim/context" in jwt_body: + raise LtiException( + "Context claim must be omitted from a DataPrivacyLaunchRequest" + ) - for_user_claim = jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/for_user') + for_user_claim = jwt_body.get( + "https://purl.imsglobal.org/spec/lti/claim/for_user" + ) if for_user_claim is None: - raise LtiException('For user claim must be included in a DataPrivacyLaunchRequest') + raise LtiException( + "For user claim must be included in a DataPrivacyLaunchRequest" + ) return True - def can_validate(self, jwt_body): - return jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/message_type') == 'DataPrivacyLaunchRequest' + def can_validate(self, jwt_body) -> bool: + return ( + jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type") + == "DataPrivacyLaunchRequest" + ) diff --git a/pylti1p3/message_validators/resource_message.py b/pylti1p3/message_validators/resource_message.py index dc0c76c..5b9f99f 100644 --- a/pylti1p3/message_validators/resource_message.py +++ b/pylti1p3/message_validators/resource_message.py @@ -3,15 +3,19 @@ class ResourceMessageValidator(MessageValidatorAbstract): - - def validate(self, jwt_body): + def validate(self, jwt_body) -> bool: self.run_common_validators(jwt_body) - id_val = jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}).get('id') + id_val = jwt_body.get( + "https://purl.imsglobal.org/spec/lti/claim/resource_link", {} + ).get("id") if not id_val: - raise LtiException('Missing Resource Link Id') + raise LtiException("Missing Resource Link Id") return True - def can_validate(self, jwt_body): - return jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/message_type') == 'LtiResourceLinkRequest' + def can_validate(self, jwt_body) -> bool: + return ( + jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type") + == "LtiResourceLinkRequest" + ) diff --git a/pylti1p3/message_validators/submission_review.py b/pylti1p3/message_validators/submission_review.py index eed6fc8..c4dc7bb 100644 --- a/pylti1p3/message_validators/submission_review.py +++ b/pylti1p3/message_validators/submission_review.py @@ -10,23 +10,36 @@ class SubmissionReviewLaunchValidator(MessageValidatorAbstract): for the reviewed submission. """ - def validate(self, jwt_body): + def validate(self, jwt_body) -> bool: self.run_common_validators(jwt_body) - if 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint' not in jwt_body: - raise LtiException('Grade services must be included in a LtiSubmissionReviewRequest') - - ags_endpoint_claim = jwt_body['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'] - if 'lineitem' not in ags_endpoint_claim: - raise LtiException('A LtiSubmissionReviewRequest must specify the lineitem it was launched for') - - for_user_claim = jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/for_user') + if "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" not in jwt_body: + raise LtiException( + "Grade services must be included in a LtiSubmissionReviewRequest" + ) + + ags_endpoint_claim = jwt_body[ + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" + ] + if "lineitem" not in ags_endpoint_claim: + raise LtiException( + "A LtiSubmissionReviewRequest must specify the lineitem it was launched for" + ) + + for_user_claim = jwt_body.get( + "https://purl.imsglobal.org/spec/lti/claim/for_user" + ) if for_user_claim is None: - raise LtiException('For user claim must be included in a LtiSubmissionReviewRequest') - if 'user_id' not in for_user_claim: - raise LtiException('For user claim must include user_id') + raise LtiException( + "For user claim must be included in a LtiSubmissionReviewRequest" + ) + if "user_id" not in for_user_claim: + raise LtiException("For user claim must include user_id") return True - def can_validate(self, jwt_body): - return jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/message_type') == 'LtiSubmissionReviewRequest' + def can_validate(self, jwt_body) -> bool: + return ( + jwt_body.get("https://purl.imsglobal.org/spec/lti/claim/message_type") + == "LtiSubmissionReviewRequest" + ) diff --git a/pylti1p3/names_roles.py b/pylti1p3/names_roles.py index 6452c3d..136cc86 100644 --- a/pylti1p3/names_roles.py +++ b/pylti1p3/names_roles.py @@ -1,52 +1,62 @@ import typing as t +import typing_extensions as te from .utils import add_param_to_url - -if t.TYPE_CHECKING: - from mypy_extensions import TypedDict - from .service_connector import ServiceConnector - from typing_extensions import Literal - - _NamesAndRolesData = TypedDict('_NamesAndRolesData', { - 'context_memberships_url': str, - }, total=False) - _Member = TypedDict('_Member', { - 'name': str, - 'status': Literal['Active', 'Inactive', 'Deleted'], - 'picture': str, - 'given_name': str, - 'family_name': str, - 'middle_name': str, - 'email': str, - 'user_id': str, - 'lis_person_sourcedid': str, - 'roles': t.List[str], - 'message': t.Union[t.List[t.Dict[str, object]], t.Dict[str, object]], - 'lti11_legacy_user_id': t.Optional[str], - }, total=False) - - -class NamesRolesProvisioningService(object): - _service_connector = None # type: ServiceConnector - _service_data = None # type: _NamesAndRolesData - - def __init__(self, service_connector, service_data): - # type: (ServiceConnector, _NamesAndRolesData) -> None +from .service_connector import ServiceConnector + +TNamesAndRolesData = te.TypedDict( + "TNamesAndRolesData", + { + "context_memberships_url": str, + }, + total=False, +) + +TMember = te.TypedDict( + "TMember", + { + "name": str, + "status": te.Literal["Active", "Inactive", "Deleted"], + "picture": str, + "given_name": str, + "family_name": str, + "middle_name": str, + "email": str, + "user_id": str, + "lis_person_sourcedid": str, + "roles": t.List[str], + "message": t.Union[t.List[t.Dict[str, object]], t.Dict[str, object]], + "lti11_legacy_user_id": t.Optional[str], + }, + total=False, +) + + +class NamesRolesProvisioningService: + _service_connector: ServiceConnector + _service_data: TNamesAndRolesData + + def __init__( + self, service_connector: ServiceConnector, service_data: TNamesAndRolesData + ): self._service_connector = service_connector self._service_data = service_data - def get_nrps_data(self, members_url=None): + def get_nrps_data(self, members_url: t.Optional[str] = None): if not members_url: - members_url = self._service_data['context_memberships_url'] + members_url = self._service_data["context_memberships_url"] data = self._service_connector.make_service_request( - ['https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'], + [ + "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly" + ], members_url, - accept='application/vnd.ims.lti-nrps.v2.membershipcontainer+json', + accept="application/vnd.ims.lti-nrps.v2.membershipcontainer+json", ) return data - def get_members_page(self, members_url=None): - # type: (t.Optional[str]) -> t.Tuple[list, t.Optional[str]] + def get_members_page( + self, members_url: t.Optional[str] = None + ) -> t.Tuple[t.List[TMember], t.Optional[str]]: """ Get one page with the users. @@ -54,22 +64,21 @@ def get_members_page(self, members_url=None): :return: tuple in format: (list with users, next page url) """ data = self.get_nrps_data(members_url=members_url) - data_body = t.cast(t.Any, data.get('body', {})) - return data_body.get('members', []), data['next_page_url'] + data_body = t.cast(t.Any, data.get("body", {})) + return data_body.get("members", []), data["next_page_url"] - def get_members(self, resource_link_id=None): - # type: (t.Optional[str]) -> t.List[_Member] + def get_members(self, resource_link_id: t.Optional[str] = None) -> t.List[TMember]: """ Get list with all users. :param resource_link_id: resource link id (optional) :return: list """ - members_res_lst = [] # type: t.List[_Member] - members_url = self._service_data['context_memberships_url'] # type: t.Optional[str] + members_res_lst: t.List[TMember] = [] + members_url: t.Optional[str] = self._service_data["context_memberships_url"] - if resource_link_id: - members_url = add_param_to_url(members_url, 'rlid', resource_link_id) + if members_url and resource_link_id: + members_url = add_param_to_url(members_url, "rlid", resource_link_id) while members_url: members, members_url = self.get_members_page(members_url) @@ -84,5 +93,5 @@ def get_context(self): :return: dict """ data = self.get_nrps_data() - data_body = t.cast(t.Any, data.get('body', {})) - return data_body.get('context', {}) + data_body = t.cast(t.Any, data.get("body", {})) + return data_body.get("context", {}) diff --git a/pylti1p3/oidc_login.py b/pylti1p3/oidc_login.py index bef8f89..6d23e01 100644 --- a/pylti1p3/oidc_login.py +++ b/pylti1p3/oidc_login.py @@ -1,55 +1,54 @@ import typing as t import uuid from abc import ABCMeta, abstractmethod +from urllib.parse import urlencode from .actions import Action +from .cookie import CookieService from .cookies_allowed_check import CookiesAllowedCheckPage from .exception import OIDCException +from .launch_data_storage.base import LaunchDataStorage +from .session import SessionService +from .registration import Registration +from .redirect import Redirect +from .request import Request +from .tool_config import ToolConfAbstract -if t.TYPE_CHECKING: - from .cookie import CookieService # pylint: disable=unused-import - from .request import Request # pylint: disable=unused-import - from .session import SessionService # pylint: disable=unused-import - from .redirect import Redirect - from .tool_config import ToolConfAbstract # pylint: disable=unused-import - from .registration import Registration - from .launch_data_storage.base import LaunchDataStorage - - def urlencode(url): - # type: (t.Dict[str, str]) -> str - return str(url) -else: - try: - from urllib import urlencode - except ImportError: - from urllib.parse import urlencode - -RED = t.TypeVar('RED') -REQ = t.TypeVar('REQ', bound='Request') -TCONF = t.TypeVar('TCONF', bound='ToolConfAbstract') -SES = t.TypeVar('SES', bound='SessionService') -COOK = t.TypeVar('COOK', bound='CookieService') - -T_SELF = t.TypeVar('T_SELF', bound='OIDCLogin') + +RED = t.TypeVar("RED") +REQ = t.TypeVar("REQ", bound=Request) +TCONF = t.TypeVar("TCONF", bound=ToolConfAbstract) +SES = t.TypeVar("SES", bound=SessionService) +COOK = t.TypeVar("COOK", bound=CookieService) class OIDCLogin(t.Generic[REQ, TCONF, SES, COOK, RED]): __metaclass__ = ABCMeta - _request = None # type: REQ - _tool_config = None # type: TCONF - _session_service = None # type: SES - _cookie_service = None # type: COOK - _launch_data_storage = None # type: t.Optional[LaunchDataStorage[t.Any]] - _registration = None # type: Registration - - _cookies_check = False # type: bool - _cookies_check_loading_text = 'Loading...' # type: str - _cookies_unavailable_msg_main_text = 'Your browser prohibits to save cookies in the iframes.' # type: str - _cookies_unavailable_msg_click_text = 'Click here to open content in the new tab.' # type: str - _state_params = {} # type: t.Dict[str, object] - - def __init__(self, request, tool_config, session_service, cookie_service, launch_data_storage=None): - # type: (REQ, TCONF, SES, COOK, t.Optional[LaunchDataStorage[t.Any]]) -> None + _request: REQ + _tool_config: TCONF + _session_service: SES + _cookie_service: COOK + _launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None + _registration: Registration + + _cookies_check: bool = False + _cookies_check_loading_text: str = "Loading..." + _cookies_unavailable_msg_main_text: str = ( + "Your browser prohibits to save cookies in the iframes." + ) + _cookies_unavailable_msg_click_text: str = ( + "Click here to open content in the new tab." + ) + _state_params: t.Dict[str, object] = {} + + def __init__( + self, + request: REQ, + tool_config: TCONF, + session_service: SES, + cookie_service: COOK, + launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None, + ): self._request = request self._tool_config = tool_config self._session_service = session_service @@ -57,44 +56,36 @@ def __init__(self, request, tool_config, session_service, cookie_service, launch self._launch_data_storage = launch_data_storage @abstractmethod - def get_redirect(self, url): - # type: (str) -> Redirect[RED] + def get_redirect(self, url: str) -> Redirect[RED]: raise NotImplementedError - def get_response(self, html): # pylint: disable=unused-argument - # type: (str) -> RED - return '' # type: ignore + def get_response(self, html: str) -> RED: # pylint: disable=unused-argument + return "" # type: ignore - def get_iss(self): - # type: () -> t.Optional[str] + def get_iss(self) -> t.Optional[str]: if self._registration: return self._registration.get_issuer() return None - def get_client_id(self): + def get_client_id(self) -> t.Optional[str]: if self._registration: return self._registration.get_client_id() return None - def _get_request_param(self, key): - # type: (str) -> t.Any + def _get_request_param(self, key: str) -> str: return self._request.get_param(key) - def _get_uuid(self): - # type: () -> str + def _get_uuid(self) -> str: return str(uuid.uuid4()) - def _generate_nonce(self): - # type: () -> str + def _generate_nonce(self) -> str: return uuid.uuid4().hex + uuid.uuid1().hex - def _is_new_window_request(self): - # type: () -> bool - lti_new_window = self._get_request_param('lti1p3_new_window') + def _is_new_window_request(self) -> bool: + lti_new_window = self._get_request_param("lti1p3_new_window") return bool(lti_new_window) - def _prepare_redirect_url(self, launch_url): - # type: (str) -> str + def _prepare_redirect_url(self, launch_url: str) -> str: if not launch_url: raise OIDCException("No launch URL configured") @@ -108,7 +99,7 @@ def _prepare_redirect_url(self, launch_url): # generate state # set cookie (short lived) - state = 'state-' + self._get_uuid() + state = "state-" + self._get_uuid() self._cookie_service.set_cookie(state, state, 5 * 60) # 5 min # generate nonce @@ -119,38 +110,38 @@ def _prepare_redirect_url(self, launch_url): # build Response client_id = self._registration.get_client_id() # Registered client id - assert client_id is not None, 'Client id should not be None' + assert client_id is not None, "Client id should not be None" auth_login_url = self._registration.get_auth_login_url() - assert auth_login_url is not None, 'Auth login url should not be None' + assert auth_login_url is not None, "Auth login url should not be None" auth_params = { - 'scope': 'openid', # OIDC Scope - 'response_type': 'id_token', # OIDC response is always an id token - 'response_mode': 'form_post', # OIDC response is always a form post - 'prompt': 'none', # Don't prompt user on redirect - 'client_id': client_id, # Registered client id - 'redirect_uri': launch_url, # URL to return to after login - 'state': state, # State to identify browser session - 'nonce': nonce, # Prevent replay attacks - 'login_hint': self._get_request_param('login_hint') # Login hint to identify platform session + "scope": "openid", # OIDC Scope + "response_type": "id_token", # OIDC response is always an id token + "response_mode": "form_post", # OIDC response is always a form post + "prompt": "none", # Don't prompt user on redirect + "client_id": client_id, # Registered client id + "redirect_uri": launch_url, # URL to return to after login + "state": state, # State to identify browser session + "nonce": nonce, # Prevent replay attacks + "login_hint": self._get_request_param( + "login_hint" + ), # Login hint to identify platform session } # pass back LTI message hint if we have it - lti_message_hint = self._get_request_param('lti_message_hint') + lti_message_hint = self._get_request_param("lti_message_hint") if lti_message_hint: # LTI message hint to identify LTI context within the platform - auth_params['lti_message_hint'] = lti_message_hint + auth_params["lti_message_hint"] = lti_message_hint auth_login_return_url = auth_login_url + "?" + urlencode(auth_params) return auth_login_return_url - def _prepare_redirect(self, launch_url): - # type: (str) -> Redirect[RED] + def _prepare_redirect(self, launch_url: str) -> Redirect[RED]: auth_login_return_url = self._prepare_redirect_url(launch_url) return self.get_redirect(auth_login_return_url) - def redirect(self, launch_url, js_redirect=False): - # type: (str, bool) -> RED + def redirect(self, launch_url: str, js_redirect: bool = False) -> RED: """ Calculate the redirect location to return to based on an OIDC third party initiated login request. @@ -169,30 +160,31 @@ def redirect(self, launch_url, js_redirect=False): return redirect_obj.do_js_redirect() return redirect_obj.do_redirect() - def get_redirect_object(self, launch_url): - # type: (str) -> Redirect[RED] + def get_redirect_object(self, launch_url: str) -> Redirect[RED]: return self._prepare_redirect(launch_url) - def validate_oidc_login(self): - # type: () -> Registration + def validate_oidc_login(self) -> Registration: # validate Issuer - iss = self._get_request_param('iss') + iss = self._get_request_param("iss") if not iss: - raise OIDCException('Could not find issuer') + raise OIDCException("Could not find issuer") # validate login hint - login_hint = self._get_request_param('login_hint') + login_hint = self._get_request_param("login_hint") if not login_hint: - raise OIDCException('Could not find login hint') + raise OIDCException("Could not find login hint") - client_id = self._get_request_param('client_id') + client_id = self._get_request_param("client_id") # fetch registration details if self._tool_config.check_iss_has_one_client(iss): - registration = self._tool_config.find_registration(iss, action=Action.OIDC_LOGIN, request=self._request) + registration = self._tool_config.find_registration( + iss, action=Action.OIDC_LOGIN, request=self._request + ) else: - registration = self._tool_config.find_registration_by_params(iss, client_id, action=Action.OIDC_LOGIN, - request=self._request) + registration = self._tool_config.find_registration_by_params( + iss, client_id, action=Action.OIDC_LOGIN, request=self._request + ) # check we got something if not registration: @@ -200,16 +192,20 @@ def validate_oidc_login(self): return registration - def pass_params_to_launch(self, params): - # type: (T_SELF, t.Dict[str, object]) -> T_SELF + def pass_params_to_launch(self, params: t.Dict[str, object]) -> "OIDCLogin": """ Ability to pass custom params from oidc login to launch. """ self._state_params = params return self - def enable_check_cookies(self, main_msg=None, click_msg=None, loading_msg=None, **kwargs): - # type: (T_SELF, t.Optional[str], t.Optional[str], t.Optional[str], **None) -> T_SELF + def enable_check_cookies( + self, + main_msg: t.Optional[str] = None, + click_msg: t.Optional[str] = None, + loading_msg: t.Optional[str] = None, + **kwargs + ) -> "OIDCLogin": # pylint: disable=unused-argument self._cookies_check = True if main_msg: @@ -220,42 +216,49 @@ def enable_check_cookies(self, main_msg=None, click_msg=None, loading_msg=None, self._cookies_check_loading_text = loading_msg return self - def disable_check_cookies(self): - # type: (T_SELF) -> T_SELF + def disable_check_cookies(self) -> "OIDCLogin": self._cookies_check = False return self - def get_additional_login_params(self): - # type: () -> t.List[str] + def get_additional_login_params(self) -> t.List[str]: """ You may add additional custom params in your own OIDCLogin class :return: list """ return [] - def get_cookies_allowed_js_check(self): - # type: () -> str - protocol = 'https' if self._request.is_secure() else 'http' - params_lst = ['iss', 'login_hint', 'target_link_uri', 'lti_message_hint', - 'lti_deployment_id', 'client_id'] + def get_cookies_allowed_js_check(self) -> str: + protocol = "https" if self._request.is_secure() else "http" + params_lst = [ + "iss", + "login_hint", + "target_link_uri", + "lti_message_hint", + "lti_deployment_id", + "client_id", + ] additional_login_params = self.get_additional_login_params() params_lst.extend(additional_login_params) - params = { - 'lti1p3_new_window': '1' - } + params = {"lti1p3_new_window": "1"} for param_key in params_lst: param_value = self._get_request_param(param_key) if param_value: params[param_key] = param_value - page = CookiesAllowedCheckPage(params, protocol, self._cookies_unavailable_msg_main_text, - self._cookies_unavailable_msg_click_text, self._cookies_check_loading_text) + page = CookiesAllowedCheckPage( + params, + protocol, + self._cookies_unavailable_msg_main_text, + self._cookies_unavailable_msg_click_text, + self._cookies_check_loading_text, + ) return page.get_html() - def set_launch_data_storage(self, data_storage): - # type: (T_SELF, LaunchDataStorage[t.Any]) -> T_SELF + def set_launch_data_storage( + self, data_storage: LaunchDataStorage[t.Any] + ) -> "OIDCLogin": data_storage.set_request(self._request) session_cookie_name = data_storage.get_session_cookie_name() if session_cookie_name: @@ -267,7 +270,6 @@ def set_launch_data_storage(self, data_storage): self._session_service.set_data_storage(data_storage) return self - def set_launch_data_lifetime(self, time_sec): - # type: (T_SELF, int) -> T_SELF + def set_launch_data_lifetime(self, time_sec: int) -> "OIDCLogin": self._session_service.set_launch_data_lifetime(time_sec) return self diff --git a/pylti1p3/redirect.py b/pylti1p3/redirect.py index 468c789..6af0fb3 100644 --- a/pylti1p3/redirect.py +++ b/pylti1p3/redirect.py @@ -1,28 +1,24 @@ import typing as t from abc import ABCMeta, abstractmethod -T = t.TypeVar('T') +T = t.TypeVar("T") class Redirect(t.Generic[T]): __metaclass__ = ABCMeta @abstractmethod - def do_redirect(self): - # type: () -> T + def do_redirect(self) -> T: raise NotImplementedError @abstractmethod - def do_js_redirect(self): - # type: () -> T + def do_js_redirect(self) -> T: raise NotImplementedError @abstractmethod - def set_redirect_url(self, location): - # type: (str) -> None + def set_redirect_url(self, location: str): raise NotImplementedError @abstractmethod - def get_redirect_url(self): - # type: () -> str + def get_redirect_url(self) -> str: raise NotImplementedError diff --git a/pylti1p3/registration.py b/pylti1p3/registration.py index 1b480b1..c15d03a 100644 --- a/pylti1p3/registration.py +++ b/pylti1p3/registration.py @@ -1,130 +1,105 @@ import json import typing as t - +import typing_extensions as te from jwcrypto.jwk import JWK # type: ignore -T_SELF = t.TypeVar('T_SELF', bound='Registration') - -if t.TYPE_CHECKING: - from mypy_extensions import TypedDict - _Key = TypedDict('_Key', {'kid': str, 'alg': str}, total=True) - _KeySet = TypedDict('_KeySet', {'keys': t.List[_Key]}, total=True) - from cryptography.hazmat.primitives.asymmetric.rsa import ( - RSAPublicKey, RSAPrivateKey - ) - _POSSIBLE_KEYS = t.Union[str, bytes, RSAPrivateKey, RSAPublicKey] +TKey = te.TypedDict("TKey", {"kid": str, "alg": str}, total=True) +TKeySet = te.TypedDict("TKeySet", {"keys": t.List[TKey]}, total=True) -class Registration(object): - _issuer = None # type: t.Optional[str] - _client_id = None # type: t.Optional[str] - _key_set_url = None # type: t.Optional[str] - _key_set = None # type: t.Optional[_KeySet] - _auth_token_url = None # type: t.Optional[str] - _auth_login_url = None # type: t.Optional[str] - _tool_private_key = None # type: t.Optional[_POSSIBLE_KEYS] - _auth_audience = None +class Registration: + _issuer: t.Optional[str] = None + _client_id: t.Optional[str] = None + _key_set_url: t.Optional[str] = None + _key_set: t.Optional[TKeySet] = None + _auth_token_url: t.Optional[str] = None + _auth_login_url: t.Optional[str] = None + _tool_private_key: t.Optional[str] = None + _auth_audience: t.Optional[str] = None _tool_public_key = None - def get_issuer(self): - # type: () -> t.Optional[str] + def get_issuer(self) -> t.Optional[str]: return self._issuer - def set_issuer(self, issuer): - # type: (T_SELF, str) -> T_SELF + def set_issuer(self, issuer: str) -> "Registration": self._issuer = issuer return self - def get_client_id(self): - # type: () -> t.Optional[str] + def get_client_id(self) -> t.Optional[str]: return self._client_id - def set_client_id(self, client_id): - # type: (T_SELF, str) -> T_SELF + def set_client_id(self, client_id: str) -> "Registration": self._client_id = client_id return self - def get_key_set(self): - # type: () -> t.Optional[_KeySet] + def get_key_set(self) -> t.Optional[TKeySet]: return self._key_set - def set_key_set(self, key_set): - # type: (T_SELF, t.Optional[_KeySet]) -> T_SELF + def set_key_set(self, key_set: t.Optional[TKeySet]) -> "Registration": self._key_set = key_set return self - def get_key_set_url(self): - # type: () -> t.Optional[str] + def get_key_set_url(self) -> t.Optional[str]: return self._key_set_url - def set_key_set_url(self, key_set_url): - # type: (T_SELF, str) -> T_SELF + def set_key_set_url(self, key_set_url: t.Optional[str]) -> "Registration": self._key_set_url = key_set_url return self - def get_auth_token_url(self): - # type: () -> t.Optional[str] + def get_auth_token_url(self) -> t.Optional[str]: return self._auth_token_url - def set_auth_token_url(self, auth_token_url): - # type: (T_SELF, str) -> T_SELF + def set_auth_token_url(self, auth_token_url: str) -> "Registration": self._auth_token_url = auth_token_url return self - def get_auth_login_url(self): - # type: () -> t.Optional[str] + def get_auth_login_url(self) -> t.Optional[str]: return self._auth_login_url - def set_auth_login_url(self, auth_login_url): - # type: (T_SELF, str) -> T_SELF + def set_auth_login_url(self, auth_login_url: str) -> "Registration": self._auth_login_url = auth_login_url return self - def get_auth_audience(self): + def get_auth_audience(self) -> t.Optional[str]: return self._auth_audience - def set_auth_audience(self, auth_audience): + def set_auth_audience(self, auth_audience: str) -> "Registration": self._auth_audience = auth_audience return self - def get_tool_private_key(self): - # type: () -> t.Optional[_POSSIBLE_KEYS] + def get_tool_private_key(self) -> t.Optional[str]: return self._tool_private_key - def set_tool_private_key(self, tool_private_key): - # type: (T_SELF, _POSSIBLE_KEYS) -> T_SELF + def set_tool_private_key(self, tool_private_key: str) -> "Registration": self._tool_private_key = tool_private_key return self def get_tool_public_key(self): return self._tool_public_key - def set_tool_public_key(self, tool_public_key): + def set_tool_public_key(self, tool_public_key) -> "Registration": self._tool_public_key = tool_public_key return self @classmethod - def get_jwk(cls, public_key): - # type: (str) -> t.Mapping[str, t.Any] - jwk_obj = JWK.from_pem(public_key.encode('utf-8')) + def get_jwk(cls, public_key: str) -> t.Mapping[str, t.Any]: + jwk_obj = JWK.from_pem(public_key.encode("utf-8")) public_jwk = json.loads(jwk_obj.export_public()) - public_jwk['alg'] = 'RS256' - public_jwk['use'] = 'sig' + public_jwk["alg"] = "RS256" + public_jwk["use"] = "sig" return public_jwk - def get_jwks(self): - # type: () -> t.List[t.Mapping[str, t.Any]] + def get_jwks(self) -> t.List[t.Mapping[str, t.Any]]: keys = [] public_key = self.get_tool_public_key() if public_key: keys.append(Registration.get_jwk(public_key)) return keys - def get_kid(self): - # type: () -> t.Optional[str] + def get_kid(self) -> t.Optional[str]: key = self.get_tool_public_key() if key: jwk = Registration.get_jwk(key) - return jwk.get('kid') if jwk else None + return jwk.get("kid") if jwk else None return None diff --git a/pylti1p3/request.py b/pylti1p3/request.py index adcdd5b..44c88df 100644 --- a/pylti1p3/request.py +++ b/pylti1p3/request.py @@ -1,47 +1,17 @@ -import typing as t from abc import ABCMeta, abstractmethod -if t.TYPE_CHECKING: - from typing_extensions import Protocol - class SessionLike(Protocol): - @t.overload - def get(self, key): - # type: (str) -> t.Any - pass - - @t.overload - def get(self, key, default): # pylint: disable=function-redefined - # type: (str, t.Any) -> t.Any - pass - - def get(self, key, default=None): # pylint: disable=function-redefined - # type: (str, t.Any) -> t.Any - pass - - def __setitem__(self, key, value): - # type: (str, t.Any) -> None - pass - - def __contains__(self, key): - # type: (object) -> bool - pass - - -class Request(object): +class Request: __metaclass__ = ABCMeta @property def session(self): - # type: () -> SessionLike raise NotImplementedError @abstractmethod - def is_secure(self): - # type: () -> bool + def is_secure(self) -> bool: raise NotImplementedError @abstractmethod - def get_param(self, key): - # type: (str) -> object + def get_param(self, key: str) -> str: raise NotImplementedError diff --git a/pylti1p3/roles.py b/pylti1p3/roles.py index aa2045e..182334c 100644 --- a/pylti1p3/roles.py +++ b/pylti1p3/roles.py @@ -1,31 +1,30 @@ from abc import ABCMeta import typing as t +import typing_extensions as te -if t.TYPE_CHECKING: - from typing_extensions import Final +class RoleType: + SYSTEM: te.Final = "system" + INSTITUTION: te.Final = "institution" + CONTEXT: te.Final = "membership" -class RoleType(object): - SYSTEM = 'system' # type: Final - INSTITUTION = 'institution' # type: Final - CONTEXT = 'membership' # type: Final - -class AbstractRole(object): +class AbstractRole: __metaclass__ = ABCMeta - _base_prefix = 'http://purl.imsglobal.org/vocab/lis/v2' # type: str - _role_types = [RoleType.SYSTEM, RoleType.INSTITUTION, RoleType.CONTEXT] # type: list - _jwt_roles = [] # type: list - _common_roles = None # type: t.Optional[tuple] - _system_roles = None # type: t.Optional[tuple] - _institution_roles = None # type: t.Optional[tuple] - _context_roles = None # type: t.Optional[tuple] + _base_prefix: str = "http://purl.imsglobal.org/vocab/lis/v2" + _role_types = [RoleType.SYSTEM, RoleType.INSTITUTION, RoleType.CONTEXT] + _jwt_roles: t.List[str] = [] + _common_roles: t.Optional[t.Tuple] = None + _system_roles: t.Optional[t.Tuple] = None + _institution_roles: t.Optional[t.Tuple] = None + _context_roles: t.Optional[t.Tuple] = None def __init__(self, jwt_body): - self._jwt_roles = jwt_body.get('https://purl.imsglobal.org/spec/lti/claim/roles', []) + self._jwt_roles = jwt_body.get( + "https://purl.imsglobal.org/spec/lti/claim/roles", [] + ) - def check(self): - # type: () -> bool + def check(self) -> bool: for role_str in self._jwt_roles: role_name, role_type = self.parse_role_str(role_str) res = self._check_access(role_name, role_type) @@ -33,20 +32,35 @@ def check(self): return True return False - def _check_access(self, role_name, role_type=None): - # type: (str, t.Optional[str]) -> bool - return bool((self._system_roles and role_type == RoleType.SYSTEM and role_name in self._system_roles) - or (self._institution_roles and role_type == RoleType.INSTITUTION - and role_name in self._institution_roles) - or (self._context_roles and role_type == RoleType.CONTEXT and role_name in self._context_roles) - or (self._common_roles and role_type is None and role_name in self._common_roles)) - - def parse_role_str(self, role_str): - # type: (str) -> t.Tuple[str, t.Optional[str]] + def _check_access(self, role_name: str, role_type: t.Optional[str] = None): + return bool( + ( + self._system_roles + and role_type == RoleType.SYSTEM + and role_name in self._system_roles + ) + or ( + self._institution_roles + and role_type == RoleType.INSTITUTION + and role_name in self._institution_roles + ) + or ( + self._context_roles + and role_type == RoleType.CONTEXT + and role_name in self._context_roles + ) + or ( + self._common_roles + and role_type is None + and role_name in self._common_roles + ) + ) + + def parse_role_str(self, role_str: str) -> t.Tuple[str, t.Optional[str]]: if role_str.startswith(self._base_prefix): - role = role_str[len(self._base_prefix):] - role_parts = role.split('/') - role_name_parts = role.split('#') + role = role_str[len(self._base_prefix) :] + role_parts = role.split("/") + role_name_parts = role.split("#") if len(role_parts) > 1 and len(role_name_parts) > 1: role_type = role_parts[1] @@ -58,38 +72,38 @@ def parse_role_str(self, role_str): class StaffRole(AbstractRole): - _system_roles = ('Administrator', 'SysAdmin') # type: tuple - _institution_roles = ('Faculty', 'SysAdmin', 'Staff', 'Instructor') # type: tuple + _system_roles = ("Administrator", "SysAdmin") + _institution_roles = ("Faculty", "SysAdmin", "Staff", "Instructor") class StudentRole(AbstractRole): - _common_roles = ('Learner', 'Member', 'User') # type: tuple - _system_roles = ('User',) # type: tuple - _institution_roles = ('Student', 'Learner', 'Member', 'ProspectiveStudent', 'User') # type: tuple - _context_roles = ('Learner', 'Member') # type: tuple + _common_roles = ("Learner", "Member", "User") + _system_roles = ("User",) + _institution_roles = ("Student", "Learner", "Member", "ProspectiveStudent", "User") + _context_roles = ("Learner", "Member") class TeacherRole(AbstractRole): - _common_roles = ('Instructor', 'Administrator') # type: tuple - _context_roles = ('Instructor', 'Administrator') # type: tuple + _common_roles = ("Instructor", "Administrator") + _context_roles = ("Instructor", "Administrator") class TeachingAssistantRole(AbstractRole): - _context_roles = ('TeachingAssistant',) # type: tuple + _context_roles = ("TeachingAssistant",) class DesignerRole(AbstractRole): - _common_roles = ('ContentDeveloper',) # type: tuple - _context_roles = ('ContentDeveloper',) # type: tuple + _common_roles = ("ContentDeveloper",) + _context_roles = ("ContentDeveloper",) class ObserverRole(AbstractRole): - _common_roles = ('Mentor',) # type: tuple - _context_roles = ('Mentor',) # type: tuple + _common_roles = ("Mentor",) + _context_roles = ("Mentor",) class TransientRole(AbstractRole): - _common_roles = ('Transient',) # type: tuple - _system_roles = ('Transient',) # type: tuple - _institution_roles = ('Transient',) # type: tuple - _context_roles = ('Transient',) # type: tuple + _common_roles = ("Transient",) + _system_roles = ("Transient",) + _institution_roles = ("Transient",) + _context_roles = ("Transient",) diff --git a/pylti1p3/service_connector.py b/pylti1p3/service_connector.py index 885423b..d50149f 100644 --- a/pylti1p3/service_connector.py +++ b/pylti1p3/service_connector.py @@ -1,35 +1,37 @@ import hashlib import re -import sys import time import typing as t import uuid import jwt # type: ignore import requests - +import typing_extensions as te from .exception import LtiServiceException +from .registration import Registration -if t.TYPE_CHECKING: - from mypy_extensions import TypedDict - from .registration import Registration - - _ServiceConnectorResponse = TypedDict('_ServiceConnectorResponse', { - 'headers': t.Union[t.Dict[str, str], t.MutableMapping[str, str]], - 'body': t.Union[None, int, float, t.List[object], t.Dict[str, object], str], - 'next_page_url': t.Optional[str] - }) +TServiceConnectorResponse = te.TypedDict( + "TServiceConnectorResponse", + { + "headers": t.Union[t.Dict[str, str], t.MutableMapping[str, str]], + "body": t.Union[None, int, float, t.List[object], t.Dict[str, object], str], + "next_page_url": t.Optional[str], + }, +) -REQUESTS_USER_AGENT = 'PyLTI1p3-client' +REQUESTS_USER_AGENT = "PyLTI1p3-client" -class ServiceConnector(object): - _registration = None # type: Registration - _access_tokens = None # type: t.Dict[str, str] +class ServiceConnector: + _registration: Registration + _access_tokens: t.Dict[str, str] - def __init__(self, registration, requests_session=None): - # type: (Registration, t.Optional[requests.Session]) -> None + def __init__( + self, + registration: Registration, + requests_session: t.Optional[requests.Session] = None, + ): self._registration = registration self._access_tokens = {} if requests_session: @@ -38,17 +40,12 @@ def __init__(self, registration, requests_session=None): self._requests_session = requests.Session() self._requests_session.headers["User-Agent"] = REQUESTS_USER_AGENT - def get_access_token(self, scopes): - # type: (t.Sequence[str]) -> str - + def get_access_token(self, scopes: t.Sequence[str]) -> str: # Don't fetch the same key more than once scopes = sorted(scopes) - scopes_str = '|'.join(scopes) # type: str + scopes_str: str = "|".join(scopes) + scopes_bytes = scopes_str.encode("utf-8") - if sys.version_info[0] > 2: - scopes_bytes = scopes_str.encode('utf-8') - else: - scopes_bytes = scopes_str scope_key = hashlib.md5(scopes_bytes).hexdigest() if scope_key in self._access_tokens: @@ -56,34 +53,35 @@ def get_access_token(self, scopes): # Build up JWT to exchange for an auth token client_id = self._registration.get_client_id() + assert client_id is not None, "client_id should be set at this point" auth_url = self._registration.get_auth_token_url() - assert auth_url is not None, 'auth_url should be set at this point' + assert auth_url is not None, "auth_url should be set at this point" auth_audience = self._registration.get_auth_audience() aud = auth_audience if auth_audience else auth_url - jwt_claim = { - "iss": client_id, - "sub": client_id, - "aud": aud, + jwt_claim: t.Dict[str, t.Union[str, int]] = { + "iss": str(client_id), + "sub": str(client_id), + "aud": str(aud), "iat": int(time.time()) - 5, "exp": int(time.time()) + 60, - "jti": 'lti-service-token-' + str(uuid.uuid4()) + "jti": "lti-service-token-" + str(uuid.uuid4()), } - headers = None + headers = {} kid = self._registration.get_kid() if kid: - headers = {'kid': kid} + headers = {"kid": kid} # Sign the JWT with our private key (given by the platform on registration) private_key = self._registration.get_tool_private_key() - assert private_key is not None, 'Private key should be set at this point' + assert private_key is not None, "Private key should be set at this point" jwt_val = self.encode_jwt(jwt_claim, private_key, headers) auth_request = { - 'grant_type': 'client_credentials', - 'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - 'client_assertion': jwt_val, - 'scope': ' '.join(scopes) + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": jwt_val, + "scope": " ".join(scopes), } # Make request to get auth token @@ -92,34 +90,35 @@ def get_access_token(self, scopes): raise LtiServiceException(r) response = r.json() - self._access_tokens[scope_key] = response['access_token'] + self._access_tokens[scope_key] = response["access_token"] return self._access_tokens[scope_key] - def encode_jwt(self, message, private_key, headers): - jwt_val = jwt.encode(message, private_key, algorithm='RS256', headers=headers) - if sys.version_info[0] > 2 and isinstance(jwt_val, bytes): - return jwt_val.decode('utf-8') + def encode_jwt( + self, + message: t.Dict[str, t.Union[str, int]], + private_key: str, + headers: t.Dict[str, str], + ) -> str: + jwt_val = jwt.encode(message, private_key, algorithm="RS256", headers=headers) + if isinstance(jwt_val, bytes): + return jwt_val.decode("utf-8") return jwt_val def make_service_request( - self, - scopes, # type: t.Sequence[str] - url, # type: str - is_post=False, # type: bool - data=None, # type: t.Union[None, str] - content_type='application/json', # type: str - accept='application/json', # type: str - case_insensitive_headers=False, # type: bool - ): - # type: (...) -> _ServiceConnectorResponse + self, + scopes: t.Sequence[str], + url: str, + is_post: bool = False, + data: t.Optional[str] = None, + content_type: str = "application/json", + accept: str = "application/json", + case_insensitive_headers: bool = False, + ) -> TServiceConnectorResponse: access_token = self.get_access_token(scopes) - headers = { - 'Authorization': 'Bearer ' + access_token, - 'Accept': accept - } + headers = {"Authorization": "Bearer " + access_token, "Accept": accept} if is_post: - headers['Content-Type'] = content_type + headers["Content-Type"] = content_type post_data = data or None r = self._requests_session.post(url, data=post_data, headers=headers) else: @@ -129,14 +128,17 @@ def make_service_request( raise LtiServiceException(r) next_page_url = None - link_header = r.headers.get('link', '') + link_header = r.headers.get("link", "") if link_header: - match = re.search(r'<([^>]*)>;\s*rel="next"', link_header.replace('\n', ' ').lower().strip()) + match = re.search( + r'<([^>]*)>;\s*rel="next"', + link_header.replace("\n", " ").lower().strip(), + ) if match: next_page_url = match.group(1) return { - 'headers': r.headers if case_insensitive_headers else dict(r.headers), - 'body': r.json() if r.content else None, - 'next_page_url': next_page_url if next_page_url else None + "headers": r.headers if case_insensitive_headers else dict(r.headers), + "body": r.json() if r.content else None, + "next_page_url": next_page_url if next_page_url else None, } diff --git a/pylti1p3/session.py b/pylti1p3/session.py index 3720fb1..e28078b 100644 --- a/pylti1p3/session.py +++ b/pylti1p3/session.py @@ -1,75 +1,70 @@ import typing as t from .launch_data_storage.session import SessionDataStorage +from .request import Request +from .launch_data_storage.base import LaunchDataStorage -if t.TYPE_CHECKING: - from .request import Request - _JWT_BODY = t.Dict[str, object] - _STATE_PARAMS = t.Dict[str, object] +TStateParams = t.Dict[str, object] +TJwtBody = t.Mapping[str, t.Any] -class SessionService(object): - data_storage = None # type: SessionDataStorage[t.Any] - _launch_data_lifetime = 86400 # type: int - _session_prefix = 'lti1p3' # type: str - def __init__(self, request): - # type: (Request) -> None +class SessionService: + data_storage: LaunchDataStorage[t.Any] + _launch_data_lifetime = 86400 + _session_prefix = "lti1p3" + + def __init__(self, request: Request): self.data_storage = SessionDataStorage() self.data_storage.set_request(request) - def _get_key(self, key, nonce=None, add_prefix=True): - # type: (str, t.Optional[str], bool) -> str - return ((self._session_prefix + '-') if add_prefix else '') + key + (('-' + nonce) if nonce else '') + def _get_key( + self, key: str, nonce: t.Optional[str] = None, add_prefix: bool = True + ): + return ( + ((self._session_prefix + "-") if add_prefix else "") + + key + + (("-" + nonce) if nonce else "") + ) - def _set_value(self, key, value): - # type: (str, object) -> None + def _set_value(self, key: str, value: object): self.data_storage.set_value(key, value, exp=self._launch_data_lifetime) - def _get_value(self, key): - # type: (str) -> t.Any + def _get_value(self, key: str) -> t.Any: return self.data_storage.get_value(key) - def get_launch_data(self, key): - # type: (str) -> _JWT_BODY + def get_launch_data(self, key: str) -> TJwtBody: return self._get_value(self._get_key(key, add_prefix=False)) - def save_launch_data(self, key, jwt_body): - # type: (str, _JWT_BODY) -> None + def save_launch_data(self, key: str, jwt_body: TJwtBody): self._set_value(self._get_key(key, add_prefix=False), jwt_body) - def save_nonce(self, nonce): - # type: (str) -> None - self._set_value(self._get_key('nonce', nonce), True) + def save_nonce(self, nonce: str): + self._set_value(self._get_key("nonce", nonce), True) - def check_nonce(self, nonce): - # type: (str) -> bool - nonce_key = self._get_key('nonce', nonce) + def check_nonce(self, nonce: str) -> bool: + nonce_key = self._get_key("nonce", nonce) return self.data_storage.check_value(nonce_key) - def save_state_params(self, state, params): - # type: (str, _STATE_PARAMS) -> None + def save_state_params(self, state: str, params: TStateParams): self._set_value(self._get_key(state), params) - def get_state_params(self, state): - # type: (str) -> _STATE_PARAMS + def get_state_params(self, state: str) -> TStateParams: return self._get_value(self._get_key(state)) - def set_state_valid(self, state, id_token_hash): - # type: (str, str) -> None - return self._set_value(self._get_key(state + '-id-token-hash'), id_token_hash) + def set_state_valid(self, state: str, id_token_hash: str): + return self._set_value(self._get_key(state + "-id-token-hash"), id_token_hash) - def check_state_is_valid(self, state, id_token_hash): - # type: (str, str) -> bool - return self._get_value(self._get_key(state + '-id-token-hash')) == id_token_hash + def check_state_is_valid(self, state: str, id_token_hash: str) -> bool: + return self._get_value(self._get_key(state + "-id-token-hash")) == id_token_hash - def set_data_storage(self, data_storage): - # type: (SessionDataStorage[t.Any]) -> None + def set_data_storage(self, data_storage: LaunchDataStorage[t.Any]): self.data_storage = data_storage - def set_launch_data_lifetime(self, time_sec): - # type: (int) -> None + def set_launch_data_lifetime(self, time_sec: int): if self.data_storage.can_set_keys_expiration(): self._launch_data_lifetime = time_sec else: - raise Exception("%s launch storage doesn't support manual change expiration of the keys" - % self.data_storage.__class__.__name__) + raise Exception( + f"{self.data_storage.__class__.__name__} launch storage doesn't support " + f"manual change expiration of the keys" + ) diff --git a/pylti1p3/tool_config/__init__.py b/pylti1p3/tool_config/__init__.py index 5c8b6d2..d6bb1de 100644 --- a/pylti1p3/tool_config/__init__.py +++ b/pylti1p3/tool_config/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .abstract import ToolConfAbstract from .dict import ToolConfDict from .json_file import ToolConfJsonFile diff --git a/pylti1p3/tool_config/abstract.py b/pylti1p3/tool_config/abstract.py index d13a8bb..e97d07e 100644 --- a/pylti1p3/tool_config/abstract.py +++ b/pylti1p3/tool_config/abstract.py @@ -1,80 +1,61 @@ -import inspect -import sys import typing as t -import warnings from abc import ABCMeta, abstractmethod +import typing_extensions as te +from ..deployment import Deployment +from ..registration import Registration +from ..request import Request -REQ = t.TypeVar('REQ', bound='Request') -if t.TYPE_CHECKING: - from ..request import Request - from ..registration import Registration - from ..deployment import Deployment - from ..message_launch import _LaunchData - from typing_extensions import Literal, Final - FIND_REG_KWARGS = t.Union[Literal['oidc_login', 'message_launch'], REQ, - _LaunchData] - PossibleRelationTypes = Literal['one-issuer-one-client-id', 'one-issuer-many-client-ids'] +REQ = t.TypeVar("REQ", bound=Request) -class IssuerToClientRelation(object): - ONE_CLIENT_ID_PER_ISSUER = 'one-issuer-one-client-id' # type: Final - MANY_CLIENTS_IDS_PER_ISSUER = 'one-issuer-many-client-ids' # type: Final +class IssuerToClientRelation: + ONE_CLIENT_ID_PER_ISSUER: te.Final = "one-issuer-one-client-id" + MANY_CLIENTS_IDS_PER_ISSUER: te.Final = "one-issuer-many-client-ids" class ToolConfAbstract(t.Generic[REQ]): __metaclass__ = ABCMeta - reg_extended_search = False # type: bool - issuers_relation_types = {} # type: t.MutableMapping[str, PossibleRelationTypes] - - def __init__(self): - # type: () -> None - if sys.version_info[0] > 2: - argspec = inspect.getfullargspec(self.find_registration_by_issuer) - self.reg_extended_search = None not in (argspec.varargs, argspec.varkw) - else: - argspec = inspect.getargspec(self.find_registration_by_issuer) # pylint: disable=deprecated-method - self.reg_extended_search = None not in (argspec.varargs, argspec.keywords) - - def check_iss_has_one_client(self, iss): - # type: (str) -> bool + issuers_relation_types: t.MutableMapping[str, str] = {} + + def check_iss_has_one_client(self, iss: str) -> bool: """ Two methods check_iss_has_one_client / check_iss_has_many_clients are needed for the the backward compatibility with the previous versions of the library (1.4.0 and early) where ToolConfDict supported only client_id per iss. Should return False for all new ToolConf-s """ - iss_type = self.issuers_relation_types.get(iss, IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER) + iss_type = self.issuers_relation_types.get( + iss, IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + ) return iss_type == IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER - def check_iss_has_many_clients(self, iss): - # type: (str) -> bool + def check_iss_has_many_clients(self, iss: str) -> bool: """ Should return True for all new ToolConf-s """ - iss_type = self.issuers_relation_types.get(iss, IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER) + iss_type = self.issuers_relation_types.get( + iss, IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + ) return iss_type == IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER - def set_iss_has_one_client(self, iss): - # type: (str) -> None - self.issuers_relation_types[iss] = IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + def set_iss_has_one_client(self, iss: str): + self.issuers_relation_types[ + iss + ] = IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER - def set_iss_has_many_clients(self, iss): - # type: (str) -> None - self.issuers_relation_types[iss] = IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER + def set_iss_has_many_clients(self, iss: str): + self.issuers_relation_types[ + iss + ] = IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER - def find_registration(self, iss, *args, **kwargs): - # type: (str, *None, **FIND_REG_KWARGS) -> t.Optional[Registration] - if self.reg_extended_search: - return self.find_registration_by_issuer(iss, *args, **kwargs) - else: - warnings.warn( - "Signature of ToolConfAbstract.find_registration_by_issuer method was changed, " - "please update you custom implementation", DeprecationWarning) - return self.find_registration_by_issuer(iss) + def find_registration(self, iss: str, *args, **kwargs) -> Registration: + """ + Backward compatibility method + """ + return self.find_registration_by_issuer(iss, *args, **kwargs) @abstractmethod - def find_registration_by_issuer(self, iss, *args, **kwargs): - # type: (str, *None, **FIND_REG_KWARGS) -> t.Optional[Registration] + def find_registration_by_issuer(self, iss: str, *args, **kwargs) -> Registration: """ Find registration in case if iss has only one client id, i.e in case of { ... "iss": { ... "client_id: "client" ... }, ... } config. @@ -84,8 +65,9 @@ def find_registration_by_issuer(self, iss, *args, **kwargs): raise NotImplementedError @abstractmethod - def find_registration_by_params(self, iss, client_id, *args, **kwargs): - # type: (str, str, *None, **FIND_REG_KWARGS) -> t.Optional[Registration] + def find_registration_by_params( + self, iss: str, client_id: str, *args, **kwargs + ) -> Registration: """ Find registration in case if iss has many client ids, i.e in case of { ... "iss": [ { ... "client_id: "client1" ... }, { ... "client_id: "client2" ... } ], ... } config. @@ -96,8 +78,7 @@ def find_registration_by_params(self, iss, client_id, *args, **kwargs): raise NotImplementedError @abstractmethod - def find_deployment(self, iss, deployment_id): - # type: (str, str) -> t.Optional[Deployment] + def find_deployment(self, iss: str, deployment_id: str) -> t.Optional[Deployment]: """ Find deployment in case if iss has only one client id, i.e in case of { ... "iss": { ... "client_id: "client" ... }, ... } config. @@ -107,8 +88,9 @@ def find_deployment(self, iss, deployment_id): raise NotImplementedError @abstractmethod - def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwargs): - # type: (str, str, str, *None, **None) -> t.Optional[Deployment] + def find_deployment_by_params( + self, iss: str, deployment_id: str, client_id: str, *args, **kwargs + ) -> t.Optional[Deployment]: """ Find deployment in case if iss has many client ids, i.e in case of { ... "iss": [ { ... "client_id: "client1" ... }, { ... "client_id: "client2" ... } ], ... } config. @@ -118,16 +100,18 @@ def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwar """ raise NotImplementedError - def get_jwks(self, iss=None, client_id=None, **kwargs): - keys = [] + def get_jwks( + self, iss: t.Optional[str] = None, client_id: t.Optional[str] = None, **kwargs + ): + keys: t.List[t.Mapping[str, t.Any]] = [] if iss: if self.check_iss_has_one_client(iss): reg = self.find_registration(iss) elif self.check_iss_has_many_clients(iss): + if not client_id: + raise Exception("client_id is not specified") reg = self.find_registration_by_params(iss, client_id, **kwargs) else: - raise Exception('Invalid issuer relation type') + raise Exception("Invalid issuer relation type") keys = reg.get_jwks() - return { - 'keys': keys - } + return {"keys": keys} diff --git a/pylti1p3/tool_config/dict.py b/pylti1p3/tool_config/dict.py index 6f2c6cd..fbde5e1 100644 --- a/pylti1p3/tool_config/dict.py +++ b/pylti1p3/tool_config/dict.py @@ -1,23 +1,38 @@ import typing as t - +import typing_extensions as te from ..deployment import Deployment -from ..registration import Registration +from ..registration import Registration, TKeySet from ..request import Request from .abstract import ToolConfAbstract -if t.TYPE_CHECKING: - from ..message_launch import _LaunchData - from typing_extensions import Literal - from .abstract import FIND_REG_KWARGS +TIssConf = te.TypedDict( + "TIssConf", + { + "default": bool, + "client_id": str, + "auth_login_url": str, + "auth_token_url": str, + "auth_audience": t.Optional[str], + "key_set_url": t.Optional[str], + "key_set": t.Optional[TKeySet], + "deployment_ids": t.List[str], + "private_key_file": t.Optional[str], + "public_key_file": t.Optional[str], + }, + total=False, +) + +TJsonData = t.Dict[str, t.Union[t.List[TIssConf], TIssConf]] class ToolConfDict(ToolConfAbstract[Request]): _config = None - _private_key = None # type: t.Optional[t.Mapping[str, str]] - _public_key = None # type: t.Optional[t.Mapping[str, str]] + _private_key_one_client: t.Dict[str, str] + _public_key_one_client: t.Dict[str, str] + _private_key_many_clients: t.Dict[str, t.Dict[str, str]] + _public_key_many_clients: t.Dict[str, t.Dict[str, str]] - def __init__(self, json_data): - # type: (dict) -> None + def __init__(self, json_data: TJsonData): """ json_data is a dict where each key is issuer and value is issuer's configuration. Configuration could be set in two formats: @@ -62,7 +77,7 @@ def __init__(self, json_data): key_set - in case if platform's JWKS endpoint somehow unavailable you may paste JWKS here deployment_ids (list) - The deployment_id passed by the platform during launch """ - super(ToolConfDict, self).__init__() + super().__init__() if not isinstance(json_data, dict): raise Exception("Invalid tool conf format. Must be dict") @@ -75,132 +90,164 @@ def __init__(self, json_data): for v in iss_conf: self._validate_iss_config_item(iss, v) else: - raise Exception("Invalid tool conf format. Allowed types of elements: list or dict") + raise Exception( + "Invalid tool conf format. Allowed types of elements: list or dict" + ) self._config = json_data - self._private_key = {} - self._public_key = {} - - def _validate_iss_config_item(self, iss, iss_config_item): - # type: (str, t.Any) -> None - if not isinstance(iss_config_item, dict): - raise Exception("Invalid configuration %s for the %s issuer. Must be dict" % (iss, str(iss_config_item))) - required_keys = ['auth_login_url', 'auth_token_url', 'client_id', 'deployment_ids'] + self._private_key_one_client = {} + self._private_key_many_clients = {} + self._public_key_one_client = {} + self._public_key_many_clients = {} + + def _validate_iss_config_item(self, iss: str, iss_conf: TIssConf): + if not isinstance(iss_conf, dict): + raise Exception( + f"Invalid configuration {iss} for the {str(iss_conf)} issuer. Must be dict" + ) + required_keys = [ + "auth_login_url", + "auth_token_url", + "client_id", + "deployment_ids", + ] for key in required_keys: - if key not in iss_config_item: - raise Exception("Key '%s' is missing in the %s config for the %s issuer" - % (key, str(iss_config_item), iss)) - if not isinstance(iss_config_item['deployment_ids'], list): - raise Exception("Invalid deployment_ids value in the %s config for the %s issuer. Must be a list" - % (str(iss_config_item), iss)) - - def _get_registration(self, iss, iss_conf): - # type: (str, t.Any) -> Registration + if key not in iss_conf: + raise Exception( + f"Key '{key}' is missing in the {str(iss_conf)} config for the {iss} issuer" + ) + if not isinstance(iss_conf["deployment_ids"], list): + raise Exception( + f"Invalid deployment_ids value in the {str(iss_conf)} config for the {iss} issuer. " + f"Must be a list" + ) + + def _get_registration(self, iss: str, iss_conf: TIssConf) -> Registration: reg = Registration() - reg.set_auth_login_url(iss_conf['auth_login_url'])\ - .set_auth_token_url(iss_conf['auth_token_url'])\ - .set_client_id(iss_conf['client_id'])\ - .set_key_set(iss_conf.get('key_set'))\ - .set_key_set_url(iss_conf.get('key_set_url'))\ - .set_issuer(iss)\ - .set_tool_private_key(self.get_private_key(iss, iss_conf['client_id'])) - auth_audience = iss_conf.get('auth_audience') + reg.set_auth_login_url(iss_conf["auth_login_url"]).set_auth_token_url( + iss_conf["auth_token_url"] + ).set_client_id(iss_conf["client_id"]).set_key_set( + iss_conf.get("key_set") + ).set_key_set_url( + iss_conf.get("key_set_url") + ).set_issuer( + iss + ).set_tool_private_key( + self.get_private_key(iss, iss_conf["client_id"]) + ) + auth_audience = iss_conf.get("auth_audience") if auth_audience: reg.set_auth_audience(auth_audience) - public_key = self.get_public_key(iss, iss_conf['client_id']) + public_key = self.get_public_key(iss, iss_conf["client_id"]) if public_key: reg.set_tool_public_key(public_key) return reg - def _get_deployment(self, iss_conf, deployment_id): - if deployment_id not in iss_conf['deployment_ids']: + def _get_deployment(self, iss_conf: TIssConf, deployment_id: str): + if deployment_id not in iss_conf["deployment_ids"]: return None d = Deployment() return d.set_deployment_id(deployment_id) - def find_registration_by_issuer(self, iss, *args, **kwargs): + def find_registration_by_issuer(self, iss: str, *args, **kwargs): # pylint: disable=unused-argument iss_conf = self.get_iss_config(iss) return self._get_registration(iss, iss_conf) - def find_registration_by_params(self, iss, client_id, *args, **kwargs): + def find_registration_by_params(self, iss: str, client_id: str, *args, **kwargs): # pylint: disable=unused-argument iss_conf = self.get_iss_config(iss, client_id) return self._get_registration(iss, iss_conf) - def find_deployment(self, iss, deployment_id): + def find_deployment(self, iss: str, deployment_id: str): iss_conf = self.get_iss_config(iss) return self._get_deployment(iss_conf, deployment_id) - def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwargs): + def find_deployment_by_params( + self, iss: str, deployment_id: str, client_id: str, *args, **kwargs + ): # pylint: disable=unused-argument iss_conf = self.get_iss_config(iss, client_id) return self._get_deployment(iss_conf, deployment_id) - def set_public_key(self, iss, key_content, client_id=None): + def set_public_key( + self, iss: str, key_content: str, client_id: t.Optional[str] = None + ): if self.check_iss_has_many_clients(iss): if not client_id: raise Exception("Can't set public key: missing client_id") - if iss not in self._public_key: - self._public_key[iss] = {} - self._public_key[iss][client_id] = key_content + if iss not in self._public_key_many_clients: + self._public_key_many_clients[iss] = {} + self._public_key_many_clients[iss][client_id] = key_content else: - self._public_key[iss] = key_content + self._public_key_one_client[iss] = key_content - def get_public_key(self, iss, client_id=None): + def get_public_key(self, iss: str, client_id: t.Optional[str] = None): if self.check_iss_has_many_clients(iss): if not client_id: raise Exception("Can't get public key: missing client_id") - return self._public_key.get(iss, {}).get(client_id) - else: - return self._public_key.get(iss) - - def set_private_key(self, iss, key_content, client_id=None): + clients_dict = self._public_key_many_clients.get(iss, {}) + if not isinstance(clients_dict, dict): + raise Exception("Invalid clients data") + return clients_dict.get(client_id) + return self._public_key_one_client.get(iss) + + def set_private_key( + self, iss: str, key_content: str, client_id: t.Optional[str] = None + ): if self.check_iss_has_many_clients(iss): if not client_id: raise Exception("Can't set private key: missing client_id") - if iss not in self._private_key: - self._private_key[iss] = {} - self._private_key[iss][client_id] = key_content + if iss not in self._private_key_many_clients: + self._private_key_many_clients[iss] = {} + self._private_key_many_clients[iss][client_id] = key_content # type: ignore else: - self._private_key[iss] = key_content + self._private_key_one_client[iss] = key_content - def get_private_key(self, iss, client_id=None): + def get_private_key(self, iss: str, client_id: t.Optional[str] = None): if self.check_iss_has_many_clients(iss): if not client_id: raise Exception("Can't get private key: missing client_id") - return self._private_key.get(iss, {}).get(client_id) - else: - return self._private_key.get(iss) - - def get_iss_config(self, iss, client_id=None): + clients_dict = self._private_key_many_clients.get(iss, {}) + if not isinstance(clients_dict, dict): + raise Exception("Invalid clients data") + return clients_dict.get(client_id) + return self._private_key_one_client.get(iss) + + def get_iss_config(self, iss: str, client_id: t.Optional[str] = None): + if not self._config: + raise Exception("Config is not set") if iss not in self._config: - raise Exception('iss %s not found in settings' % iss) - - if isinstance(self._config[iss], list): - items_len = len(self._config[iss]) - for subitem in self._config[iss]: - if (client_id and subitem['client_id'] == client_id)\ - or (not client_id and subitem.get('default', False))\ - or (not client_id and items_len == 1): + raise Exception(f"iss {iss} not found in settings") + config_iss = self._config[iss] + + if isinstance(config_iss, list): + items_len = len(config_iss) + for subitem in config_iss: + # pylint: disable=too-many-boolean-expressions + if ( + (client_id and subitem["client_id"] == client_id) + or (not client_id and subitem.get("default", False)) + or (not client_id and items_len == 1) + ): return subitem - raise Exception('iss %s [client_id=%s] not found in settings' % (iss, client_id)) - return self._config[iss] + raise Exception(f"iss {iss} [client_id={client_id}] not found in settings") + return config_iss - def get_jwks(self, iss=None, client_id=None, **kwargs): + def get_jwks( + self, iss: t.Optional[str] = None, client_id: t.Optional[str] = None, **kwargs + ): # pylint: disable=unused-argument if iss or client_id: - return super(ToolConfDict, self).get_jwks(iss, client_id) + return super().get_jwks(iss, client_id) public_keys = [] - for iss_item in self._public_key.values(): - if isinstance(iss_item, dict): - for pub_key in iss_item.values(): - if pub_key not in public_keys: - public_keys.append(pub_key) - else: - if iss_item not in public_keys: - public_keys.append(iss_item) - return { - 'keys': [Registration.get_jwk(k) for k in public_keys] - } + for iss_item1 in self._public_key_one_client.values(): + if iss_item1 not in public_keys: + public_keys.append(iss_item1) + for iss_item2 in self._public_key_many_clients.values(): + for pub_key in iss_item2.values(): + if pub_key not in public_keys: + public_keys.append(pub_key) + + return {"keys": [Registration.get_jwk(k) for k in public_keys]} diff --git a/pylti1p3/tool_config/json_file.py b/pylti1p3/tool_config/json_file.py index e6ad4e5..e9ceaeb 100644 --- a/pylti1p3/tool_config/json_file.py +++ b/pylti1p3/tool_config/json_file.py @@ -1,14 +1,14 @@ +import typing as t import json import os -from .dict import ToolConfDict +from .dict import ToolConfDict, TIssConf, TJsonData class ToolConfJsonFile(ToolConfDict): - _configs_dir = None + _configs_dir: str - def __init__(self, config_file): - # type: (str) -> None + def __init__(self, config_file: str): """ config_file contains JSON with issuers settings. Each key is issuer and value is issuer's configuration. @@ -64,32 +64,37 @@ def __init__(self, config_file): raise Exception("LTI tool config file not found: " + config_file) self._configs_dir = os.path.dirname(config_file) - cfg = open(config_file, 'r') - iss_conf_dict = json.loads(cfg.read()) - super(ToolConfJsonFile, self).__init__(iss_conf_dict) - cfg.close() + with open(config_file, encoding="utf-8") as cfg: + iss_conf_dict: TJsonData = json.loads(cfg.read()) + super().__init__(iss_conf_dict) for iss in iss_conf_dict: if isinstance(iss_conf_dict[iss], list): for iss_conf in iss_conf_dict[iss]: - self._process_iss_conf_item(iss_conf, iss, iss_conf['client_id']) + client_id = t.cast(TIssConf, iss_conf).get("client_id") + self._process_iss_conf_item( + t.cast(TIssConf, iss_conf), iss, client_id + ) else: - self._process_iss_conf_item(iss_conf_dict[iss], iss) + self._process_iss_conf_item(t.cast(TIssConf, iss_conf_dict[iss]), iss) - def _process_iss_conf_item(self, iss_conf, iss, client_id=None): - private_key_file = iss_conf['private_key_file'] - if not private_key_file.startswith('/'): - private_key_file = self._configs_dir + '/' + private_key_file + def _process_iss_conf_item( + self, iss_conf: TIssConf, iss: str, client_id: t.Optional[str] = None + ): + private_key_file = iss_conf.get("private_key_file") + if not private_key_file: + raise Exception("iss config error: private_key_file not found") - prf = open(private_key_file, 'r') - self.set_private_key(iss, prf.read(), client_id=client_id) - prf.close() + if not private_key_file.startswith("/"): + private_key_file = self._configs_dir + "/" + private_key_file - public_key_file = iss_conf.get('public_key_file', None) + with open(private_key_file, encoding="utf-8") as prf: + self.set_private_key(iss, prf.read(), client_id=client_id) + + public_key_file = iss_conf.get("public_key_file", None) if public_key_file: - if not public_key_file.startswith('/'): - public_key_file = self._configs_dir + '/' + public_key_file + if not public_key_file.startswith("/"): + public_key_file = self._configs_dir + "/" + public_key_file - pubf = open(public_key_file, 'r') - self.set_public_key(iss, pubf.read(), client_id=client_id) - pubf.close() + with open(public_key_file, encoding="utf-8") as pubf: + self.set_public_key(iss, pubf.read(), client_id=client_id) diff --git a/pylti1p3/utils.py b/pylti1p3/utils.py index a794e31..b8e2ed3 100644 --- a/pylti1p3/utils.py +++ b/pylti1p3/utils.py @@ -1,28 +1,10 @@ -import sys +import urllib.parse as urlparse # type: ignore +from urllib.parse import urlencode # type: ignore -try: - import urllib.parse as urlparse # type: ignore - from urllib.parse import urlencode # type: ignore -except ImportError: # python 2 fallback - # pylint: disable=ungrouped-imports - import urlparse # type: ignore - from urllib import urlencode # type: ignore -if sys.version_info > (2, ): - def encode_on_py3(arg, encoding): - # type: (str, str) -> bytes - return arg.encode(encoding) -else: - def encode_on_py3(arg, encoding): - # type: (str, str) -> bytes - # pylint: disable=unused-argument - return arg - - -def add_param_to_url(url, param_name, param_value): +def add_param_to_url(url: str, param_name: str, param_value: object) -> str: url_parts = list(urlparse.urlparse(url)) query = dict(urlparse.parse_qsl(url_parts[4])) query[str(param_name)] = str(param_value) - url_parts[4] = urlencode(query) return urlparse.urlunparse(url_parts) diff --git a/setup.cfg b/setup.cfg index 4707463..2cd4d0c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,9 +5,10 @@ universal=1 description-file = README.rst license_file = LICENSE -[pycodestyle] +[flake8] max_line_length=120 -exclude=.git,.idea,.tox,venv,venv3,env +exclude=.git,.idea,.tox,build,venv,venv3,env +extend-ignore=E203 [coverage:run] branch = True diff --git a/setup.py b/setup.py index 4000af6..4f6ba05 100644 --- a/setup.py +++ b/setup.py @@ -6,18 +6,17 @@ from pylti1p3 import __version__ -if sys.version_info < (2, 7): - error = "ERROR: PyLTI1p3 requires Python 2.7+ ... exiting." +if sys.version_info < (3, 6): + error = "ERROR: PyLTI1p3 requires Python 3.6+ ... exiting." print(error, file=sys.stderr) sys.exit(1) install_requires = [ - 'pyjwt>=1.5', - 'jwcrypto==0.9.1; python_version>="2" and python_version<"3"', - 'jwcrypto; python_version>="3"', - 'requests', - 'typing; python_version<"3.5"' + "jwcrypto", + "pyjwt>=1.5", + "requests", + "typing_extensions", ] with open("README.rst", "rt") as readme: @@ -26,32 +25,30 @@ packages = find_packages(exclude=["examples", "tests"]) setup( - name='PyLTI1p3', + name="PyLTI1p3", version=__version__, - description='LTI 1.3 Advantage Tool implementation in Python', + description="LTI 1.3 Advantage Tool implementation in Python", keywords="pylti,pylti1p3,lti,lti1.3,lti1p3,django,flask", - author='Dmitry Viskov', - author_email='dmitry.viskov@webenterprise.ru', + author="Dmitry Viskov", + author_email="dmitry.viskov@webenterprise.ru", maintainer="Dmitry Viskov", long_description=long_description, install_requires=install_requires, - license='MIT', - url='https://github.com/dmitry-viskov/pylti1.3', + license="MIT", + url="https://github.com/dmitry-viskov/pylti1.3", packages=packages, zip_safe=False, include_package_data=True, classifiers=[ "Development Status :: 5 - Production/Stable", - 'Environment :: Web Environment', + "Environment :: Web Environment", "Framework :: Django", "Framework :: Flask", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "License :: OSI Approved :: MIT License", - 'Operating System :: OS Independent', - 'Programming Language :: Python', - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.5", + "Operating System :: OS Independent", + "Programming Language :: Python", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", @@ -61,12 +58,12 @@ "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Security", "Topic :: Software Development :: Libraries :: Application Frameworks", - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - 'Topic :: Software Development :: Libraries :: Python Modules' + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development :: Libraries :: Python Modules", ], package_data={ - 'pylti1p3': ['py.typed'], - 'pylti1p3.tool_config': ['py.typed'], - 'pylti1p3.contrib': ['py.typed'], + "pylti1p3": ["py.typed"], + "pylti1p3.tool_config": ["py.typed"], + "pylti1p3.contrib": ["py.typed"], }, ) diff --git a/tests/__init__.py b/tests/__init__.py index 9b944b8..1bc98f0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .test_course_groups import TestCourseGroups from .test_deep_link import TestDjangoDeepLink, TestFlaskDeepLink from .test_grades import TestGrades @@ -5,5 +6,8 @@ from .test_resource_link import TestDjangoResourceLink, TestFlaskResourceLink from .test_tool_conf import TestToolConf from .test_privacy_launch import TestDjangoPrivacyLaunch, TestFlaskPrivacyLaunch -from .test_submission_review_launch import TestDjangoSubmissionReviewLaunch, TestFlaskSubmissionReviewLaunch +from .test_submission_review_launch import ( + TestDjangoSubmissionReviewLaunch, + TestFlaskSubmissionReviewLaunch, +) from .test_utils import TestUtils diff --git a/tests/base.py b/tests/base.py index 55ffefd..f9ea55d 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,16 +1,12 @@ import json import unittest +from unittest.mock import patch import requests_mock - -try: - from unittest.mock import patch -except ImportError: - from mock import patch from .tool_config import TOOL_CONFIG class TestLinkBase(unittest.TestCase): - iss = 'replace-me' + iss = "replace-me" get_login_data = {} post_login_data = {} @@ -20,21 +16,29 @@ def _get_launch_obj(self, request, tool_conf, cache): def _get_launch_cls(self): raise NotImplementedError - def _launch(self, request, tool_conf, key_set_url_response=None, force_validation=False, cache=False): + def _launch( + self, + request, + tool_conf, + key_set_url_response=None, + force_validation=False, + cache=False, + ): obj = self._get_launch_obj(request, tool_conf, cache=cache) - obj.set_jwt_verify_options({ - 'verify_aud': False, - 'verify_exp': False - }) + obj.set_jwt_verify_options({"verify_aud": False, "verify_exp": False}) - with patch('socket.gethostbyname', return_value="127.0.0.1"): + with patch("socket.gethostbyname", return_value="127.0.0.1"): with requests_mock.Mocker() as m: - key_set_url_text = key_set_url_response if key_set_url_response else json.dumps(self.jwt_canvas_keys) - m.get(TOOL_CONFIG[self.iss]['key_set_url'], text=key_set_url_text) + # pylint: disable=no-member + key_set_url_text = ( + key_set_url_response + if key_set_url_response + else json.dumps(self.jwt_canvas_keys) + ) + m.get(TOOL_CONFIG[self.iss]["key_set_url"], text=key_set_url_text) if force_validation: return obj.validate() - else: - return obj.get_launch_data() + return obj.get_launch_data() def _launch_with_invalid_jwt_body(self, side_effect, request, tool_conf): launch_cls = self._get_launch_cls() @@ -48,59 +52,61 @@ class TestServicesBase(unittest.TestCase): context_group_sets_url = "https://www.myuniv.example.com/2344/groups/sets" jwt_body = { - 'iss': 'https://canvas.instructure.com', - 'aud': '10000000000004', - 'sub': 'a445ca99-1a64-4697-9bfa-508a118245ea', - 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice': { - 'context_memberships_url': 'http://canvas.docker/api/lti/courses/1/names_and_roles', - 'service_versions': ['2.0'], - 'errors': {'errors': {}}, - 'validation_context': None + "iss": "https://canvas.instructure.com", + "aud": "10000000000004", + "sub": "a445ca99-1a64-4697-9bfa-508a118245ea", + "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { + "context_memberships_url": "http://canvas.docker/api/lti/courses/1/names_and_roles", + "service_versions": ["2.0"], + "errors": {"errors": {}}, + "validation_context": None, }, - 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': { - 'scope': ['https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'], - 'lineitems': 'http://canvas.docker/api/lti/courses/1/line_items', - 'errors': {'errors': {}}, - 'validation_context': None + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { + "scope": [ + "https://purl.imsglobal.org/spec/lti-ags/scope/score", + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + ], + "lineitems": "http://canvas.docker/api/lti/courses/1/line_items", + "errors": {"errors": {}}, + "validation_context": None, }, - 'https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice': { - 'scope': [ - 'https://purl.imsglobal.org/spec/lti-gs/scope/contextgroup.readonly' + "https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice": { + "scope": [ + "https://purl.imsglobal.org/spec/lti-gs/scope/contextgroup.readonly" ], - 'context_groups_url': context_groups_url, - 'context_group_sets_url': context_group_sets_url, - 'service_versions': ['1.0'] + "context_groups_url": context_groups_url, + "context_group_sets_url": context_group_sets_url, + "service_versions": ["1.0"], }, } def _get_auth_token_url(self): - return TOOL_CONFIG[self.jwt_body['iss']]['auth_token_url'] + return TOOL_CONFIG[self.jwt_body["iss"]]["auth_token_url"] def _get_auth_token_response(self): return { - 'access_token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwcz' - 'ovL2NhbnZhcy5pbnN0cnVjdHVyZS5jb20iLCJzdWI' - 'iOiIxMDAwMDAwMDAwMDAwNCIsImF1ZCI6Imh0dHA6Ly9jYW52YXMuZG' - '9ja2VyL2xvZ2luL29hdXRoMi90b2tlbiIsImlhdCI' - '6MTU2NTYwNDc3NiwiZXhwIjoxNTY1NjA4Mzc2LCJqdGkiOiIyZTg1Nz' - 'ZkYi0wODhkLTQ1ZjUtYTBhMC03YzE2NzI4NjA2Zjg' - 'iLCJzY29wZXMiOiJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcG' - 'VjL2x0aS1hZ3Mvc2NvcGUvbGluZWl0ZW0gaHR0cHM' - '6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL3Njb3BlL2' - 'xpbmVpdGVtLnJlYWRvbmx5IGh0dHBzOi8vcHVybC5' - 'pbXNnbG9iYWwub3JnL3NwZWMvbHRpLWFncy9zY29wZS9yZXN1bHQucm' - 'VhZG9ubHkgaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5' - 'vcmcvc3BlYy9sdGktYWdzL3Njb3BlL3Njb3JlIn0.GdRwhYsmxEENWY' - 'iqFOdpcKRgrHCl0Wb1-GuBb-qXqms', - 'token_type': 'Bearer', - 'expires_in': 3600, - 'scope': 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem ' - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly ' - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly ' - 'https://purl.imsglobal.org/spec/lti-ags/scope/score' + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwcz" + "ovL2NhbnZhcy5pbnN0cnVjdHVyZS5jb20iLCJzdWI" + "iOiIxMDAwMDAwMDAwMDAwNCIsImF1ZCI6Imh0dHA6Ly9jYW52YXMuZG" + "9ja2VyL2xvZ2luL29hdXRoMi90b2tlbiIsImlhdCI" + "6MTU2NTYwNDc3NiwiZXhwIjoxNTY1NjA4Mzc2LCJqdGkiOiIyZTg1Nz" + "ZkYi0wODhkLTQ1ZjUtYTBhMC03YzE2NzI4NjA2Zjg" + "iLCJzY29wZXMiOiJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcG" + "VjL2x0aS1hZ3Mvc2NvcGUvbGluZWl0ZW0gaHR0cHM" + "6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL3Njb3BlL2" + "xpbmVpdGVtLnJlYWRvbmx5IGh0dHBzOi8vcHVybC5" + "pbXNnbG9iYWwub3JnL3NwZWMvbHRpLWFncy9zY29wZS9yZXN1bHQucm" + "VhZG9ubHkgaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5" + "vcmcvc3BlYy9sdGktYWdzL3Njb3BlL3Njb3JlIn0.GdRwhYsmxEENWY" + "iqFOdpcKRgrHCl0Wb1-GuBb-qXqms", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem " + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly " + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly " + "https://purl.imsglobal.org/spec/lti-ags/scope/score", } def _get_jwt_body(self): diff --git a/tests/cache.py b/tests/cache.py index a22c85e..0d9d768 100644 --- a/tests/cache.py +++ b/tests/cache.py @@ -1,7 +1,7 @@ from pylti1p3.launch_data_storage.cache import CacheDataStorage -class Cache(object): +class Cache: _data = None def __init__(self): @@ -15,7 +15,6 @@ def set(self, key, value, exp=None): # pylint: disable=unused-argument class FakeCacheDataStorage(CacheDataStorage): - def __init__(self, *args, **kwargs): self._cache = Cache() - super(FakeCacheDataStorage, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) diff --git a/tests/django_mixin.py b/tests/django_mixin.py index 3434aed..aa6d7c9 100644 --- a/tests/django_mixin.py +++ b/tests/django_mixin.py @@ -1,36 +1,47 @@ -try: - from unittest.mock import patch -except ImportError: - from mock import patch -try: - from urllib import quote -except ImportError: - from urllib.parse import quote +from unittest.mock import patch +from urllib.parse import quote from .request import FakeRequest from .response import FakeResponse from .tool_config import get_test_tool_conf, TOOL_CONFIG -class DjangoMixin(object): +class DjangoMixin: + # pylint: disable=import-outside-toplevel - def _get_request(self, login_request, login_response, request_is_secure=False, post_data=None, - empty_session=False, empty_cookies=False): + def _get_request( + self, + login_request, + login_response, + request_is_secure=False, + post_data=None, + empty_session=False, + empty_cookies=False, + ): session = None if empty_session else login_request.session cookies = None if empty_cookies else login_response.get_cookies_dict() post_launch_data = post_data if post_data else self.post_launch_data - return FakeRequest(post=post_launch_data, - cookies=cookies, - session=session, - secure=request_is_secure) - - def _make_oidc_login(self, uuid_val=None, tool_conf_cls=None, secure=False, tool_conf_extended=False, - enable_check_cookies=False, cache=False): + return FakeRequest( + post=post_launch_data, + cookies=cookies, + session=session, + secure=request_is_secure, + ) + + def _make_oidc_login( + self, + uuid_val=None, + tool_conf_cls=None, + secure=False, + tool_conf_extended=False, + enable_check_cookies=False, + cache=False, + ): # pylint: disable=too-many-statements tool_conf = get_test_tool_conf(tool_conf_cls, tool_conf_extended) request = None login_data = {} if not uuid_val: - uuid_val = 'test-uuid-1234' + uuid_val = "test-uuid-1234" if self.get_login_data: request = FakeRequest(get=self.get_login_data, secure=secure) @@ -39,30 +50,44 @@ def _make_oidc_login(self, uuid_val=None, tool_conf_cls=None, secure=False, tool request = FakeRequest(post=self.post_login_data, secure=secure) login_data = self.post_login_data.copy() - with patch('django.shortcuts.redirect') as mock_redirect: + with patch("django.shortcuts.redirect") as mock_redirect: from pylti1p3.contrib.django import DjangoOIDCLogin - with patch.object(DjangoOIDCLogin, "_get_uuid", autospec=True) as get_uuid,\ - patch.object(DjangoOIDCLogin, "_generate_nonce", autospec=True) as generate_nonce,\ - patch.object(DjangoOIDCLogin, 'get_response', autospec=True) as get_response: - get_uuid.side_effect = lambda x: uuid_val # pylint: disable=unnecessary-lambda - generate_nonce.side_effect = lambda x: uuid_val # pylint: disable=unnecessary-lambda + + with patch.object( + DjangoOIDCLogin, "_get_uuid", autospec=True + ) as get_uuid, patch.object( + DjangoOIDCLogin, "_generate_nonce", autospec=True + ) as generate_nonce, patch.object( + DjangoOIDCLogin, "get_response", autospec=True + ) as get_response: + get_uuid.side_effect = ( + lambda x: uuid_val + ) # pylint: disable=unnecessary-lambda + generate_nonce.side_effect = ( + lambda x: uuid_val + ) # pylint: disable=unnecessary-lambda get_response.side_effect = lambda y, html: html oidc_login = DjangoOIDCLogin(request, tool_conf) if cache: oidc_login.set_launch_data_storage(cache) - mock_redirect.side_effect = lambda x: FakeResponse(x) # pylint: disable=unnecessary-lambda - launch_url = 'http://lti.django.test/launch/' + # pylint: disable=unnecessary-lambda + mock_redirect.side_effect = lambda x: FakeResponse(x) + launch_url = "http://lti.django.test/launch/" if enable_check_cookies: - response_html = oidc_login.enable_check_cookies().redirect(launch_url) + response_html = oidc_login.enable_check_cookies().redirect( + launch_url + ) self.assertTrue('')) + self.assertTrue( + html.endswith( + '" + ) + ) class TestDjangoDeepLink(DjangoMixin, DeepLinkBase): diff --git a/tests/test_grades.py b/tests/test_grades.py index bd452b8..e83a0d4 100644 --- a/tests/test_grades.py +++ b/tests/test_grades.py @@ -1,10 +1,7 @@ import datetime import json +from unittest.mock import patch import requests_mock -try: - from unittest.mock import patch -except ImportError: - from mock import patch from parameterized import parameterized from pylti1p3.grade import Grade from pylti1p3.lineitem import LineItem @@ -14,110 +11,155 @@ class TestGrades(TestServicesBase): - - @parameterized.expand([['line_items_exist', True], ['line_items_dont_exist', False]]) - def test_get_grades(self, name, line_items_exist): # pylint: disable=unused-argument + # pylint: disable=import-outside-toplevel + + @parameterized.expand( + [["line_items_exist", True], ["line_items_dont_exist", False]] + ) + def test_get_grades( + self, name, line_items_exist + ): # pylint: disable=unused-argument from pylti1p3.contrib.django import DjangoMessageLaunch + tool_conf = get_test_tool_conf() - with patch.object(DjangoMessageLaunch, "_get_jwt_body", autospec=True) as get_jwt_body: + with patch.object( + DjangoMessageLaunch, "_get_jwt_body", autospec=True + ) as get_jwt_body: message_launch = DjangoMessageLaunch(FakeRequest(), tool_conf) - line_items_url = 'http://canvas.docker/api/lti/courses/1/line_items' + line_items_url = "http://canvas.docker/api/lti/courses/1/line_items" get_jwt_body.side_effect = lambda x: self._get_jwt_body() - with patch('socket.gethostbyname', return_value="127.0.0.1"): + with patch("socket.gethostbyname", return_value="127.0.0.1"): with requests_mock.Mocker() as m: - m.post(self._get_auth_token_url(), text=json.dumps(self._get_auth_token_response())) + m.post( + self._get_auth_token_url(), + text=json.dumps(self._get_auth_token_response()), + ) line_items_response = [] if line_items_exist: - line_items_response = [{ - 'scoreMaximum': 100.0, - 'tag': 'score', - 'id': 'http://canvas.docker/api/lti/courses/1/line_items/1', - 'label': 'Score' - }, { - 'scoreMaximum': 999.0, - 'tag': 'time', - 'id': 'http://canvas.docker/api/lti/courses/1/line_items/2', - 'label': 'Time Taken' - }] + line_items_response = [ + { + "scoreMaximum": 100.0, + "tag": "score", + "id": "http://canvas.docker/api/lti/courses/1/line_items/1", + "label": "Score", + }, + { + "scoreMaximum": 999.0, + "tag": "time", + "id": "http://canvas.docker/api/lti/courses/1/line_items/2", + "label": "Time Taken", + }, + ] else: - m.post(line_items_url, text=json.dumps({ - 'scoreMaximum': 100.0, - 'tag': 'score', - 'id': 'http://canvas.docker/api/lti/courses/1/line_items/1', - 'label': 'Score' - })) + m.post( + line_items_url, + text=json.dumps( + { + "scoreMaximum": 100.0, + "tag": "score", + "id": "http://canvas.docker/api/lti/courses/1/line_items/1", + "label": "Score", + } + ), + ) m.get(line_items_url, text=json.dumps(line_items_response)) - m.get('http://canvas.docker/api/lti/courses/1/line_items/1/results', - text=json.dumps([{ - 'resultScore': 13.0, - 'resultMaximum': 100.0, - 'userId': '20eb59f5-26e8-46bc-87b0-57ed54820aeb', - 'id': 'http://canvas.docker/api/lti/courses/1/line_items/1/results/1', - 'scoreOf': 'http://canvas.docker/api/lti/courses/1/line_items/1' - }])) + m.get( + "http://canvas.docker/api/lti/courses/1/line_items/1/results", + text=json.dumps( + [ + { + "resultScore": 13.0, + "resultMaximum": 100.0, + "userId": "20eb59f5-26e8-46bc-87b0-57ed54820aeb", + "id": "http://canvas.docker/api/lti/courses/1/line_items/1/results/1", + "scoreOf": "http://canvas.docker/api/lti/courses/1/line_items/1", + } + ] + ), + ) ags = message_launch.validate_registration().get_ags() score_line_item = LineItem() - score_line_item.set_tag('score') \ - .set_score_maximum(100) \ - .set_label('Score') + score_line_item.set_tag("score").set_score_maximum(100).set_label( + "Score" + ) line_item = ags.find_or_create_lineitem(score_line_item) self.assertIsNotNone(line_item) scores = ags.get_grades(line_item) self.assertEqual(len(scores), 1) - self.assertDictEqual(scores[0], { - 'resultScore': 13.0, - 'resultMaximum': 100.0, - 'userId': '20eb59f5-26e8-46bc-87b0-57ed54820aeb', - 'id': 'http://canvas.docker/api/lti/courses/1/line_items/1/results/1', - 'scoreOf': 'http://canvas.docker/api/lti/courses/1/line_items/1' - }) + self.assertDictEqual( + scores[0], + { + "resultScore": 13.0, + "resultMaximum": 100.0, + "userId": "20eb59f5-26e8-46bc-87b0-57ed54820aeb", + "id": "http://canvas.docker/api/lti/courses/1/line_items/1/results/1", + "scoreOf": "http://canvas.docker/api/lti/courses/1/line_items/1", + }, + ) def test_send_scores(self): from pylti1p3.contrib.django import DjangoMessageLaunch + tool_conf = get_test_tool_conf() - with patch.object(DjangoMessageLaunch, "_get_jwt_body", autospec=True) as get_jwt_body: + with patch.object( + DjangoMessageLaunch, "_get_jwt_body", autospec=True + ) as get_jwt_body: message_launch = DjangoMessageLaunch(FakeRequest(), tool_conf) get_jwt_body.side_effect = lambda x: self._get_jwt_body() - with patch('socket.gethostbyname', return_value="127.0.0.1"): + with patch("socket.gethostbyname", return_value="127.0.0.1"): with requests_mock.Mocker() as m: - m.post(self._get_auth_token_url(), text=json.dumps(self._get_auth_token_response())) - m.get('http://canvas.docker/api/lti/courses/1/line_items', - text=json.dumps([{ - 'scoreMaximum': 100.0, - 'tag': 'score', - 'id': 'http://canvas.docker/api/lti/courses/1/line_items/1', - 'label': 'Score' - }])) + m.post( + self._get_auth_token_url(), + text=json.dumps(self._get_auth_token_response()), + ) + m.get( + "http://canvas.docker/api/lti/courses/1/line_items", + text=json.dumps( + [ + { + "scoreMaximum": 100.0, + "tag": "score", + "id": "http://canvas.docker/api/lti/courses/1/line_items/1", + "label": "Score", + } + ] + ), + ) expected_result = { - 'resultUrl': 'http://canvas.docker/api/lti/courses/1/line_items/1/results/4' + "resultUrl": "http://canvas.docker/api/lti/courses/1/line_items/1/results/4" } - m.post('http://canvas.docker/api/lti/courses/1/line_items/1/scores', - text=json.dumps(expected_result)) + m.post( + "http://canvas.docker/api/lti/courses/1/line_items/1/scores", + text=json.dumps(expected_result), + ) ags = message_launch.validate_registration().get_ags() - sub = message_launch.get_launch_data().get('sub') + sub = message_launch.get_launch_data().get("sub") - timestamp = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S+0000') + timestamp = datetime.datetime.utcnow().strftime( + "%Y-%m-%dT%H:%M:%S+0000" + ) sc = Grade() - sc.set_score_given(5) \ - .set_score_maximum(100) \ - .set_timestamp(timestamp) \ - .set_activity_progress('Completed') \ - .set_grading_progress('FullyGraded') \ - .set_user_id(sub) + sc.set_score_given(5).set_score_maximum(100).set_timestamp( + timestamp + ).set_activity_progress("Completed").set_grading_progress( + "FullyGraded" + ).set_user_id( + sub + ) sc_line_item = LineItem() - sc_line_item.set_tag('score') \ - .set_score_maximum(100) \ - .set_label('Score') + sc_line_item.set_tag("score").set_score_maximum(100).set_label( + "Score" + ) resp = ags.put_grade(sc, sc_line_item) - self.assertEqual(expected_result, resp['body']) + self.assertEqual(expected_result, resp["body"]) diff --git a/tests/test_names_roles.py b/tests/test_names_roles.py index f3b8f92..f3d8661 100644 --- a/tests/test_names_roles.py +++ b/tests/test_names_roles.py @@ -1,66 +1,89 @@ import json +from unittest.mock import patch import requests_mock -try: - from unittest.mock import patch -except ImportError: - from mock import patch from .request import FakeRequest from .tool_config import get_test_tool_conf from .base import TestServicesBase class TestNamesRolesProvisioningService(TestServicesBase): - def test_get_members(self): + # pylint: disable=import-outside-toplevel from pylti1p3.contrib.django import DjangoMessageLaunch + tool_conf = get_test_tool_conf() - with patch.object(DjangoMessageLaunch, "_get_jwt_body", autospec=True) as get_jwt_body: + with patch.object( + DjangoMessageLaunch, "_get_jwt_body", autospec=True + ) as get_jwt_body: message_launch = DjangoMessageLaunch(FakeRequest(), tool_conf) get_jwt_body.side_effect = lambda x: self._get_jwt_body() - with patch('socket.gethostbyname', return_value="127.0.0.1"): + with patch("socket.gethostbyname", return_value="127.0.0.1"): with requests_mock.Mocker() as m: - m.post(self._get_auth_token_url(), text=json.dumps(self._get_auth_token_response())) - m.get(self._get_jwt_body()['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'] - ['context_memberships_url'], - text=json.dumps({ - 'members': [{ - 'status': 'Active', - 'user_id': '20eb59f5-26e8-46bc-87b0-57ed54820aeb', - 'roles': ['http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'] - }], - 'id': 'http://canvas.docker/api/lti/courses/1/names_and_roles', - 'context': { - 'title': 'Test', - 'id': '4dde05e8ca1973bcca9bffc13e1548820eee93a3', - 'label': 'Test'} - }), - headers={ - 'Status': '200 OK', - 'X-Request-Context-Id': 'fb3662e8-527c-4c83-b4d5-b32d247a896c', - 'X-XSS-Protection': '1; mode=block', 'X-Content-Type-Options': 'nosniff', - 'Transfer-Encoding': 'chunked', - 'X-Rate-Limit-Remaining': '600.0', - 'X-Runtime': '0.235817', - 'Server': 'nginx/1.13.5', - 'X-Canvas-Meta': 'o=lti/ims/names_and_roles;n=course_index;t=Course;i=1;b=892132;' - 'm=892132;u=0.08;y=0.00;d=0.01;', - 'Connection': 'keep-alive', 'ETag': 'W/"a198e0b4e31245287ba175ddf5a9223c"', - 'X-Request-Cost': '0.09043669500000007', 'X-UA-Compatible': 'IE=Edge,chrome=1', - 'Cache-Control': 'max-age=0, private, must-revalidate', - 'Date': 'Mon, 12 Aug 2019 09:50:45 GMT', - 'Link': '; rel="current",; rel="first",' - '; rel="last"', - 'X-Frame-Options': 'SAMEORIGIN', - 'Content-Type': 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json; charset=utf-8' - }) - members = message_launch.validate_registration().get_nrps().get_members() + m.post( + self._get_auth_token_url(), + text=json.dumps(self._get_auth_token_response()), + ) + m.get( + self._get_jwt_body()[ + "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice" + ]["context_memberships_url"], + text=json.dumps( + { + "members": [ + { + "status": "Active", + "user_id": "20eb59f5-26e8-46bc-87b0-57ed54820aeb", + "roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner" + ], + } + ], + "id": "http://canvas.docker/api/lti/courses/1/names_and_roles", + "context": { + "title": "Test", + "id": "4dde05e8ca1973bcca9bffc13e1548820eee93a3", + "label": "Test", + }, + } + ), + headers={ + "Status": "200 OK", + "X-Request-Context-Id": "fb3662e8-527c-4c83-b4d5-b32d247a896c", + "X-XSS-Protection": "1; mode=block", + "X-Content-Type-Options": "nosniff", + "Transfer-Encoding": "chunked", + "X-Rate-Limit-Remaining": "600.0", + "X-Runtime": "0.235817", + "Server": "nginx/1.13.5", + "X-Canvas-Meta": "o=lti/ims/names_and_roles;n=course_index;t=Course;i=1;b=892132;" + "m=892132;u=0.08;y=0.00;d=0.01;", + "Connection": "keep-alive", + "ETag": 'W/"a198e0b4e31245287ba175ddf5a9223c"', + "X-Request-Cost": "0.09043669500000007", + "X-UA-Compatible": "IE=Edge,chrome=1", + "Cache-Control": "max-age=0, private, must-revalidate", + "Date": "Mon, 12 Aug 2019 09:50:45 GMT", + "Link": "; rel="current",; rel="first",' + "; rel="last"', + "X-Frame-Options": "SAMEORIGIN", + "Content-Type": "application/vnd.ims.lti-nrps.v2.membershipcontainer+json; charset=utf-8", + }, + ) + members = ( + message_launch.validate_registration().get_nrps().get_members() + ) self.assertEqual(len(members), 1) - self.assertDictEqual(members[0], { - 'status': 'Active', - 'user_id': '20eb59f5-26e8-46bc-87b0-57ed54820aeb', - 'roles': ['http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'] - }) + self.assertDictEqual( + members[0], + { + "status": "Active", + "user_id": "20eb59f5-26e8-46bc-87b0-57ed54820aeb", + "roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner" + ], + }, + ) diff --git a/tests/test_privacy_launch.py b/tests/test_privacy_launch.py index aa07bc0..75ad301 100644 --- a/tests/test_privacy_launch.py +++ b/tests/test_privacy_launch.py @@ -4,9 +4,9 @@ class PrivacyLaunchBase(TestLinkBase): - # pylint: disable=abstract-method + # pylint: disable=abstract-method,no-member - iss = 'https://canvas.instructure.com' + iss = "https://canvas.instructure.com" jwt_canvas_keys = { "keys": [ { @@ -14,71 +14,71 @@ class PrivacyLaunchBase(TestLinkBase): "kid": "NtQYzsKs_TWLQ0p3bLmfM7fOwY0nEBVVH3z3Q-zJ06Y", "kty": "RSA", "n": "uvEnCaUOy1l9gk3wjW3Pib1dBc5g92-6rhvZZOsN1a77fdOqKsrjWG1lDu8kq2nL-wbAzR3DdEPVw" - "_1WUwtr_Q1d5m-7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS-ujLfsRc2Ime" - "LP5OHxpE1yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT_f7iK47Mp2iQd4KYYG7YZ7lMMP" - "CMBuhej7SOtZQ2FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6aD2-HUxYztVIPCkhyO84mQ7W4BFsOnKW" - "4WRfEySHXd2hZkFMgcFNXY3dA6de519qlcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5" - "jWYBg0ILgULo7ve7VXG5lMwasW_ch2zKp7tTILnDJwITMjF71h4fn4dMTun_7MWEtSl_iFiALnIL_" - "4_YY717cr4rmcG1424LyxJGRD9L9WjO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdVRH7qN_e" - "rfGHmykmVGgH8AfK9GLT_cjN4GHA29bK9jMed6SWdrkygbQmlnsCAHrw0RA-QE0t617h3uTrSEr5v" - "kbLz-KThVEBfH84qsweqcac_unKIZ0e2iRuyVnG4cbq8HUdio8gJ62D3wZ0UvVgr4a0", + "_1WUwtr_Q1d5m-7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS-ujLfsRc2Ime" + "LP5OHxpE1yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT_f7iK47Mp2iQd4KYYG7YZ7lMMP" + "CMBuhej7SOtZQ2FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6aD2-HUxYztVIPCkhyO84mQ7W4BFsOnKW" + "4WRfEySHXd2hZkFMgcFNXY3dA6de519qlcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5" + "jWYBg0ILgULo7ve7VXG5lMwasW_ch2zKp7tTILnDJwITMjF71h4fn4dMTun_7MWEtSl_iFiALnIL_" + "4_YY717cr4rmcG1424LyxJGRD9L9WjO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdVRH7qN_e" + "rfGHmykmVGgH8AfK9GLT_cjN4GHA29bK9jMed6SWdrkygbQmlnsCAHrw0RA-QE0t617h3uTrSEr5v" + "kbLz-KThVEBfH84qsweqcac_unKIZ0e2iRuyVnG4cbq8HUdio8gJ62D3wZ0UvVgr4a0", "alg": "RS256", - "use": "sig" + "use": "sig", } ] } post_login_data = { - 'iss': iss, - 'login_hint': '86157096483e6b3a50bfedc6bac902c0b20a824f', - 'target_link_uri': 'http://lti.django.test/launch/', - 'lti_message_hint': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJpZmllciI6Ijg0NjMxZjc1Z' - 'GYxNmNiZjNmYTM5YzEwMzk4YTg0M2U1NTAwZTc5MTU2OTBhN2RjYTJhNGMzMTJjYjR' - 'jOWU0YWY5NzE2MWVhYjg4ODhmOWJlNDc2MmViNzUzZDE5ZmI3YWU5N2I2MjAxZWZjM' - 'jRmODY4NWE3NjJmY2U0ZWU4MDk4IiwiY2FudmFzX2RvbWFpbiI6ImNhbnZhcy5kb2N' - 'rZXIiLCJjb250ZXh0X3R5cGUiOiJDb3Vyc2UiLCJjb250ZXh0X2lkIjoxMDAwMDAwM' - 'DAwMDAwMSwiZXhwIjoxNTY1NDQyMzcwfQ.B1Lddgthaa-YBT4-Lkm3OM_noETl3dIz' - '5E14YWJ8m_Q' + "iss": iss, + "login_hint": "86157096483e6b3a50bfedc6bac902c0b20a824f", + "target_link_uri": "http://lti.django.test/launch/", + "lti_message_hint": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJpZmllciI6Ijg0NjMxZjc1Z" + "GYxNmNiZjNmYTM5YzEwMzk4YTg0M2U1NTAwZTc5MTU2OTBhN2RjYTJhNGMzMTJjYjR" + "jOWU0YWY5NzE2MWVhYjg4ODhmOWJlNDc2MmViNzUzZDE5ZmI3YWU5N2I2MjAxZWZjM" + "jRmODY4NWE3NjJmY2U0ZWU4MDk4IiwiY2FudmFzX2RvbWFpbiI6ImNhbnZhcy5kb2N" + "rZXIiLCJjb250ZXh0X3R5cGUiOiJDb3Vyc2UiLCJjb250ZXh0X2lkIjoxMDAwMDAwM" + "DAwMDAwMSwiZXhwIjoxNTY1NDQyMzcwfQ.B1Lddgthaa-YBT4-Lkm3OM_noETl3dIz" + "5E14YWJ8m_Q", } post_launch_data = { - 'utf8': '%E2%9C%93', - 'authenticity_token': 'oOOlsiqy2nFHP5wgWIKWSEoHKYDZg0u%2BCRKC3BWuFsORmeT2HMC%2BASxQzEoW0' - 'KdnfnZe6ovmOe9gVOqYPth5mw%3D%3D', - 'state': 'state-test-uuid-1234', - 'id_token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik50UVl6c0tzX1RXTFEwcDNiTG1mTTdmT3dZMG5FQlZWSDN6M1' - 'EtekowNlkifQ.eyJpc3MiOiJodHRwczovL2NhbnZhcy5pbnN0cnVjdHVyZS5jb20iLCJzdWIiOiJhNmQ1YzQ0My0xZjUxL' - 'TQ3ODMtYmExYS03Njg2ZmZlM2I1NGEiLCJhdWQiOiIxMDAwMDAwMDAwMDAwNCIsImV4cCI6MTU2NTQ0NTY3MCwiaWF0Ijo' - 'xNTY1NDQyMDcwLCJub25jZSI6InRlc3QtdXVpZC0xMjM0IiwibmFtZSI6Ik1zIEphbmUgTWFyaWUgRG9lIiwiZ2l2ZW5fb' - 'mFtZSI6IkphbmUiLCJmYW1pbHlfbmFtZSI6IkRvZSIsImVtYWlsIjoiamFuZUBleGFtcGxlLm9yZyIsImh0dHBzOi8vcHV' - 'ybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2RlcGxveW1lbnRfaWQiOiI2Ojg4NjVhYTA1YjRiNzliNjRhOTFhO' - 'DYwNDJlNDNhZjVlYThhZTc5ZWIiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWd' - 'lX3R5cGUiOiJEYXRhUHJpdmFjeUxhdW5jaFJlcXVlc3QiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0a' - 'S9jbGFpbS92ZXJzaW9uIjoiMS4zLjAiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9yb2x' - 'lcyI6WyJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3ZvY2FiL2xpcy92Mi9zeXN0ZW0vcGVyc29uI0FkbWluaXN0cmF0b' - '3IiXSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vZm9yX3VzZXIiOnsiaWQiOiI4ZjA0MWQ' - '5NC05OTQzLTQ2NmItOWRlYi1hNjkyYTZiODVjMDIiLCJwZXJzb25fc291cmNlZGlkIjoiZXhhbXBsZS5lZHU6NzFlZTdlN' - 'DItZjZkMi00MTRhLTgwZGItYjY5YWMyZGVmZDQiLCJnaXZlbl9uYW1lIjoiSnVkZSIsImZhbWlseV9uYW1lIjoiV2lsYmV' - 'ydCIsImVtYWlsIjoiandpbGJlcnRAZXhhbXBsZS5vcmciLCJyb2xlcyI6WyJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL' - '3ZvY2FiL2xpcy92Mi9zeXN0ZW0vcGVyc29uI1VzZXIiXX0sImxvY2FsZSI6ImVuLVVTIiwiaHR0cHM6Ly9wdXJsLmltc2d' - 'sb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vdG9vbF9wbGF0Zm9ybSI6eyJuYW1lIjoiRXhhbXBsZSBQbGF0Zm9ybSIsImRlc' - '2NyaXB0aW9uIjoiUHJvdmlkZXMgYW4gZXhhbXBsZSBvZiBhIHBsYXRmb3JtLiIsImd1aWQiOiIxYjc2M2E4Yy0wZjkxLTQ' - '2MTUtYmE0Ni1iYzNkNzc2Y2E3ZjgiLCJwcm9kdWN0X2ZhbWlseV9jb2RlIjoiRXhhbXBsZVBsYXRmb3JtIiwidmVyc2lvb' - 'iI6IjEuMC4wLjYiLCJ1cmwiOiJodHRwczovL3BsYXRmb3JtLmV4YW1wbGUub3JnIiwiY29udGFjdF9lbWFpbCI6InNvbWV' - 'vbmVAcGxhdGZvcm0uZXhhbXBsZS5vcmcifSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vY' - '3VzdG9tIjp7IlNvbWVfY3VzdG9tX3NldHRpbmciOiJhX3ZhbHVlMSJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9' - 'zcGVjL2x0aS9jbGFpbS9saXMiOnsicGVyc29uX3NvdXJjZWRpZCI6ImV4YW1wbGUuZWR1OmI2YjkzMTA1LThkMmYtNGFmO' - 'C05M2VjLTM2YzA1MGI5ODQxMyJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9sYXVuY2h' - 'fcHJlc2VudGF0aW9uIjp7InJldHVybl91cmwiOiJodHRwczovL3BsYXRmb3JtLmV4YW1wbGUub3JnL2x0aS9yZXR1cm4if' - 'SwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vdGFyZ2V0X2xpbmtfdXJpIjoiaHR0cHM6Ly9' - 'wbGF0Zm9ybS5leGFtcGxlLm9yZy9sdGkvcHJpdmFjeSJ9.d3NSZd283wbzzyWLU9i3EHSnNfg1t8J9wDkQXEyPebA9Hnnn' - '6jsfj0li8BD8_3Q8-sLczLUFEWVDNl6J9U2gtLaGa2gW809Xw9dJ-tQgBgH3bVqRPfcIPVQ_qA5xspXx_lZVp2In4LZUPl' - 'Qh4l81yaIrXauoOsLrtIpqrnsExD1Xva0xtWmCOD-KrTgN4EDWrSj0Tf23siSyjVTRW_ha84zH9WWR4cbXfA2nqzbjE_-5' - '0pIrzdCI9i5E23q76XEpDO-uxjfWaSo2KdZBkGibQJDPdyWtam1sS5EqbMe0kQBg-5frH-vpaRfkYJ0GWLKULUsaBYYQM-' - 'GG3wVmg4F86Oxvwb0fGOO6lGQx9arqoUlkCPCAmvmg7wuK7dBF7a2KZBwa1LYxtAFetQbdQs5FhLKFiQFw7IxbDza4A1aP' - 'N-DHwMZxFQT4nfewuC-bhU4TmYkbxO3csDYtk5ng97EY3JxaFIXYqUj9w0W8y7Dj_1qmQIVIc_pQU594YZCSbl06DWGCD1' - 'uKyJu0PRj6Sa3T3hG6ecqvQaf6V_OwgEd-u_f6qXbmUYstb9l8sW3yWcB6PH7-OC-I9Ttoy4vSKkPQRZ-9Tu2LZlXjMnRF' - 'odIoYA-LekMYldb0sabYT4yw1pmXAo5dyMfGSHIjJ21-xAiaqFTpfQ5b3fgmIlv-oZYda1c' + "utf8": "%E2%9C%93", + "authenticity_token": "oOOlsiqy2nFHP5wgWIKWSEoHKYDZg0u%2BCRKC3BWuFsORmeT2HMC%2BASxQzEoW0" + "KdnfnZe6ovmOe9gVOqYPth5mw%3D%3D", + "state": "state-test-uuid-1234", + "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik50UVl6c0tzX1RXTFEwcDNiTG1mTTdmT3dZMG5FQlZWSDN6M1" + "EtekowNlkifQ.eyJpc3MiOiJodHRwczovL2NhbnZhcy5pbnN0cnVjdHVyZS5jb20iLCJzdWIiOiJhNmQ1YzQ0My0xZjUxL" + "TQ3ODMtYmExYS03Njg2ZmZlM2I1NGEiLCJhdWQiOiIxMDAwMDAwMDAwMDAwNCIsImV4cCI6MTU2NTQ0NTY3MCwiaWF0Ijo" + "xNTY1NDQyMDcwLCJub25jZSI6InRlc3QtdXVpZC0xMjM0IiwibmFtZSI6Ik1zIEphbmUgTWFyaWUgRG9lIiwiZ2l2ZW5fb" + "mFtZSI6IkphbmUiLCJmYW1pbHlfbmFtZSI6IkRvZSIsImVtYWlsIjoiamFuZUBleGFtcGxlLm9yZyIsImh0dHBzOi8vcHV" + "ybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2RlcGxveW1lbnRfaWQiOiI2Ojg4NjVhYTA1YjRiNzliNjRhOTFhO" + "DYwNDJlNDNhZjVlYThhZTc5ZWIiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWd" + "lX3R5cGUiOiJEYXRhUHJpdmFjeUxhdW5jaFJlcXVlc3QiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0a" + "S9jbGFpbS92ZXJzaW9uIjoiMS4zLjAiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9yb2x" + "lcyI6WyJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3ZvY2FiL2xpcy92Mi9zeXN0ZW0vcGVyc29uI0FkbWluaXN0cmF0b" + "3IiXSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vZm9yX3VzZXIiOnsiaWQiOiI4ZjA0MWQ" + "5NC05OTQzLTQ2NmItOWRlYi1hNjkyYTZiODVjMDIiLCJwZXJzb25fc291cmNlZGlkIjoiZXhhbXBsZS5lZHU6NzFlZTdlN" + "DItZjZkMi00MTRhLTgwZGItYjY5YWMyZGVmZDQiLCJnaXZlbl9uYW1lIjoiSnVkZSIsImZhbWlseV9uYW1lIjoiV2lsYmV" + "ydCIsImVtYWlsIjoiandpbGJlcnRAZXhhbXBsZS5vcmciLCJyb2xlcyI6WyJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL" + "3ZvY2FiL2xpcy92Mi9zeXN0ZW0vcGVyc29uI1VzZXIiXX0sImxvY2FsZSI6ImVuLVVTIiwiaHR0cHM6Ly9wdXJsLmltc2d" + "sb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vdG9vbF9wbGF0Zm9ybSI6eyJuYW1lIjoiRXhhbXBsZSBQbGF0Zm9ybSIsImRlc" + "2NyaXB0aW9uIjoiUHJvdmlkZXMgYW4gZXhhbXBsZSBvZiBhIHBsYXRmb3JtLiIsImd1aWQiOiIxYjc2M2E4Yy0wZjkxLTQ" + "2MTUtYmE0Ni1iYzNkNzc2Y2E3ZjgiLCJwcm9kdWN0X2ZhbWlseV9jb2RlIjoiRXhhbXBsZVBsYXRmb3JtIiwidmVyc2lvb" + "iI6IjEuMC4wLjYiLCJ1cmwiOiJodHRwczovL3BsYXRmb3JtLmV4YW1wbGUub3JnIiwiY29udGFjdF9lbWFpbCI6InNvbWV" + "vbmVAcGxhdGZvcm0uZXhhbXBsZS5vcmcifSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vY" + "3VzdG9tIjp7IlNvbWVfY3VzdG9tX3NldHRpbmciOiJhX3ZhbHVlMSJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9" + "zcGVjL2x0aS9jbGFpbS9saXMiOnsicGVyc29uX3NvdXJjZWRpZCI6ImV4YW1wbGUuZWR1OmI2YjkzMTA1LThkMmYtNGFmO" + "C05M2VjLTM2YzA1MGI5ODQxMyJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9sYXVuY2h" + "fcHJlc2VudGF0aW9uIjp7InJldHVybl91cmwiOiJodHRwczovL3BsYXRmb3JtLmV4YW1wbGUub3JnL2x0aS9yZXR1cm4if" + "SwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vdGFyZ2V0X2xpbmtfdXJpIjoiaHR0cHM6Ly9" + "wbGF0Zm9ybS5leGFtcGxlLm9yZy9sdGkvcHJpdmFjeSJ9.d3NSZd283wbzzyWLU9i3EHSnNfg1t8J9wDkQXEyPebA9Hnnn" + "6jsfj0li8BD8_3Q8-sLczLUFEWVDNl6J9U2gtLaGa2gW809Xw9dJ-tQgBgH3bVqRPfcIPVQ_qA5xspXx_lZVp2In4LZUPl" + "Qh4l81yaIrXauoOsLrtIpqrnsExD1Xva0xtWmCOD-KrTgN4EDWrSj0Tf23siSyjVTRW_ha84zH9WWR4cbXfA2nqzbjE_-5" + "0pIrzdCI9i5E23q76XEpDO-uxjfWaSo2KdZBkGibQJDPdyWtam1sS5EqbMe0kQBg-5frH-vpaRfkYJ0GWLKULUsaBYYQM-" + "GG3wVmg4F86Oxvwb0fGOO6lGQx9arqoUlkCPCAmvmg7wuK7dBF7a2KZBwa1LYxtAFetQbdQs5FhLKFiQFw7IxbDza4A1aP" + "N-DHwMZxFQT4nfewuC-bhU4TmYkbxO3csDYtk5ng97EY3JxaFIXYqUj9w0W8y7Dj_1qmQIVIc_pQU594YZCSbl06DWGCD1" + "uKyJu0PRj6Sa3T3hG6ecqvQaf6V_OwgEd-u_f6qXbmUYstb9l8sW3yWcB6PH7-OC-I9Ttoy4vSKkPQRZ-9Tu2LZlXjMnRF" + "odIoYA-LekMYldb0sabYT4yw1pmXAo5dyMfGSHIjJ21-xAiaqFTpfQ5b3fgmIlv-oZYda1c", } expected_message_launch_data = { @@ -104,9 +104,7 @@ class PrivacyLaunchBase(TestLinkBase): "given_name": "Jude", "family_name": "Wilbert", "email": "jwilbert@example.org", - "roles": [ - "http://purl.imsglobal.org/vocab/lis/v2/system/person#User" - ] + "roles": ["http://purl.imsglobal.org/vocab/lis/v2/system/person#User"], }, "locale": "en-US", "https://purl.imsglobal.org/spec/lti/claim/tool_platform": { @@ -116,7 +114,7 @@ class PrivacyLaunchBase(TestLinkBase): "product_family_code": "ExamplePlatform", "version": "1.0.0.6", "url": "https://platform.example.org", - "contact_email": "someone@platform.example.org" + "contact_email": "someone@platform.example.org", }, "https://purl.imsglobal.org/spec/lti/claim/custom": { "Some_custom_setting": "a_value1" @@ -127,19 +125,24 @@ class PrivacyLaunchBase(TestLinkBase): "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { "return_url": "https://platform.example.org/lti/return" }, - "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "https://platform.example.org/lti/privacy" + "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "https://platform.example.org/lti/privacy", } def test_privacy_launch_success(self): tool_conf, login_request, login_response = self._make_oidc_login() launch_request = self._get_request(login_request, login_response) - validated_message_launch = self._launch(launch_request, tool_conf, force_validation=True) + validated_message_launch = self._launch( + launch_request, tool_conf, force_validation=True + ) message_launch_data = validated_message_launch.get_launch_data() self.assertDictEqual(message_launch_data, self.expected_message_launch_data) self.assertTrue(validated_message_launch.is_data_privacy_launch()) self.assertDictEqual( validated_message_launch.get_data_privacy_launch_user(), - self.expected_message_launch_data.get('https://purl.imsglobal.org/spec/lti/claim/for_user')) + self.expected_message_launch_data.get( + "https://purl.imsglobal.org/spec/lti/claim/for_user" + ), + ) class TestDjangoPrivacyLaunch(DjangoMixin, PrivacyLaunchBase): diff --git a/tests/test_resource_link.py b/tests/test_resource_link.py index f2132ba..cb669c4 100644 --- a/tests/test_resource_link.py +++ b/tests/test_resource_link.py @@ -4,178 +4,209 @@ from .cache import FakeCacheDataStorage from .django_mixin import DjangoMixin from .flask_mixin import FlaskMixin -from .tool_config import ToolConfDeprecated class ResourceLinkBase(TestLinkBase): - # pylint: disable=abstract-method + # pylint: disable=abstract-method,no-member - iss = 'https://canvas.instructure.com' + iss = "https://canvas.instructure.com" jwt_canvas_keys = { "keys": [ { "kty": "RSA", "e": "AQAB", "n": "uX1MpfEMQCBUMcj0sBYI-iFaG5Nodp3C6OlN8uY60fa5zSBd83-iIL3n_qzZ8VCluuTLfB7rrV_tiX727XIEqQ", - "kid": "2018-05-18T22:33:20Z" - }, { + "kid": "2018-05-18T22:33:20Z", + }, + { "kty": "RSA", "e": "AQAB", "n": "uX1MpfEMQCBUMcj0sBYI-iFaG5Nodp3C6OlN8uY60fa5zSBd83-iIL3n_qzZ8VCluuTLfB7rrV_tiX727XIEqQ", - "kid": "2018-06-18T22:33:20Z" - }, { + "kid": "2018-06-18T22:33:20Z", + }, + { "kty": "RSA", "e": "AQAB", "n": "uX1MpfEMQCBUMcj0sBYI-iFaG5Nodp3C6OlN8uY60fa5zSBd83-iIL3n_qzZ8VCluuTLfB7rrV_tiX727XIEqQ", - "kid": "2018-07-18T22:33:20Z" - } + "kid": "2018-07-18T22:33:20Z", + }, ] } post_login_data = { - 'iss': iss, - 'login_hint': '86157096483e6b3a50bfedc6bac902c0b20a824f', - 'target_link_uri': 'http://lti.django.test/launch/', - 'lti_message_hint': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJpZmllciI6Ijg0NjMxZjc1Z' - 'GYxNmNiZjNmYTM5YzEwMzk4YTg0M2U1NTAwZTc5MTU2OTBhN2RjYTJhNGMzMTJjYjR' - 'jOWU0YWY5NzE2MWVhYjg4ODhmOWJlNDc2MmViNzUzZDE5ZmI3YWU5N2I2MjAxZWZjM' - 'jRmODY4NWE3NjJmY2U0ZWU4MDk4IiwiY2FudmFzX2RvbWFpbiI6ImNhbnZhcy5kb2N' - 'rZXIiLCJjb250ZXh0X3R5cGUiOiJDb3Vyc2UiLCJjb250ZXh0X2lkIjoxMDAwMDAwM' - 'DAwMDAwMSwiZXhwIjoxNTY1NDQyMzcwfQ.B1Lddgthaa-YBT4-Lkm3OM_noETl3dIz' - '5E14YWJ8m_Q' + "iss": iss, + "login_hint": "86157096483e6b3a50bfedc6bac902c0b20a824f", + "target_link_uri": "http://lti.django.test/launch/", + "lti_message_hint": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJpZmllciI6Ijg0NjMxZjc1Z" + "GYxNmNiZjNmYTM5YzEwMzk4YTg0M2U1NTAwZTc5MTU2OTBhN2RjYTJhNGMzMTJjYjR" + "jOWU0YWY5NzE2MWVhYjg4ODhmOWJlNDc2MmViNzUzZDE5ZmI3YWU5N2I2MjAxZWZjM" + "jRmODY4NWE3NjJmY2U0ZWU4MDk4IiwiY2FudmFzX2RvbWFpbiI6ImNhbnZhcy5kb2N" + "rZXIiLCJjb250ZXh0X3R5cGUiOiJDb3Vyc2UiLCJjb250ZXh0X2lkIjoxMDAwMDAwM" + "DAwMDAwMSwiZXhwIjoxNTY1NDQyMzcwfQ.B1Lddgthaa-YBT4-Lkm3OM_noETl3dIz" + "5E14YWJ8m_Q", } post_launch_data = { - 'utf8': '%E2%9C%93', - 'authenticity_token': 'oOOlsiqy2nFHP5wgWIKWSEoHKYDZg0u%2BCRKC3BWuFsORmeT2HMC%2BASxQzEoW0' - 'KdnfnZe6ovmOe9gVOqYPth5mw%3D%3D', - 'state': 'state-test-uuid-1234', - 'id_token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjIwMTgtMDYtMThUMjI6MzM6MjBaIn0.' - 'eyJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWdlX3R5cGUi' - 'OiJMdGlSZXNvdXJjZUxpbmtSZXF1ZXN0IiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3Bl' - 'Yy9sdGkvY2xhaW0vdmVyc2lvbiI6IjEuMy4wIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcv' - 'c3BlYy9sdGkvY2xhaW0vcmVzb3VyY2VfbGluayI6eyJpZCI6IjRkZGUwNWU4Y2ExOTczYmNjYTli' - 'ZmZjMTNlMTU0ODgyMGVlZTkzYTMiLCJkZXNjcmlwdGlvbiI6bnVsbCwidGl0bGUiOm51bGwsInZh' - 'bGlkYXRpb25fY29udGV4dCI6bnVsbCwiZXJyb3JzIjp7ImVycm9ycyI6e319fSwiYXVkIjoiMTAw' - 'MDAwMDAwMDAwMDQiLCJhenAiOiIxMDAwMDAwMDAwMDAwNCIsImh0dHBzOi8vcHVybC5pbXNnbG9i' - 'YWwub3JnL3NwZWMvbHRpL2NsYWltL2RlcGxveW1lbnRfaWQiOiI2Ojg4NjVhYTA1YjRiNzliNjRh' - 'OTFhODYwNDJlNDNhZjVlYThhZTc5ZWIiLCJleHAiOjE1NjU0NDU2NzAsImlhdCI6MTU2NTQ0MjA3' - 'MCwiaXNzIjoiaHR0cHM6Ly9jYW52YXMuaW5zdHJ1Y3R1cmUuY29tIiwibm9uY2UiOiJ0ZXN0LXV1' - 'aWQtMTIzNCIsInN1YiI6ImE0NDVjYTk5LTFhNjQtNDY5Ny05YmZhLTUwOGExMTgyNDVlYSIsImh0' - 'dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL3RhcmdldF9saW5rX3VyaSI6' - 'Imh0dHA6Ly9sdGkuZGphbmdvLnRlc3QvbGF1bmNoLyIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwu' - 'b3JnL3NwZWMvbHRpL2NsYWltL2NvbnRleHQiOnsiaWQiOiI0ZGRlMDVlOGNhMTk3M2JjY2E5YmZm' - 'YzEzZTE1NDg4MjBlZWU5M2EzIiwibGFiZWwiOiJUZXN0IiwidGl0bGUiOiJUZXN0IiwidHlwZSI6' - 'WyJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3ZvY2FiL2xpcy92Mi9jb3Vyc2UjQ291cnNlT2Zm' - 'ZXJpbmciXSwidmFsaWRhdGlvbl9jb250ZXh0IjpudWxsLCJlcnJvcnMiOnsiZXJyb3JzIjp7fX19' - 'LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS90b29sX3BsYXRmb3Jt' - 'Ijp7Imd1aWQiOiJDZUFEeks3aHNQWWZ6bXlDN0xUTDhjcHpaSVNOZHBXalZnMVVaakxZOmNhbnZh' - 'cy1sbXMiLCJuYW1lIjoiRG1pdHJ5T3JnIiwidmVyc2lvbiI6ImNsb3VkIiwicHJvZHVjdF9mYW1p' - 'bHlfY29kZSI6ImNhbnZhcyIsInZhbGlkYXRpb25fY29udGV4dCI6bnVsbCwiZXJyb3JzIjp7ImVy' - 'cm9ycyI6e319fSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vbGF1' - 'bmNoX3ByZXNlbnRhdGlvbiI6eyJkb2N1bWVudF90YXJnZXQiOiJpZnJhbWUiLCJoZWlnaHQiOm51' - 'bGwsIndpZHRoIjpudWxsLCJyZXR1cm5fdXJsIjoiaHR0cDovL2NhbnZhcy5kb2NrZXIvY291cnNl' - 'cy8xL2V4dGVybmFsX2NvbnRlbnQvc3VjY2Vzcy9leHRlcm5hbF90b29sX3JlZGlyZWN0IiwibG9j' - 'YWxlIjoiZW4iLCJ2YWxpZGF0aW9uX2NvbnRleHQiOm51bGwsImVycm9ycyI6eyJlcnJvcnMiOnt9' - 'fX0sImxvY2FsZSI6ImVuIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xh' - 'aW0vcm9sZXMiOlsiaHR0cDovL3B1cmwuaW1zZ2xvYmFsLm9yZy92b2NhYi9saXMvdjIvaW5zdGl0' - 'dXRpb24vcGVyc29uI0FkbWluaXN0cmF0b3IiLCJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3Zv' - 'Y2FiL2xpcy92Mi9zeXN0ZW0vcGVyc29uI1N5c0FkbWluIiwiaHR0cDovL3B1cmwuaW1zZ2xvYmFs' - 'Lm9yZy92b2NhYi9saXMvdjIvc3lzdGVtL3BlcnNvbiNVc2VyIl0sImh0dHBzOi8vcHVybC5pbXNn' - 'bG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2N1c3RvbSI6eyJlbWFpbCI6ImFkbWluQGFkbWluLmNv' - 'bSIsInVzZXJfaWQiOjJ9LCJlcnJvcnMiOnsiZXJyb3JzIjp7fX0sImh0dHBzOi8vcHVybC5pbXNn' - 'bG9iYWwub3JnL3NwZWMvbHRpLWFncy9jbGFpbS9lbmRwb2ludCI6eyJzY29wZSI6WyJodHRwczov' - 'L3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1hZ3Mvc2NvcGUvc2NvcmUiLCJodHRwczovL3B1' - 'cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1hZ3Mvc2NvcGUvcmVzdWx0LnJlYWRvbmx5IiwiaHR0' - 'cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL3Njb3BlL2xpbmVpdGVtLnJlYWRv' - 'bmx5IiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL3Njb3BlL2xpbmVp' - 'dGVtIl0sImxpbmVpdGVtcyI6Imh0dHA6Ly9jYW52YXMuZG9ja2VyL2FwaS9sdGkvY291cnNlcy8x' - 'L2xpbmVfaXRlbXMiLCJ2YWxpZGF0aW9uX2NvbnRleHQiOm51bGwsImVycm9ycyI6eyJlcnJvcnMi' - 'Ont9fX0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpLW5ycHMvY2xhaW0vbmFt' - 'ZXNyb2xlc2VydmljZSI6eyJjb250ZXh0X21lbWJlcnNoaXBzX3VybCI6Imh0dHA6Ly9jYW52YXMu' - 'ZG9ja2VyL2FwaS9sdGkvY291cnNlcy8xL25hbWVzX2FuZF9yb2xlcyIsInNlcnZpY2VfdmVyc2lv' - 'bnMiOlsiMi4wIl0sInZhbGlkYXRpb25fY29udGV4dCI6bnVsbCwiZXJyb3JzIjp7ImVycm9ycyI6' - 'e319fX0.XR7ED7t3GVksBKO12gh99dvTgEhWtwcEgmJUqrdeU9UYGKyU7AX8r3hpmsonyItZnTOH' - 'wuITv7Y0ejn033RypQ' + "utf8": "%E2%9C%93", + "authenticity_token": "oOOlsiqy2nFHP5wgWIKWSEoHKYDZg0u%2BCRKC3BWuFsORmeT2HMC%2BASxQzEoW0" + "KdnfnZe6ovmOe9gVOqYPth5mw%3D%3D", + "state": "state-test-uuid-1234", + "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjIwMTgtMDYtMThUMjI6MzM6MjBaIn0." + "eyJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWdlX3R5cGUi" + "OiJMdGlSZXNvdXJjZUxpbmtSZXF1ZXN0IiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3Bl" + "Yy9sdGkvY2xhaW0vdmVyc2lvbiI6IjEuMy4wIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcv" + "c3BlYy9sdGkvY2xhaW0vcmVzb3VyY2VfbGluayI6eyJpZCI6IjRkZGUwNWU4Y2ExOTczYmNjYTli" + "ZmZjMTNlMTU0ODgyMGVlZTkzYTMiLCJkZXNjcmlwdGlvbiI6bnVsbCwidGl0bGUiOm51bGwsInZh" + "bGlkYXRpb25fY29udGV4dCI6bnVsbCwiZXJyb3JzIjp7ImVycm9ycyI6e319fSwiYXVkIjoiMTAw" + "MDAwMDAwMDAwMDQiLCJhenAiOiIxMDAwMDAwMDAwMDAwNCIsImh0dHBzOi8vcHVybC5pbXNnbG9i" + "YWwub3JnL3NwZWMvbHRpL2NsYWltL2RlcGxveW1lbnRfaWQiOiI2Ojg4NjVhYTA1YjRiNzliNjRh" + "OTFhODYwNDJlNDNhZjVlYThhZTc5ZWIiLCJleHAiOjE1NjU0NDU2NzAsImlhdCI6MTU2NTQ0MjA3" + "MCwiaXNzIjoiaHR0cHM6Ly9jYW52YXMuaW5zdHJ1Y3R1cmUuY29tIiwibm9uY2UiOiJ0ZXN0LXV1" + "aWQtMTIzNCIsInN1YiI6ImE0NDVjYTk5LTFhNjQtNDY5Ny05YmZhLTUwOGExMTgyNDVlYSIsImh0" + "dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL3RhcmdldF9saW5rX3VyaSI6" + "Imh0dHA6Ly9sdGkuZGphbmdvLnRlc3QvbGF1bmNoLyIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwu" + "b3JnL3NwZWMvbHRpL2NsYWltL2NvbnRleHQiOnsiaWQiOiI0ZGRlMDVlOGNhMTk3M2JjY2E5YmZm" + "YzEzZTE1NDg4MjBlZWU5M2EzIiwibGFiZWwiOiJUZXN0IiwidGl0bGUiOiJUZXN0IiwidHlwZSI6" + "WyJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3ZvY2FiL2xpcy92Mi9jb3Vyc2UjQ291cnNlT2Zm" + "ZXJpbmciXSwidmFsaWRhdGlvbl9jb250ZXh0IjpudWxsLCJlcnJvcnMiOnsiZXJyb3JzIjp7fX19" + "LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS90b29sX3BsYXRmb3Jt" + "Ijp7Imd1aWQiOiJDZUFEeks3aHNQWWZ6bXlDN0xUTDhjcHpaSVNOZHBXalZnMVVaakxZOmNhbnZh" + "cy1sbXMiLCJuYW1lIjoiRG1pdHJ5T3JnIiwidmVyc2lvbiI6ImNsb3VkIiwicHJvZHVjdF9mYW1p" + "bHlfY29kZSI6ImNhbnZhcyIsInZhbGlkYXRpb25fY29udGV4dCI6bnVsbCwiZXJyb3JzIjp7ImVy" + "cm9ycyI6e319fSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW0vbGF1" + "bmNoX3ByZXNlbnRhdGlvbiI6eyJkb2N1bWVudF90YXJnZXQiOiJpZnJhbWUiLCJoZWlnaHQiOm51" + "bGwsIndpZHRoIjpudWxsLCJyZXR1cm5fdXJsIjoiaHR0cDovL2NhbnZhcy5kb2NrZXIvY291cnNl" + "cy8xL2V4dGVybmFsX2NvbnRlbnQvc3VjY2Vzcy9leHRlcm5hbF90b29sX3JlZGlyZWN0IiwibG9j" + "YWxlIjoiZW4iLCJ2YWxpZGF0aW9uX2NvbnRleHQiOm51bGwsImVycm9ycyI6eyJlcnJvcnMiOnt9" + "fX0sImxvY2FsZSI6ImVuIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xh" + "aW0vcm9sZXMiOlsiaHR0cDovL3B1cmwuaW1zZ2xvYmFsLm9yZy92b2NhYi9saXMvdjIvaW5zdGl0" + "dXRpb24vcGVyc29uI0FkbWluaXN0cmF0b3IiLCJodHRwOi8vcHVybC5pbXNnbG9iYWwub3JnL3Zv" + "Y2FiL2xpcy92Mi9zeXN0ZW0vcGVyc29uI1N5c0FkbWluIiwiaHR0cDovL3B1cmwuaW1zZ2xvYmFs" + "Lm9yZy92b2NhYi9saXMvdjIvc3lzdGVtL3BlcnNvbiNVc2VyIl0sImh0dHBzOi8vcHVybC5pbXNn" + "bG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2N1c3RvbSI6eyJlbWFpbCI6ImFkbWluQGFkbWluLmNv" + "bSIsInVzZXJfaWQiOjJ9LCJlcnJvcnMiOnsiZXJyb3JzIjp7fX0sImh0dHBzOi8vcHVybC5pbXNn" + "bG9iYWwub3JnL3NwZWMvbHRpLWFncy9jbGFpbS9lbmRwb2ludCI6eyJzY29wZSI6WyJodHRwczov" + "L3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1hZ3Mvc2NvcGUvc2NvcmUiLCJodHRwczovL3B1" + "cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1hZ3Mvc2NvcGUvcmVzdWx0LnJlYWRvbmx5IiwiaHR0" + "cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL3Njb3BlL2xpbmVpdGVtLnJlYWRv" + "bmx5IiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL3Njb3BlL2xpbmVp" + "dGVtIl0sImxpbmVpdGVtcyI6Imh0dHA6Ly9jYW52YXMuZG9ja2VyL2FwaS9sdGkvY291cnNlcy8x" + "L2xpbmVfaXRlbXMiLCJ2YWxpZGF0aW9uX2NvbnRleHQiOm51bGwsImVycm9ycyI6eyJlcnJvcnMi" + "Ont9fX0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpLW5ycHMvY2xhaW0vbmFt" + "ZXNyb2xlc2VydmljZSI6eyJjb250ZXh0X21lbWJlcnNoaXBzX3VybCI6Imh0dHA6Ly9jYW52YXMu" + "ZG9ja2VyL2FwaS9sdGkvY291cnNlcy8xL25hbWVzX2FuZF9yb2xlcyIsInNlcnZpY2VfdmVyc2lv" + "bnMiOlsiMi4wIl0sInZhbGlkYXRpb25fY29udGV4dCI6bnVsbCwiZXJyb3JzIjp7ImVycm9ycyI6" + "e319fX0.XR7ED7t3GVksBKO12gh99dvTgEhWtwcEgmJUqrdeU9UYGKyU7AX8r3hpmsonyItZnTOH" + "wuITv7Y0ejn033RypQ", } expected_message_launch_data = { - 'nonce': 'test-uuid-1234', - 'https://purl.imsglobal.org/spec/lti/claim/tool_platform': { - 'errors': {'errors': {}}, 'name': 'DmitryOrg', - 'version': 'cloud', - 'product_family_code': 'canvas', - 'guid': 'CeADzK7hsPYfzmyC7LTL8cpzZISNdpWjVg1UZjLY:canvas-lms', - 'validation_context': None}, - 'https://purl.imsglobal.org/spec/lti/claim/context': { - 'errors': { - 'errors': {} - }, - 'title': 'Test', - 'label': 'Test', - 'type': ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering'], - 'id': '4dde05e8ca1973bcca9bffc13e1548820eee93a3', - 'validation_context': None}, 'errors': {'errors': {}}, - 'aud': '10000000000004', - 'https://purl.imsglobal.org/spec/lti/claim/version': '1.3.0', - 'iss': 'https://canvas.instructure.com', - 'https://purl.imsglobal.org/spec/lti/claim/roles': [ - 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator', - 'http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin', - 'http://purl.imsglobal.org/vocab/lis/v2/system/person#User'], - 'https://purl.imsglobal.org/spec/lti/claim/custom': {'user_id': 2, 'email': 'admin@admin.com'}, - 'https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice': { - 'context_memberships_url': 'http://canvas.docker/api/lti/courses/1/names_and_roles', - 'service_versions': ['2.0'], 'errors': {'errors': {}}, 'validation_context': None}, 'locale': 'en', - 'https://purl.imsglobal.org/spec/lti/claim/resource_link': { - 'errors': {'errors': {}}, - 'validation_context': None, 'title': None, - 'id': '4dde05e8ca1973bcca9bffc13e1548820eee93a3', - 'description': None}, - 'https://purl.imsglobal.org/spec/lti/claim/message_type': 'LtiResourceLinkRequest', - 'https://purl.imsglobal.org/spec/lti/claim/deployment_id': '6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb', - 'iat': 1565442070, - 'azp': '10000000000004', - 'exp': 1565445670, - 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': { - 'scope': ['https://purl.imsglobal.org/spec/lti-ags/scope/score', - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'], - 'lineitems': 'http://canvas.docker/api/lti/courses/1/line_items', - 'errors': {'errors': {}}, - 'validation_context': None + "nonce": "test-uuid-1234", + "https://purl.imsglobal.org/spec/lti/claim/tool_platform": { + "errors": {"errors": {}}, + "name": "DmitryOrg", + "version": "cloud", + "product_family_code": "canvas", + "guid": "CeADzK7hsPYfzmyC7LTL8cpzZISNdpWjVg1UZjLY:canvas-lms", + "validation_context": None, + }, + "https://purl.imsglobal.org/spec/lti/claim/context": { + "errors": {"errors": {}}, + "title": "Test", + "label": "Test", + "type": ["http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"], + "id": "4dde05e8ca1973bcca9bffc13e1548820eee93a3", + "validation_context": None, }, - 'https://purl.imsglobal.org/spec/lti/claim/launch_presentation': { - 'errors': {'errors': {}}, - 'locale': 'en', - 'height': None, - 'width': None, - 'document_target': 'iframe', - 'return_url': 'http://canvas.docker/courses/1/external_content/success/external_tool_redirect', - 'validation_context': None}, - 'https://purl.imsglobal.org/spec/lti/claim/target_link_uri': 'http://lti.django.test/launch/', - 'sub': 'a445ca99-1a64-4697-9bfa-508a118245ea' + "errors": {"errors": {}}, + "aud": "10000000000004", + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "iss": "https://canvas.instructure.com", + "https://purl.imsglobal.org/spec/lti/claim/roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator", + "http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin", + "http://purl.imsglobal.org/vocab/lis/v2/system/person#User", + ], + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "user_id": 2, + "email": "admin@admin.com", + }, + "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { + "context_memberships_url": "http://canvas.docker/api/lti/courses/1/names_and_roles", + "service_versions": ["2.0"], + "errors": {"errors": {}}, + "validation_context": None, + }, + "locale": "en", + "https://purl.imsglobal.org/spec/lti/claim/resource_link": { + "errors": {"errors": {}}, + "validation_context": None, + "title": None, + "id": "4dde05e8ca1973bcca9bffc13e1548820eee93a3", + "description": None, + }, + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb", + "iat": 1565442070, + "azp": "10000000000004", + "exp": 1565445670, + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { + "scope": [ + "https://purl.imsglobal.org/spec/lti-ags/scope/score", + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + ], + "lineitems": "http://canvas.docker/api/lti/courses/1/line_items", + "errors": {"errors": {}}, + "validation_context": None, + }, + "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { + "errors": {"errors": {}}, + "locale": "en", + "height": None, + "width": None, + "document_target": "iframe", + "return_url": "http://canvas.docker/courses/1/external_content/success/external_tool_redirect", + "validation_context": None, + }, + "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "http://lti.django.test/launch/", + "sub": "a445ca99-1a64-4697-9bfa-508a118245ea", } - def _launch_success(self, tool_conf_cls=None, secure=False, tool_conf_extended=False, enable_check_cookies=False, - use_cache=False): + def _launch_success( + self, + tool_conf_cls=None, + secure=False, + tool_conf_extended=False, + enable_check_cookies=False, + use_cache=False, + ): cache = FakeCacheDataStorage() if use_cache else False tool_conf, login_request, login_response = self._make_oidc_login( - tool_conf_cls=tool_conf_cls, secure=secure, tool_conf_extended=tool_conf_extended, - enable_check_cookies=enable_check_cookies, cache=cache) - launch_request = self._get_request(login_request, login_response, request_is_secure=secure) + tool_conf_cls=tool_conf_cls, + secure=secure, + tool_conf_extended=tool_conf_extended, + enable_check_cookies=enable_check_cookies, + cache=cache, + ) + launch_request = self._get_request( + login_request, login_response, request_is_secure=secure + ) message_launch_data = self._launch(launch_request, tool_conf, cache=cache) self.assertDictEqual(message_launch_data, self.expected_message_launch_data) - @parameterized.expand([['base_non_secure', False, None, False], - ['base_secure', True, None, False], - ['tool_conf_deprecated_non_secure', False, ToolConfDeprecated, False], - ['tool_conf_deprecated_secure', True, ToolConfDeprecated, False], - ['tool_conf_one_iss_many_clients', False, None, True]]) - def test_res_link_launch_success(self, name, secure, tool_conf_cls, # pylint: disable=unused-argument - tool_conf_extended): - self._launch_success(tool_conf_cls, secure, tool_conf_extended) + @parameterized.expand( + [ + ["base_non_secure", False, False], + ["base_secure", True, False], + ["tool_conf_one_iss_many_clients", False, True], + ] + ) + def test_res_link_launch_success( + self, name, secure, tool_conf_extended # pylint: disable=unused-argument + ): + self._launch_success(None, secure, tool_conf_extended) def test_res_link_check_cookies_page(self): self._launch_success(enable_check_cookies=True) @@ -187,68 +218,84 @@ def test_res_link_launch_invalid_public_key(self): tool_conf, login_request, login_response = self._make_oidc_login() launch_request = self._get_request(login_request, login_response) - with self.assertRaisesRegexp(LtiException, 'Invalid response'): # pylint: disable=deprecated-method - self._launch(launch_request, tool_conf, 'invalid_key_set') + with self.assertRaisesRegex(LtiException, "Invalid response"): + self._launch(launch_request, tool_conf, "invalid_key_set") def test_res_link_launch_invalid_state(self): tool_conf, login_request, login_response = self._make_oidc_login() post_data = self.post_launch_data.copy() - post_data.pop('state', None) + post_data.pop("state", None) - launch_request = self._get_request(login_request, login_response, post_data=post_data) - with self.assertRaisesRegexp(LtiException, 'Missing state param'): # pylint: disable=deprecated-method + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) + with self.assertRaisesRegex(LtiException, "Missing state param"): self._launch(launch_request, tool_conf) - launch_request = self._get_request(login_request, login_response, empty_cookies=True) - with self.assertRaisesRegexp(LtiException, 'State not found'): # pylint: disable=deprecated-method + launch_request = self._get_request( + login_request, login_response, empty_cookies=True + ) + with self.assertRaisesRegex(LtiException, "State not found"): self._launch(launch_request, tool_conf) def test_res_link_launch_invalid_jwt_format(self): tool_conf, login_request, login_response = self._make_oidc_login() post_data = self.post_launch_data.copy() - post_data['id_token'] += '.absjdbasdj' + post_data["id_token"] += ".absjdbasdj" - launch_request = self._get_request(login_request, login_response, post_data=post_data) - with self.assertRaisesRegexp(LtiException, 'Invalid id_token'): # pylint: disable=deprecated-method + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) + with self.assertRaisesRegex(LtiException, "Invalid id_token"): self._launch(launch_request, tool_conf) post_data = self.post_launch_data.copy() - post_data['id_token'] = 'jbafjjsdbjasdabsjdbasdj1212121212.sdfhdhsf.sdfdsfdsf' + post_data["id_token"] = "jbafjjsdbjasdabsjdbasdj1212121212.sdfhdhsf.sdfdsfdsf" - launch_request = self._get_request(login_request, login_response, post_data=post_data) - with self.assertRaisesRegexp(LtiException, 'Invalid JWT format'): # pylint: disable=deprecated-method + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) + with self.assertRaisesRegex(LtiException, "Invalid JWT format"): self._launch(launch_request, tool_conf) def test_res_link_launch_invalid_jwt_signature(self): tool_conf, login_request, login_response = self._make_oidc_login() post_data = self.post_launch_data.copy() - post_data['id_token'] += 'jbafjjsdbjasdabsjdbasdj' + post_data["id_token"] += "jbafjjsdbjasdabsjdbasdj" - launch_request = self._get_request(login_request, login_response, post_data=post_data) - with self.assertRaisesRegexp(LtiException, "Can't decode id_token"): # pylint: disable=deprecated-method + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) + with self.assertRaisesRegex(LtiException, "Can't decode id_token"): self._launch(launch_request, tool_conf) def _get_data_without_nonce(self, *args): # pylint: disable=unused-argument message_launch_data = self.expected_message_launch_data.copy() - message_launch_data.pop('nonce', None) + message_launch_data.pop("nonce", None) return message_launch_data def _get_data_with_invalid_aud(self, *args): # pylint: disable=unused-argument message_launch_data = self.expected_message_launch_data.copy() - message_launch_data['aud'] = 'dsfsdfsdfsdfsd' + message_launch_data["aud"] = "dsfsdfsdfsdfsd" return message_launch_data - def _get_data_with_invalid_deployment(self, *args): # pylint: disable=unused-argument + def _get_data_with_invalid_deployment( + self, *args + ): # pylint: disable=unused-argument message_launch_data = self.expected_message_launch_data.copy() - message_launch_data['https://purl.imsglobal.org/spec/lti/claim/deployment_id'] = 'dsfsdfsdfsdfsd' + message_launch_data[ + "https://purl.imsglobal.org/spec/lti/claim/deployment_id" + ] = "dsfsdfsdfsdfsd" return message_launch_data def _get_data_with_invalid_message(self, *args): # pylint: disable=unused-argument message_launch_data = self.expected_message_launch_data.copy() - message_launch_data['https://purl.imsglobal.org/spec/lti/claim/version'] = '1.2.0' + message_launch_data[ + "https://purl.imsglobal.org/spec/lti/claim/version" + ] = "1.2.0" return message_launch_data def test_res_link_launch_invalid_nonce(self): @@ -256,43 +303,62 @@ def test_res_link_launch_invalid_nonce(self): tool_conf, login_request, login_response = self._make_oidc_login() post_data = self.post_launch_data.copy() - launch_request = self._get_request(login_request, login_response, post_data=post_data) + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) - with self.assertRaisesRegexp(LtiException, '"nonce" is empty'): # pylint: disable=deprecated-method - self._launch_with_invalid_jwt_body(self._get_data_without_nonce, launch_request, tool_conf) + with self.assertRaisesRegex(LtiException, '"nonce" is empty'): + self._launch_with_invalid_jwt_body( + self._get_data_without_nonce, launch_request, tool_conf + ) - launch_request = self._get_request(login_request, login_response, post_data=post_data, empty_session=True) + launch_request = self._get_request( + login_request, login_response, post_data=post_data, empty_session=True + ) - with self.assertRaisesRegexp(LtiException, "Invalid Nonce"): # pylint: disable=deprecated-method + with self.assertRaisesRegex(LtiException, "Invalid Nonce"): self._launch(launch_request, tool_conf) def test_res_link_launch_invalid_registration(self): tool_conf, login_request, login_response = self._make_oidc_login() post_data = self.post_launch_data.copy() - launch_request = self._get_request(login_request, login_response, post_data=post_data) + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) - # pylint: disable=deprecated-method - with self.assertRaisesRegexp(LtiException, 'Client id not registered for this issuer'): - self._launch_with_invalid_jwt_body(self._get_data_with_invalid_aud, launch_request, tool_conf) + with self.assertRaisesRegex( + LtiException, "Client id not registered for this issuer" + ): + self._launch_with_invalid_jwt_body( + self._get_data_with_invalid_aud, launch_request, tool_conf + ) def test_res_link_launch_invalid_deployment(self): tool_conf, login_request, login_response = self._make_oidc_login() post_data = self.post_launch_data.copy() - launch_request = self._get_request(login_request, login_response, post_data=post_data) + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) - with self.assertRaisesRegexp(Exception, 'Unable to find deployment'): # pylint: disable=deprecated-method - self._launch_with_invalid_jwt_body(self._get_data_with_invalid_deployment, launch_request, tool_conf) + with self.assertRaisesRegex(Exception, "Unable to find deployment"): + self._launch_with_invalid_jwt_body( + self._get_data_with_invalid_deployment, launch_request, tool_conf + ) def test_res_link_launch_invalid_message(self): tool_conf, login_request, login_response = self._make_oidc_login() post_data = self.post_launch_data.copy() - launch_request = self._get_request(login_request, login_response, post_data=post_data) - - with self.assertRaisesRegexp(LtiException, 'Incorrect version'): # pylint: disable=deprecated-method - self._launch_with_invalid_jwt_body(self._get_data_with_invalid_message, launch_request, tool_conf) + launch_request = self._get_request( + login_request, login_response, post_data=post_data + ) + + with self.assertRaisesRegex(LtiException, "Incorrect version"): + self._launch_with_invalid_jwt_body( + self._get_data_with_invalid_message, launch_request, tool_conf + ) class TestDjangoResourceLink(DjangoMixin, ResourceLinkBase): diff --git a/tests/test_submission_review_launch.py b/tests/test_submission_review_launch.py index 13c8fff..42745d6 100644 --- a/tests/test_submission_review_launch.py +++ b/tests/test_submission_review_launch.py @@ -4,19 +4,19 @@ class SubmissionReviewLaunchBase(TestLinkBase): - # pylint: disable=abstract-method + # pylint: disable=abstract-method,no-member - iss = 'https://canvas.instructure.com' + iss = "https://canvas.instructure.com" jwt_canvas_keys = { "keys": [ { "kty": "RSA", "e": "AQAB", "n": "wSJ8fSR-ZHfmj00-tQaz2TrOT3TREWIMtfhuNS6JvWFd5kg29TK8y4hBvYi6AMnSWn97Kps" - "AewK2VABI7MInYRlNRtX9jLrUyatucsOBx4usU2u_qYm3sPpaUgds37mZn1_w6dfbZNG_Z4" - "givpIUdSAq8QKxCQCk9MV0k94eRMn5xWfJ7hqesb6xiBGKDZUlmt3PfAaSgvk3lxLjd_Jf0" - "WwZS4KspzjGdeq2ctyuRMB9QZTVvit4PXpRVxT1zwhN3kxH09kWRqNF5CIKw5m93mFmewdC" - "xjHSZ9AEtTe918zFaYbrh09ZH6E-zz9rXXLvesqoPBDYIT73MeJQhclkqw", + "AewK2VABI7MInYRlNRtX9jLrUyatucsOBx4usU2u_qYm3sPpaUgds37mZn1_w6dfbZNG_Z4" + "givpIUdSAq8QKxCQCk9MV0k94eRMn5xWfJ7hqesb6xiBGKDZUlmt3PfAaSgvk3lxLjd_Jf0" + "WwZS4KspzjGdeq2ctyuRMB9QZTVvit4PXpRVxT1zwhN3kxH09kWRqNF5CIKw5m93mFmewdC" + "xjHSZ9AEtTe918zFaYbrh09ZH6E-zz9rXXLvesqoPBDYIT73MeJQhclkqw", "kid": "JcJy_-ZbGGXE-fiXLrqbUCyNRg1skGPXngZ5hxD64CA", "alg": "RS256", "use": "sig", @@ -34,38 +34,38 @@ class SubmissionReviewLaunchBase(TestLinkBase): post_launch_data = { "state": "state-test-uuid-1234", "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkpjSnlfLVpiR0dYRS1maVhMcnFiVUN5TlJnMXNrR1BYbmdaNWh4R" - "DY0Q0EifQ.eyJpc3MiOiJodHRwczovL2NhbnZhcy5pbnN0cnVjdHVyZS5jb20iLCJzdWIiOiJhNmQ1YzQ0My0xZjUxLTQ3ODM" - "tYmExYS03Njg2ZmZlM2I1NGEiLCJhdWQiOiIxMDAwMDAwMDAwMDAwNCIsImV4cCI6MTUxMDE4NTcyOCwiaWF0IjoxNTEwMTg1" - "MjI4LCJhenAiOiI5NjJmYTRkOC1iY2JmLTQ5YTAtOTRiMi0yZGUwNWFkMjc0YWYiLCJub25jZSI6InRlc3QtdXVpZC0xMjM0I" - "iwibmFtZSI6Ik1zIEphbmUgTWFyaWUgRG9lIiwiZ2l2ZW5fbmFtZSI6IkphbmUiLCJmYW1pbHlfbmFtZSI6IkRvZSIsImVtYW" - "lsIjoiamFuZUBleGFtcGxlLm9yZyIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2RlcGxveW1" - "lbnRfaWQiOiI2Ojg4NjVhYTA1YjRiNzliNjRhOTFhODYwNDJlNDNhZjVlYThhZTc5ZWIiLCJodHRwczovL3B1cmwuaW1zZ2xv" - "YmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWdlX3R5cGUiOiJMdGlTdWJtaXNzaW9uUmV2aWV3UmVxdWVzdCIsImh0dHBzO" - "i8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL3ZlcnNpb24iOiIxLjMuMCIsImh0dHBzOi8vcHVybC5pbXNnbG" - "9iYWwub3JnL3NwZWMvbHRpL2NsYWltL3JvbGVzIjpbImh0dHA6Ly9wdXJsLmltc2dsb2JhbC5vcmcvdm9jYWIvbGlzL3YyL21" - "lbWJlcnNoaXAjSW5zdHJ1Y3RvciJdLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9jb250ZXh0" - "Ijp7ImlkIjoiYzFkODg3ZjAtYTFhMy00YmNhLWFlMjUtYzM3NWVkY2MxMzFhIiwibGFiZWwiOiJFQ09OIDEwMTAiLCJ0aXRsZ" - "SI6IkVjb25vbWljcyBhcyBhIFNvY2lhbCBTY2llbmNlIiwidHlwZSI6WyJDb3Vyc2VPZmZlcmluZyJdfSwiaHR0cHM6Ly9wdX" - "JsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL2NsYWltL2VuZHBvaW50Ijp7InNjb3BlIjpbImh0dHBzOi8vcHVybC5pbXN" - "nbG9iYWwub3JnL3NwZWMvbHRpLWFncy9zY29wZS9saW5laXRlbSIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMv" - "bHRpLWFncy9zY29wZS9yZXN1bHQucmVhZG9ubHkiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1hZ3Mvc" - "2NvcGUvc2NvcmUiXSwibGluZWl0ZW1zIjoiaHR0cHM6Ly93d3cubXl1bml2LmVkdS8yMzQ0L2xpbmVpdGVtcy8iLCJsaW5laX" - "RlbSI6Imh0dHBzOi8vd3d3Lm15dW5pdi5lZHUvMjM0NC9saW5laXRlbXMvMTIzNC9saW5laXRlbSJ9LCJodHRwczovL3B1cmw" - "uaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9mb3JfdXNlciI6eyJ1c2VyX2lkIjoiMTIzOWEtaWx0IiwicGVyc29uX3Nv" - "dXJjZWRpZCI6ImV4YW1wbGUuZWR1OjcxZWU3ZTQyLWY2ZDItNDE0YS04MGRiLWI2OWFjMmRlZmQ0IiwiZ2l2ZW5fbmFtZSI6I" - "kp1ZGUiLCJmYW1pbHlfbmFtZSI6IldpbGJlcnQiLCJlbWFpbCI6Imp3aWxiZXJ0QGV4YW1wbGUub3JnIiwicm9sZXMiOlsiaH" - "R0cDovL3B1cmwuaW1zZ2xvYmFsLm9yZy92b2NhYi9saXMvdjIvbWVtYmVyc2hpcCNMZWFybmVyIl19LCJodHRwczovL3B1cmw" - "uaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9yZXNvdXJjZV9saW5rIjp7ImlkIjoiMjAwZDEwMWYtMmMxNC00MzRhLWEw" - "ZjMtNTdjMmE0MjM2OWZkIiwiZGVzY3JpcHRpb24iOiJBc3NpZ25tZW50IHRvIGludHJvZHVjZSB3aG8geW91IGFyZSIsInRpd" - "GxlIjoiSW50cm9kdWN0aW9uIEFzc2lnbm1lbnQifSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW" - "0vbGF1bmNoX3ByZXNlbnRhdGlvbiI6eyJyZXR1cm5fdXJsIjoiaHR0cDovL2V4YW1wbGUub3JnL3JldHVyblRvR3JhZGVib29" - "rIn0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2N1c3RvbSI6eyJhY3Rpdml0eV9pZCI6IjEy" - "MyJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS90b29sX3BsYXRmb3JtIjp7Imd1aWQiOiI4O" - "TAyMzg5MDIzODo0MzI3MjM4IiwicHJvZHVjdF9mYW1pbHlfY29kZSI6ImV4YW1wbGUub3JnIn19.SKZ1_btZHPx9Avlsh3To4" - "JZAXb1abZhhlYCubOnJo0rBq-45OhUEdhdMo4rpgsN5TI3lUV7djBaAZv7twnwrGeSw2h7L4zfmlP0pIL8CG5PE1wKqZ3SuuZ" - "xpt_eHc82ORtNl5wSsKV4ibIKOA1LtPktvCNR3hIv56dbCEscYbxi9sFE8F2YI4KfPhVkcssuew3R1ubUogqCV0Fvn0kCh1EA" - "tDNgEHcHoEkwLBTd88v3_k-398E6oNEM8HqO5Xef4YFnaFgXeXWh94h2O9L8TctCXAU9P_ZnoV0OVfPF6K8mCf1ES9cMK72UY" - "CXaaORkicEJPJK3bfLC-d4MZDOb-Aw", + "DY0Q0EifQ.eyJpc3MiOiJodHRwczovL2NhbnZhcy5pbnN0cnVjdHVyZS5jb20iLCJzdWIiOiJhNmQ1YzQ0My0xZjUxLTQ3ODM" + "tYmExYS03Njg2ZmZlM2I1NGEiLCJhdWQiOiIxMDAwMDAwMDAwMDAwNCIsImV4cCI6MTUxMDE4NTcyOCwiaWF0IjoxNTEwMTg1" + "MjI4LCJhenAiOiI5NjJmYTRkOC1iY2JmLTQ5YTAtOTRiMi0yZGUwNWFkMjc0YWYiLCJub25jZSI6InRlc3QtdXVpZC0xMjM0I" + "iwibmFtZSI6Ik1zIEphbmUgTWFyaWUgRG9lIiwiZ2l2ZW5fbmFtZSI6IkphbmUiLCJmYW1pbHlfbmFtZSI6IkRvZSIsImVtYW" + "lsIjoiamFuZUBleGFtcGxlLm9yZyIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2RlcGxveW1" + "lbnRfaWQiOiI2Ojg4NjVhYTA1YjRiNzliNjRhOTFhODYwNDJlNDNhZjVlYThhZTc5ZWIiLCJodHRwczovL3B1cmwuaW1zZ2xv" + "YmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9tZXNzYWdlX3R5cGUiOiJMdGlTdWJtaXNzaW9uUmV2aWV3UmVxdWVzdCIsImh0dHBzO" + "i8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL3ZlcnNpb24iOiIxLjMuMCIsImh0dHBzOi8vcHVybC5pbXNnbG" + "9iYWwub3JnL3NwZWMvbHRpL2NsYWltL3JvbGVzIjpbImh0dHA6Ly9wdXJsLmltc2dsb2JhbC5vcmcvdm9jYWIvbGlzL3YyL21" + "lbWJlcnNoaXAjSW5zdHJ1Y3RvciJdLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9jb250ZXh0" + "Ijp7ImlkIjoiYzFkODg3ZjAtYTFhMy00YmNhLWFlMjUtYzM3NWVkY2MxMzFhIiwibGFiZWwiOiJFQ09OIDEwMTAiLCJ0aXRsZ" + "SI6IkVjb25vbWljcyBhcyBhIFNvY2lhbCBTY2llbmNlIiwidHlwZSI6WyJDb3Vyc2VPZmZlcmluZyJdfSwiaHR0cHM6Ly9wdX" + "JsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGktYWdzL2NsYWltL2VuZHBvaW50Ijp7InNjb3BlIjpbImh0dHBzOi8vcHVybC5pbXN" + "nbG9iYWwub3JnL3NwZWMvbHRpLWFncy9zY29wZS9saW5laXRlbSIsImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMv" + "bHRpLWFncy9zY29wZS9yZXN1bHQucmVhZG9ubHkiLCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS1hZ3Mvc" + "2NvcGUvc2NvcmUiXSwibGluZWl0ZW1zIjoiaHR0cHM6Ly93d3cubXl1bml2LmVkdS8yMzQ0L2xpbmVpdGVtcy8iLCJsaW5laX" + "RlbSI6Imh0dHBzOi8vd3d3Lm15dW5pdi5lZHUvMjM0NC9saW5laXRlbXMvMTIzNC9saW5laXRlbSJ9LCJodHRwczovL3B1cmw" + "uaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9mb3JfdXNlciI6eyJ1c2VyX2lkIjoiMTIzOWEtaWx0IiwicGVyc29uX3Nv" + "dXJjZWRpZCI6ImV4YW1wbGUuZWR1OjcxZWU3ZTQyLWY2ZDItNDE0YS04MGRiLWI2OWFjMmRlZmQ0IiwiZ2l2ZW5fbmFtZSI6I" + "kp1ZGUiLCJmYW1pbHlfbmFtZSI6IldpbGJlcnQiLCJlbWFpbCI6Imp3aWxiZXJ0QGV4YW1wbGUub3JnIiwicm9sZXMiOlsiaH" + "R0cDovL3B1cmwuaW1zZ2xvYmFsLm9yZy92b2NhYi9saXMvdjIvbWVtYmVyc2hpcCNMZWFybmVyIl19LCJodHRwczovL3B1cmw" + "uaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS9yZXNvdXJjZV9saW5rIjp7ImlkIjoiMjAwZDEwMWYtMmMxNC00MzRhLWEw" + "ZjMtNTdjMmE0MjM2OWZkIiwiZGVzY3JpcHRpb24iOiJBc3NpZ25tZW50IHRvIGludHJvZHVjZSB3aG8geW91IGFyZSIsInRpd" + "GxlIjoiSW50cm9kdWN0aW9uIEFzc2lnbm1lbnQifSwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9sdGkvY2xhaW" + "0vbGF1bmNoX3ByZXNlbnRhdGlvbiI6eyJyZXR1cm5fdXJsIjoiaHR0cDovL2V4YW1wbGUub3JnL3JldHVyblRvR3JhZGVib29" + "rIn0sImh0dHBzOi8vcHVybC5pbXNnbG9iYWwub3JnL3NwZWMvbHRpL2NsYWltL2N1c3RvbSI6eyJhY3Rpdml0eV9pZCI6IjEy" + "MyJ9LCJodHRwczovL3B1cmwuaW1zZ2xvYmFsLm9yZy9zcGVjL2x0aS9jbGFpbS90b29sX3BsYXRmb3JtIjp7Imd1aWQiOiI4O" + "TAyMzg5MDIzODo0MzI3MjM4IiwicHJvZHVjdF9mYW1pbHlfY29kZSI6ImV4YW1wbGUub3JnIn19.SKZ1_btZHPx9Avlsh3To4" + "JZAXb1abZhhlYCubOnJo0rBq-45OhUEdhdMo4rpgsN5TI3lUV7djBaAZv7twnwrGeSw2h7L4zfmlP0pIL8CG5PE1wKqZ3SuuZ" + "xpt_eHc82ORtNl5wSsKV4ibIKOA1LtPktvCNR3hIv56dbCEscYbxi9sFE8F2YI4KfPhVkcssuew3R1ubUogqCV0Fvn0kCh1EA" + "tDNgEHcHoEkwLBTd88v3_k-398E6oNEM8HqO5Xef4YFnaFgXeXWh94h2O9L8TctCXAU9P_ZnoV0OVfPF6K8mCf1ES9cMK72UY" + "CXaaORkicEJPJK3bfLC-d4MZDOb-Aw", } expected_launch_data = { @@ -80,10 +80,8 @@ class SubmissionReviewLaunchBase(TestLinkBase): "given_name": "Jane", "family_name": "Doe", "email": "jane@example.org", - "https://purl.imsglobal.org/spec/lti/claim/deployment_id": - "6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb", - "https://purl.imsglobal.org/spec/lti/claim/message_type": - "LtiSubmissionReviewRequest", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb", + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiSubmissionReviewRequest", "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", "https://purl.imsglobal.org/spec/lti/claim/roles": [ "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor" @@ -92,16 +90,16 @@ class SubmissionReviewLaunchBase(TestLinkBase): "id": "c1d887f0-a1a3-4bca-ae25-c375edcc131a", "label": "ECON 1010", "title": "Economics as a Social Science", - "type": ["CourseOffering"] + "type": ["CourseOffering"], }, "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { "scope": [ "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", - "https://purl.imsglobal.org/spec/lti-ags/scope/score" + "https://purl.imsglobal.org/spec/lti-ags/scope/score", ], "lineitems": "https://www.myuniv.edu/2344/lineitems/", - "lineitem": "https://www.myuniv.edu/2344/lineitems/1234/lineitem" + "lineitem": "https://www.myuniv.edu/2344/lineitems/1234/lineitem", }, "https://purl.imsglobal.org/spec/lti/claim/for_user": { "user_id": "1239a-ilt", @@ -109,35 +107,37 @@ class SubmissionReviewLaunchBase(TestLinkBase): "given_name": "Jude", "family_name": "Wilbert", "email": "jwilbert@example.org", - "roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"] + "roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"], }, "https://purl.imsglobal.org/spec/lti/claim/resource_link": { "id": "200d101f-2c14-434a-a0f3-57c2a42369fd", "description": "Assignment to introduce who you are", - "title": "Introduction Assignment" + "title": "Introduction Assignment", }, "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { "return_url": "http://example.org/returnToGradebook" }, - "https://purl.imsglobal.org/spec/lti/claim/custom": { - "activity_id": "123" - }, + "https://purl.imsglobal.org/spec/lti/claim/custom": {"activity_id": "123"}, "https://purl.imsglobal.org/spec/lti/claim/tool_platform": { "guid": "89023890238:4327238", - "product_family_code": "example.org" - } + "product_family_code": "example.org", + }, } def test_submission_review_launch_success(self): tool_conf, login_request, login_response = self._make_oidc_login() launch_request = self._get_request(login_request, login_response) - validated_message_launch = self._launch(launch_request, tool_conf, force_validation=True) + validated_message_launch = self._launch( + launch_request, tool_conf, force_validation=True + ) message_launch_data = validated_message_launch.get_launch_data() self.assertDictEqual(message_launch_data, self.expected_launch_data) self.assertTrue(validated_message_launch.is_submission_review_launch()) self.assertDictEqual( validated_message_launch.get_submission_review_user(), - self.expected_launch_data.get('https://purl.imsglobal.org/spec/lti/claim/for_user') + self.expected_launch_data.get( + "https://purl.imsglobal.org/spec/lti/claim/for_user" + ), ) diff --git a/tests/test_tool_conf.py b/tests/test_tool_conf.py index e0fafbc..05757b5 100644 --- a/tests/test_tool_conf.py +++ b/tests/test_tool_conf.py @@ -3,7 +3,6 @@ class TestToolConf(TestServicesBase): - def test_get_jwks(self): tc = get_test_tool_conf() jwks = tc.get_jwks("https://canvas.instructure.com") @@ -14,21 +13,23 @@ def test_get_jwks(self): "kid": "NtQYzsKs_TWLQ0p3bLmfM7fOwY0nEBVVH3z3Q-zJ06Y", "kty": "RSA", "n": "uvEnCaUOy1l9gk3wjW3Pib1dBc5g92-6rhvZZOsN1a77fdOqKsrjWG1lDu8kq2nL-wbAzR3DdEPVw_1WU" - "wtr_Q1d5m-7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS-ujLfsRc2ImeLP5OHxpE1" - "yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT_f7iK47Mp2iQd4KYYG7YZ7lMMPCMBuhej7SOtZQ2" - "FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6aD2-HUxYztVIPCkhyO84mQ7W4BFsOnKW4WRfEySHXd2hZkFMgcF" - "NXY3dA6de519qlcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5jWYBg0ILgULo7ve7VXG5lMwa" - "sW_ch2zKp7tTILnDJwITMjF71h4fn4dMTun_7MWEtSl_iFiALnIL_4_YY717cr4rmcG1424LyxJGRD9L9W" - "jO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdVRH7qN_erfGHmykmVGgH8AfK9GLT_cjN4GHA29bK9j" - "Med6SWdrkygbQmlnsCAHrw0RA-QE0t617h3uTrSEr5vkbLz-KThVEBfH84qsweqcac_unKIZ0e2iRuyVnG" - "4cbq8HUdio8gJ62D3wZ0UvVgr4a0", + "wtr_Q1d5m-7S4ciXT63pENs1EPwWmeN33O0zkGx8I7vdiOTSVoywEyUZe6UyS-ujLfsRc2ImeLP5OHxpE1" + "yULEDSiMLtSvgzEaMvf2AkVq5EL5nLYDWXZWXUnpiT_f7iK47Mp2iQd4KYYG7YZ7lMMPCMBuhej7SOtZQ2" + "FwaBjvZiXDZ172sQYBCiBAmOR3ofTL6aD2-HUxYztVIPCkhyO84mQ7W4BFsOnKW4WRfEySHXd2hZkFMgcF" + "NXY3dA6de519qlcrL0YYx8ZHpzNt0foEzUsgJd8uJMUVvzPZgExwcyIbv5jWYBg0ILgULo7ve7VXG5lMwa" + "sW_ch2zKp7tTILnDJwITMjF71h4fn4dMTun_7MWEtSl_iFiALnIL_4_YY717cr4rmcG1424LyxJGRD9L9W" + "jO8etAbPkiRFJUd5fmfqjHkO6fPxyWsMUAu8bfYdVRH7qN_erfGHmykmVGgH8AfK9GLT_cjN4GHA29bK9j" + "Med6SWdrkygbQmlnsCAHrw0RA-QE0t617h3uTrSEr5vkbLz-KThVEBfH84qsweqcac_unKIZ0e2iRuyVnG" + "4cbq8HUdio8gJ62D3wZ0UvVgr4a0", "alg": "RS256", - "use": "sig" + "use": "sig", } ] } self.assertEqual(jwks, expected_jwks) tc_extended = get_test_tool_conf(tool_conf_extended=True) - jwks = tc_extended.get_jwks("https://canvas.instructure.com", client_id="10000000000004") + jwks = tc_extended.get_jwks( + "https://canvas.instructure.com", client_id="10000000000004" + ) self.assertEqual(jwks, expected_jwks) diff --git a/tests/test_utils.py b/tests/test_utils.py index ffdff16..0ceea66 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,16 +3,28 @@ class TestUtils(unittest.TestCase): - def test_add_param_to_url(self): - res = add_param_to_url('https://lms.example.com/class/2923/groups/sets', 'user_id', 123) - self.assertEqual(res, 'https://lms.example.com/class/2923/groups/sets?user_id=123') + res = add_param_to_url( + "https://lms.example.com/class/2923/groups/sets", "user_id", 123 + ) + self.assertEqual( + res, "https://lms.example.com/class/2923/groups/sets?user_id=123" + ) - res = add_param_to_url('https://lms.example.com/class/2923/groups/sets?some=xxx', 'user_id', 123) - self.assertIn(res, [ - 'https://lms.example.com/class/2923/groups/sets?some=xxx&user_id=123', - 'https://lms.example.com/class/2923/groups/sets?user_id=123&some=xxx' - ]) + res = add_param_to_url( + "https://lms.example.com/class/2923/groups/sets?some=xxx", "user_id", 123 + ) + self.assertIn( + res, + [ + "https://lms.example.com/class/2923/groups/sets?some=xxx&user_id=123", + "https://lms.example.com/class/2923/groups/sets?user_id=123&some=xxx", + ], + ) - res = add_param_to_url('https://lms.example.com/class/2923/groups/sets?user_id=456', 'user_id', 123) - self.assertEqual(res, 'https://lms.example.com/class/2923/groups/sets?user_id=123') + res = add_param_to_url( + "https://lms.example.com/class/2923/groups/sets?user_id=456", "user_id", 123 + ) + self.assertEqual( + res, "https://lms.example.com/class/2923/groups/sets?user_id=123" + ) diff --git a/tests/tool_config.py b/tests/tool_config.py index a0aa818..2799cd0 100644 --- a/tests/tool_config.py +++ b/tests/tool_config.py @@ -1,4 +1,3 @@ -import warnings from pylti1p3.tool_config import ToolConfDict @@ -10,7 +9,7 @@ "key_set_url": "https://lti-ri.imsglobal.org/platforms/370/platform_keys/361.json", "key_set": None, "private_key_file": "private.key", - "deployment_ids": ["py1234"] + "deployment_ids": ["py1234"], }, "https://canvas.instructure.com": { "client_id": "10000000000004", @@ -19,8 +18,8 @@ "key_set_url": "http://canvas.docker/api/lti/security/jwks", "key_set": None, "private_key_file": "private.key", - "deployment_ids": ["6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb"] - } + "deployment_ids": ["6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb"], + }, } TOOL_CONFIG_ONE_ISSUES_MANY_CLIENTS = { @@ -32,27 +31,30 @@ "key_set_url": "https://lti-ri.imsglobal.org/platforms/370/platform_keys/361.json", "key_set": None, "private_key_file": "private.key", - "deployment_ids": ["py1234"] + "deployment_ids": ["py1234"], }, - "https://canvas.instructure.com": [{ - "default": False, - "client_id": "10000000000000", - "auth_login_url": "http://canvas.docker/api/lti/authorize_redirect", - "auth_token_url": "http://canvas.docker/login/oauth2/token", - "key_set_url": "http://canvas.docker/api/lti/security/jwks", - "key_set": None, - "private_key_file": "private.key", - "deployment_ids": ["6:xxxx"] - }, { - "default": True, - "client_id": "10000000000004", - "auth_login_url": "http://canvas.docker/api/lti/authorize_redirect", - "auth_token_url": "http://canvas.docker/login/oauth2/token", - "key_set_url": "http://canvas.docker/api/lti/security/jwks", - "key_set": None, - "private_key_file": "private.key", - "deployment_ids": ["6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb"] - }] + "https://canvas.instructure.com": [ + { + "default": False, + "client_id": "10000000000000", + "auth_login_url": "http://canvas.docker/api/lti/authorize_redirect", + "auth_token_url": "http://canvas.docker/login/oauth2/token", + "key_set_url": "http://canvas.docker/api/lti/security/jwks", + "key_set": None, + "private_key_file": "private.key", + "deployment_ids": ["6:xxxx"], + }, + { + "default": True, + "client_id": "10000000000004", + "auth_login_url": "http://canvas.docker/api/lti/authorize_redirect", + "auth_token_url": "http://canvas.docker/login/oauth2/token", + "key_set_url": "http://canvas.docker/api/lti/security/jwks", + "key_set": None, + "private_key_file": "private.key", + "deployment_ids": ["6:8865aa05b4b79b64a91a86042e43af5ea8ae79eb"], + }, + ], } @@ -133,27 +135,13 @@ def get_test_tool_conf(tool_conf_cls=None, tool_conf_extended=False): for iss, iss_conf in tc.items(): if isinstance(iss_conf, list): for iss_conf_item in iss_conf: - tool_conf.set_private_key(iss, PRIVATE_KEY, client_id=iss_conf_item['client_id']) - tool_conf.set_public_key(iss, PUBLIC_KEY, client_id=iss_conf_item['client_id']) + tool_conf.set_private_key( + iss, PRIVATE_KEY, client_id=iss_conf_item["client_id"] + ) + tool_conf.set_public_key( + iss, PUBLIC_KEY, client_id=iss_conf_item["client_id"] + ) else: tool_conf.set_private_key(iss, PRIVATE_KEY) tool_conf.set_public_key(iss, PUBLIC_KEY) return tool_conf - - -class ToolConfDeprecated(ToolConfDict): - """ - Conf class to check backward compatibility with the previous implementation - of ToolConfAbstract.find_registration_by_issuer - - """ - def find_registration(self, iss, *args, **kwargs): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - result = super(ToolConfDeprecated, self).find_registration(iss, *args, **kwargs) - assert issubclass(w[-1].category, DeprecationWarning) - assert "find_registration_by_issuer" in str(w[-1].message) - return result - - def find_registration_by_issuer(self, iss): # pylint: disable=arguments-differ,useless-super-delegation - return super(ToolConfDeprecated, self).find_registration_by_issuer(iss) diff --git a/tox.ini b/tox.ini index 1a3282c..910bdff 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,26 @@ [tox] -envlist = py27, py35, py36, py37, py38, py39 +envlist = py36, py37, py38, py39, py310 [testenv] commands = - pycodestyle . -v --show-source --show-pep8 + flake8 . pylint --rcfile=pylintrc pylti1p3 tests mypy pylti1p3 + black . --check --diff coverage run -m unittest -v tests coverage report -m deps = + black coverage django + flake8 flask + jwcrypto mock - mypy==0.812 + mypy parameterized - pycodestyle pyjwt + pylint requests requests-mock - py27: jwcrypto==0.9.1 - py27: pylint==1.9.5 - py35: pylint==2.6.2 - py{36,37,38,39}: pylint==2.8.1 - py{35,36,37,38,39}: jwcrypto + types-requests