ff-ltitoolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
  2. ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
  3. ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
  4. ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  5. ltitoolkit/__init__.py +20 -0
  6. ltitoolkit/adapters/__init__.py +11 -0
  7. ltitoolkit/adapters/brightspace/__init__.py +35 -0
  8. ltitoolkit/adapters/brightspace/client.py +176 -0
  9. ltitoolkit/adapters/canvas/__init__.py +27 -0
  10. ltitoolkit/adapters/canvas/client.py +142 -0
  11. ltitoolkit/advantage/__init__.py +9 -0
  12. ltitoolkit/advantage/service.py +96 -0
  13. ltitoolkit/core/__init__.py +19 -0
  14. ltitoolkit/core/actions.py +6 -0
  15. ltitoolkit/core/assignments_grades.py +300 -0
  16. ltitoolkit/core/contrib/__init__.py +0 -0
  17. ltitoolkit/core/contrib/django/__init__.py +5 -0
  18. ltitoolkit/core/contrib/django/cookie.py +56 -0
  19. ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
  20. ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
  21. ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
  22. ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
  23. ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
  24. ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
  25. ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
  26. ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
  27. ltitoolkit/core/contrib/django/message_launch.py +39 -0
  28. ltitoolkit/core/contrib/django/oidc_login.py +41 -0
  29. ltitoolkit/core/contrib/django/redirect.py +34 -0
  30. ltitoolkit/core/contrib/django/request.py +32 -0
  31. ltitoolkit/core/contrib/django/session.py +5 -0
  32. ltitoolkit/core/contrib/flask/__init__.py +7 -0
  33. ltitoolkit/core/contrib/flask/cookie.py +34 -0
  34. ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
  35. ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
  36. ltitoolkit/core/contrib/flask/message_launch.py +32 -0
  37. ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
  38. ltitoolkit/core/contrib/flask/redirect.py +34 -0
  39. ltitoolkit/core/contrib/flask/request.py +40 -0
  40. ltitoolkit/core/contrib/flask/session.py +5 -0
  41. ltitoolkit/core/contrib/py.typed +0 -0
  42. ltitoolkit/core/cookie.py +17 -0
  43. ltitoolkit/core/cookies_allowed_check.py +151 -0
  44. ltitoolkit/core/course_groups.py +115 -0
  45. ltitoolkit/core/deep_link.py +100 -0
  46. ltitoolkit/core/deep_link_resource.py +96 -0
  47. ltitoolkit/core/deployment.py +13 -0
  48. ltitoolkit/core/exception.py +16 -0
  49. ltitoolkit/core/grade.py +143 -0
  50. ltitoolkit/core/launch_data_storage/__init__.py +0 -0
  51. ltitoolkit/core/launch_data_storage/base.py +75 -0
  52. ltitoolkit/core/launch_data_storage/cache.py +43 -0
  53. ltitoolkit/core/launch_data_storage/session.py +29 -0
  54. ltitoolkit/core/lineitem.py +205 -0
  55. ltitoolkit/core/message_launch.py +828 -0
  56. ltitoolkit/core/message_validators/__init__.py +13 -0
  57. ltitoolkit/core/message_validators/abstract.py +25 -0
  58. ltitoolkit/core/message_validators/deep_link.py +34 -0
  59. ltitoolkit/core/message_validators/privacy_launch.py +40 -0
  60. ltitoolkit/core/message_validators/resource_message.py +21 -0
  61. ltitoolkit/core/message_validators/submission_review.py +45 -0
  62. ltitoolkit/core/names_roles.py +97 -0
  63. ltitoolkit/core/oidc_login.py +275 -0
  64. ltitoolkit/core/py.typed +0 -0
  65. ltitoolkit/core/redirect.py +24 -0
  66. ltitoolkit/core/registration.py +119 -0
  67. ltitoolkit/core/request.py +17 -0
  68. ltitoolkit/core/roles.py +109 -0
  69. ltitoolkit/core/service_connector.py +144 -0
  70. ltitoolkit/core/session.py +70 -0
  71. ltitoolkit/core/tool_config/__init__.py +4 -0
  72. ltitoolkit/core/tool_config/abstract.py +117 -0
  73. ltitoolkit/core/tool_config/dict.py +253 -0
  74. ltitoolkit/core/tool_config/json_file.py +100 -0
  75. ltitoolkit/core/tool_config/py.typed +0 -0
  76. ltitoolkit/core/utils.py +10 -0
  77. ltitoolkit/dynamic_registration/__init__.py +39 -0
  78. ltitoolkit/dynamic_registration/models.py +192 -0
  79. ltitoolkit/dynamic_registration/service.py +156 -0
  80. ltitoolkit/dynamic_registration/store.py +40 -0
  81. ltitoolkit/dynamic_registration/tool_conf.py +102 -0
  82. ltitoolkit/exceptions.py +42 -0
  83. ltitoolkit/fastapi/__init__.py +30 -0
  84. ltitoolkit/fastapi/cookie.py +53 -0
  85. ltitoolkit/fastapi/dynamic_registration.py +40 -0
  86. ltitoolkit/fastapi/message_launch.py +60 -0
  87. ltitoolkit/fastapi/oidc_login.py +47 -0
  88. ltitoolkit/fastapi/redirect.py +54 -0
  89. ltitoolkit/fastapi/request.py +77 -0
  90. ltitoolkit/fastapi/session.py +13 -0
  91. ltitoolkit/http.py +80 -0
  92. ltitoolkit/token/__init__.py +20 -0
  93. ltitoolkit/token/cache.py +47 -0
  94. ltitoolkit/token/service.py +165 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: ff-ltitoolkit
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic LTI 1.3 Advantage toolkit for Python — connect any app to any LMS.
5
+ Project-URL: Homepage, https://github.com/CNIT-Organization/ltitoolkit
6
+ Project-URL: Repository, https://github.com/CNIT-Organization/ltitoolkit
7
+ Project-URL: Issues, https://github.com/CNIT-Organization/ltitoolkit/issues
8
+ Author: Faisal Fida
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: canvas,education,lms,lti,lti-advantage,lti1.3,moodle,oidc
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Education
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Security
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: jwcrypto>=1.5
25
+ Requires-Dist: pyjwt[crypto]<3,>=2.8
26
+ Requires-Dist: requests>=2.31
27
+ Requires-Dist: typing-extensions>=4.9
28
+ Provides-Extra: fastapi
29
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
30
+ Requires-Dist: itsdangerous>=2.1; extra == 'fastapi'
31
+ Requires-Dist: python-multipart>=0.0.9; extra == 'fastapi'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # ltitoolkit
35
+
36
+ A framework-agnostic **LTI 1.3 Advantage** toolkit for Python. Connect any Python
37
+ application to any LTI 1.3 compliant LMS — Canvas, Moodle, Blackboard, and more —
38
+ with a single dependency.
39
+
40
+ > Status: **early development (v0.1.0)** — the vendored LTI engine is in place;
41
+ > the FastAPI adapter, Dynamic Registration, and token minting are being built.
42
+ > See [`docs/PROJECT.md`](./docs/PROJECT.md) for the design rationale, capability
43
+ > boundaries, and roadmap.
44
+
45
+ ## What it does (portable — same code on every LMS)
46
+
47
+ - **Launch & identity** — verified OIDC/JWT launch: who the user is, which course,
48
+ what role.
49
+ - **LTI Advantage**
50
+ - **AGS** — read/write grades for your tool's activities.
51
+ - **NRPS** — fetch the current course roster.
52
+ - **Deep Linking** — let instructors embed your content into a course.
53
+ - **Dynamic Registration** — install on a new LMS by pasting one URL (no credentials).
54
+ - **Client-credentials tokens** — authenticate as the tool with its own key; no user
55
+ login required.
56
+
57
+ ## What it deliberately does **not** do
58
+
59
+ LTI is not a remote control for the whole LMS. Listing all courses, browsing/opening
60
+ files, or creating native quizzes require each LMS's **proprietary** REST API and are
61
+ **not** part of this portable core. Put that code in thin, per-LMS adapters (e.g. a
62
+ Canvas adapter) built on top of the toolkit's generic token minting.
63
+
64
+ ## Layout
65
+
66
+ ```
67
+ src/ltitoolkit/
68
+ ├── core/ # vendored LTI 1.3 engine (PyLTI1p3, rebranded) — internal
69
+ ├── fastapi/ # FastAPI adapter (Phase 2)
70
+ ├── token/ # generic client-credentials token minting (Phase 4)
71
+ └── dynamic_registration/ # single-URL install (Phase 5)
72
+ ```
73
+
74
+ ## Install (as a dependency)
75
+
76
+ Published via Git (no PyPI required). Pin to a tag:
77
+
78
+ ```bash
79
+ pip install "git+https://github.com/CNIT-Organization/ltitoolkit.git@v0.1.0"
80
+ # with the FastAPI adapter:
81
+ pip install "ltitoolkit[fastapi] @ git+https://github.com/CNIT-Organization/ltitoolkit.git@v0.1.0"
82
+ ```
83
+
84
+ ## Develop (uv)
85
+
86
+ ```bash
87
+ uv sync # create the env + install runtime, fastapi extra, and dev tools
88
+ uv run pytest # tests
89
+ uv run ruff check src tests
90
+ uv run mypy src
91
+ uv build # build wheel + sdist into dist/
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT. The `core/` engine is a vendored copy of
97
+ [PyLTI1p3](https://github.com/dmitry-viskov/pylti1.3) (MIT); its original license
98
+ is preserved in [`LICENSE`](./LICENSE).
@@ -0,0 +1,94 @@
1
+ ltitoolkit/__init__.py,sha256=Co8qSrVcXduzGMSUNl9ysjVfoGyJxzWog1xJXEbHWec,863
2
+ ltitoolkit/exceptions.py,sha256=HpiQnT-lPeKp-N6dmH6Fbi4kdqHrmJSPN_ttZXEQG_4,1353
3
+ ltitoolkit/http.py,sha256=KgUtnfuDcux1O9Ann0GBpf-YGGR2DdOp7jYScq_Ycz0,2808
4
+ ltitoolkit/adapters/__init__.py,sha256=LLFys4S97EWFhJ1Nmg1NkzHFGHCpukFpVb53YP92niM,590
5
+ ltitoolkit/adapters/brightspace/__init__.py,sha256=3NOhQeEQCdIGHPyKgfXlhED0vlCo9CpgB_PIIrdT2og,976
6
+ ltitoolkit/adapters/brightspace/client.py,sha256=0v_QgqXz2sMdaQZwo6apvEaKoQdBuqa6sCXBNlIFz2o,6857
7
+ ltitoolkit/adapters/canvas/__init__.py,sha256=xLFpt4hTwWRVSPLAhF-cvMKT_qGhxdolrOCMY1pgCfM,764
8
+ ltitoolkit/adapters/canvas/client.py,sha256=ueUaH5xmD6siAe0cI3ZVR1PDLVkhjxd0t8_YMsyTi2I,5591
9
+ ltitoolkit/advantage/__init__.py,sha256=IUNagJR2pF5q72t9uMRKaupdV4uLvMpBgyfmi1BhQm4,335
10
+ ltitoolkit/advantage/service.py,sha256=XTp6XCNTUvRPF8wYmYwnHKukTmPaM4IGp8j0hXjzmzE,3576
11
+ ltitoolkit/core/__init__.py,sha256=Lz1LFiJDTWon1XUMARh-38m0huj_81q70ZKC6j9oews,830
12
+ ltitoolkit/core/actions.py,sha256=WY6_BfIrebRX7PHmP7ztows0N5ROxtNiRbgasY6oGXU,135
13
+ ltitoolkit/core/assignments_grades.py,sha256=oG4hfGUO-cTuiB4845bisGVY2BLtSzNydLPAv4receY,10784
14
+ ltitoolkit/core/cookie.py,sha256=ecu8e0zyz9OSg26S6yn2oTsDT31mehJdmwl1P1Tdf64,417
15
+ ltitoolkit/core/cookies_allowed_check.py,sha256=wJTy8GLoBv12HtIpiaRGPZV8nKpc7gCWpjOnC6sntmg,4867
16
+ ltitoolkit/core/course_groups.py,sha256=mjsXltN6i_BzBGuVfvkxPlxrJunWCs_WGbsYOS2M5Co,3268
17
+ ltitoolkit/core/deep_link.py,sha256=TPDOpK7jsmtlsTw-FqN4wWvyN5E8VRMMqAFv7hgsKWE,3501
18
+ ltitoolkit/core/deep_link_resource.py,sha256=1JTd2Fcz2rywbPL5hZnDK8lE7n-42E2e_9NlCkgFPDU,2698
19
+ ltitoolkit/core/deployment.py,sha256=ctqH-s-PiGKPunDPuSb4yE1h7iAtBXR6oMzQbLdU1hU,305
20
+ ltitoolkit/core/exception.py,sha256=s-OumXf3eEWv5IwYw2f2rirhKhkf1SgUjC8XmPuvmRE,355
21
+ ltitoolkit/core/grade.py,sha256=CKtUfzYikEr0nHX-4uAMmCeIOHT1VLacVX8tHklyLR0,4492
22
+ ltitoolkit/core/lineitem.py,sha256=RltsnKZo7JBkJtx4n1tvDffW_sXlLbOOmCGX9ocrAAI,6268
23
+ ltitoolkit/core/message_launch.py,sha256=NiFxEhe1Lv_T4csQFrVHen_DlUwQ-nfEXi166VbnVbE,29057
24
+ ltitoolkit/core/names_roles.py,sha256=06uObxqqjeUz5A-pzPh3qngrFTbZvBb9w7Q8vyBH0X0,2975
25
+ ltitoolkit/core/oidc_login.py,sha256=gFLJampd9QgDGe0mj5Z-VvvTLgOzmOxHSBKaPnC630g,9923
26
+ ltitoolkit/core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
+ ltitoolkit/core/redirect.py,sha256=WQgQDmldqWXDqia2bFZrtVgeNgyjmiXi76xfTun1J1U,512
28
+ ltitoolkit/core/registration.py,sha256=hDMrS2zG8Eqr5ZXjNT0ahlSrQKNuoTcHbpaYcrc80g0,3889
29
+ ltitoolkit/core/request.py,sha256=mBmjkfPOfNT2Ic496JcV-_Bvy0PhroJEhTMfghWurE8,342
30
+ ltitoolkit/core/roles.py,sha256=2QNMwDJh_FzpozCAeTj1prtb4pI7JT2zclEwDev8syQ,3444
31
+ ltitoolkit/core/service_connector.py,sha256=MLyDUhmPnVS42j7ptVjWISdL-llp6wA5dfcSt9dUlUI,4948
32
+ ltitoolkit/core/session.py,sha256=dqr2CnWG6MLFgf7zJDPnoIDyZivJjKXc71rrrjzgzLg,2537
33
+ ltitoolkit/core/utils.py,sha256=kyTYEOXaGsZr6Qd_cF7bHWFX3T7GnujmtSlezVylHcM,398
34
+ ltitoolkit/core/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
+ ltitoolkit/core/contrib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
+ ltitoolkit/core/contrib/django/__init__.py,sha256=OFQWKv2Jnm0PCgR08r7A7PEb-XaXnT01E8ox301M5eg,214
37
+ ltitoolkit/core/contrib/django/cookie.py,sha256=aUCcCeaxE63CHZpOVQOXtYfGPRAaEUQmW69Gfvf9VTc,2104
38
+ ltitoolkit/core/contrib/django/message_launch.py,sha256=F04FRN0SiD3K--7BsvTSzB6xFb8TxiZ80uMfHHNyRuM,1156
39
+ ltitoolkit/core/contrib/django/oidc_login.py,sha256=KGqtJh6YnyWs-zXyNp7QRtvd-MX6GCwPote-HZVDS3A,1206
40
+ ltitoolkit/core/contrib/django/redirect.py,sha256=YVrDq-hFq6nvgdEScr-muMtY2FTmt84S8npGG5Q_A4s,1005
41
+ ltitoolkit/core/contrib/django/request.py,sha256=iPqA1q0ypqnEmg3L9AgOA9kRnnZOqJlN9SEfp6Vktd0,910
42
+ ltitoolkit/core/contrib/django/session.py,sha256=rwy9cuREpRRXndEHdq4-3lmUcZK6puiw1fdRI_BXITQ,106
43
+ ltitoolkit/core/contrib/django/launch_data_storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
+ ltitoolkit/core/contrib/django/launch_data_storage/cache.py,sha256=uoOq8xexkPUsEv9yVTOKO_MODFJlLVE48R-hKm2XoyM,337
45
+ ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py,sha256=1NX0pPIOqfthTHQ6gVW_fX1bwJmYKZolZGbWOsknStA,4467
46
+ ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py,sha256=KOuEM91zvvORvikkde-_VacKnW_DOAeCA6l3KePBKY8,1344
47
+ ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py,sha256=1g8CrH8eCxlzK4VNQ7X_U6g7P_7PHOXGfm_BTY1Deog,195
48
+ ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py,sha256=X4VR3jawccaE_4KaGb9j7LCSZaROHeeh8loQUa3-zjE,5826
49
+ ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py,sha256=CLpCZA42jFmgJJGB9tgCa4FlQztB5wCFlyA-Ckdr52E,6085
50
+ ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
+ ltitoolkit/core/contrib/flask/__init__.py,sha256=KwwFgHZp48KqEooGP0HKryOHwd2NO7VYTE34jb07Dys,276
52
+ ltitoolkit/core/contrib/flask/cookie.py,sha256=cjLEw4jMgf8lmVvxx7Ju1zC_AOrywBW5D5ldGra0ss0,1072
53
+ ltitoolkit/core/contrib/flask/message_launch.py,sha256=T3L01Gt0Y0qFRpfcebHjy3rJVHVyl1wEUbHj-DIpybQ,901
54
+ ltitoolkit/core/contrib/flask/oidc_login.py,sha256=C4t2RbgRjHdngu25KEVpH8WAgZkSm3fvHPCrdlmCw3g,935
55
+ ltitoolkit/core/contrib/flask/redirect.py,sha256=zebVxFZ7UxSE_xi-K_lOQQ2UkQziG5iDwxqQ2c9ocFQ,957
56
+ ltitoolkit/core/contrib/flask/request.py,sha256=jxucbaA7bJQvHva5ZumXZBcL3OI5_ixjWRgFtB_BN_M,1171
57
+ ltitoolkit/core/contrib/flask/session.py,sha256=G4vP7P4ywTD_yzMvY1F4J9UPqULLqPvpMjz_JpiVV2E,105
58
+ ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
+ ltitoolkit/core/contrib/flask/launch_data_storage/cache.py,sha256=LfXc9N7H44rLdeB8qneiNhkZZFnIhnRego9bkvQ8nwY,250
60
+ ltitoolkit/core/launch_data_storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
+ ltitoolkit/core/launch_data_storage/base.py,sha256=N06Ag9ySzISgWCUV-FAuAP1B09oCoZ_Bli6TG4ldB4Q,2301
62
+ ltitoolkit/core/launch_data_storage/cache.py,sha256=R5hLQhckHhuFjnEvPAAVS4pp5TyTTpnK14npUOMlLnU,1577
63
+ ltitoolkit/core/launch_data_storage/session.py,sha256=NR7YIHZcR2PvWq3jY6z0rhNKFSCoZbWKocKJMwVBnVI,936
64
+ ltitoolkit/core/message_validators/__init__.py,sha256=Oia7N0EyQLYRWSL8FHtIz92yleWnBViVcXKXzONnX2I,409
65
+ ltitoolkit/core/message_validators/abstract.py,sha256=RRiz3R85df2qpgqhHeO7qQA0S66d0BytIwItP1ZEc-8,805
66
+ ltitoolkit/core/message_validators/deep_link.py,sha256=MQ-sPmJcPnGF3C0XCHGhjMAtVD_HnKroM042CjZx1fs,1246
67
+ ltitoolkit/core/message_validators/privacy_launch.py,sha256=K3tNX_ygfFYk2RumWzFwHw8CGGX0dbGEI-19v50wCF8,1349
68
+ ltitoolkit/core/message_validators/resource_message.py,sha256=eQzShJHVP8mpafmOyvfgM1xGgjnMb9iEzeTE_6MaxN4,657
69
+ ltitoolkit/core/message_validators/submission_review.py,sha256=qxD28-buYRkduGPkBTyz4A7KQ2C88X88y3fSgyA4sDY,1607
70
+ ltitoolkit/core/tool_config/__init__.py,sha256=EUiHrHGKBhZ72hYqdUTMHA2m3Z-r-XgC-3Sj_jwVp5U,125
71
+ ltitoolkit/core/tool_config/abstract.py,sha256=ChK8YfJiezkK4Gbvf5jnt_6Shr1jCHbzjNSwMjuFGBk,4718
72
+ ltitoolkit/core/tool_config/dict.py,sha256=iPiA0Ng2io7hf4JKVQJaJ2J5MxryOT16tKNmpTyZ4yY,10658
73
+ ltitoolkit/core/tool_config/json_file.py,sha256=OMgwsbH36lO42GguDe-MWwpQfp6-7lgN53LR58Sh5Oo,4597
74
+ ltitoolkit/core/tool_config/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
+ ltitoolkit/dynamic_registration/__init__.py,sha256=Dp7UfGaL-Ovcv4b3p_n4lFbpHoBx2Y3ZfeRZTMSvjJI,1134
76
+ ltitoolkit/dynamic_registration/models.py,sha256=qkrPnpfK_Fgik-CosCBQAqNa5NWj4y6I3UPou9PWA2Q,6797
77
+ ltitoolkit/dynamic_registration/service.py,sha256=Nvk2eeiJum8bhYkJnRBopLjidK_0ukeRY7kmyTzAh1o,5836
78
+ ltitoolkit/dynamic_registration/store.py,sha256=2REKtCo5v-1R-LCDT18hor8rk4Kifx_6R1E9pfOgnT0,1451
79
+ ltitoolkit/dynamic_registration/tool_conf.py,sha256=FnYiwrS7o_GXeNy7PoAduM0wC59bRx80lIErp4tAlJs,4012
80
+ ltitoolkit/fastapi/__init__.py,sha256=1ARAh3InJSUdcspOIcMQuXm7DrT_3Ojl3vZNuOXUqkY,1117
81
+ ltitoolkit/fastapi/cookie.py,sha256=TI4R9zmph1dcft8xPaAj3RKPACYbCUyK7YP5oMkhzzQ,1903
82
+ ltitoolkit/fastapi/dynamic_registration.py,sha256=FWZG8QQjsLIJL3xIG7a8B6tBvgH1FMiftCkxg6jl_so,1632
83
+ ltitoolkit/fastapi/message_launch.py,sha256=Y8NHagx8nu7IqPGk8pb28-_oEcniSOAq31QKYT7VFm8,2095
84
+ ltitoolkit/fastapi/oidc_login.py,sha256=kCjybspHQx2bJEEqkmYmlr8fkCUrLfrVtQ9V4u3i5fw,1672
85
+ ltitoolkit/fastapi/redirect.py,sha256=GuJmOMoSt3G6P4bNgv-j93_7VAcXMlc-e6Fd3vhsEiE,1642
86
+ ltitoolkit/fastapi/request.py,sha256=olrg4IC5Ab1tMeOh2ojRQ0Vmm4AkpS9lGuMP8z1KoGk,2721
87
+ ltitoolkit/fastapi/session.py,sha256=6eIgtZGlgUVwSp4RCI1Cuia5Pqc1MjVtlDGxTZ3JleY,426
88
+ ltitoolkit/token/__init__.py,sha256=OgpavFZ2Llx91fZbj-TcxQ97Fky7UMqnUe8A-ZuuJZo,717
89
+ ltitoolkit/token/cache.py,sha256=pF0c4zvpFpXgfNxqatVt5vmVoZoqaurrfMlyOy_cSlQ,1473
90
+ ltitoolkit/token/service.py,sha256=SkbJnKD_HpzXUG1MgakXy_ilP3b0xBb5lli00v9hFcY,6121
91
+ ff_ltitoolkit-0.1.0.dist-info/METADATA,sha256=XpqgfHKyyIzO9OeY7LnLSazE82HhizZfKF-kCnKQ5mQ,3820
92
+ ff_ltitoolkit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
93
+ ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE,sha256=Wu9360Rx7_xM3-0AOjY-UEMJiYqb7awBf3j0bhdukfg,1070
94
+ ff_ltitoolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Dmitry Viskov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ltitoolkit/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """ltitoolkit — a framework-agnostic LTI 1.3 Advantage toolkit for Python.
2
+
3
+ Connect any Python application to any LTI 1.3 compliant LMS (Canvas, Moodle,
4
+ Blackboard, …) with one dependency:
5
+
6
+ - LTI 1.3 launch + identity (who, which course, what role)
7
+ - LTI Advantage: Assignment & Grade Services (AGS), Names & Role Provisioning
8
+ (NRPS), and Deep Linking — portable across every LMS
9
+ - Dynamic Registration: single-URL, no-credentials tool install
10
+ - Generic client-credentials token minting for LMS service/API calls
11
+
12
+ The portable LTI engine lives in :mod:`ltitoolkit.core` (vendored). Framework
13
+ glue lives in adapters such as :mod:`ltitoolkit.fastapi`. LMS-proprietary REST
14
+ calls (listing files, quizzes, etc.) are intentionally *not* part of the core —
15
+ they belong in thin, separate per-LMS adapters.
16
+ """
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = ["__version__"]
@@ -0,0 +1,11 @@
1
+ """Per-LMS adapters for *proprietary* (Layer 3) APIs.
2
+
3
+ Everything in this package is intentionally **outside** the portable LTI core.
4
+ LTI standardises identity, roster (NRPS), and grades (AGS) — but **not** browsing
5
+ course files, listing quizzes, or reading the full gradebook. Those require each
6
+ LMS's own REST API, which differs per vendor and is not portable.
7
+
8
+ Adapters here build on the portable pieces (`ltitoolkit.token` for auth,
9
+ `ltitoolkit.http` for sessions, `ltitoolkit.exceptions` for errors) but call
10
+ vendor-specific endpoints. Use them only against the LMS they target.
11
+ """
@@ -0,0 +1,35 @@
1
+ """Brightspace (D2L) proprietary REST API adapter (Layer 3 — NOT portable).
2
+
3
+ Calls Brightspace's own Valence LE API (course content: modules, topics, files)
4
+ using the tool's OAuth2 client-credentials token minted for a Service User — no
5
+ user login. Requires the registered OAuth client to carry the matching scopes
6
+ (e.g. ``content:modules:read``); the LMS admin approves those once at install.
7
+
8
+ This works **only** on Brightspace. Other LMSs need their own adapter.
9
+ """
10
+
11
+ from .client import (
12
+ SCOPE_CONTENT_FILE_READ,
13
+ SCOPE_CONTENT_READ,
14
+ SCOPE_CONTENT_TOPICS_READ,
15
+ SCOPE_ENROLLMENT_READ,
16
+ TOPIC_FILE,
17
+ TOPIC_LINK,
18
+ TYPE_MODULE,
19
+ TYPE_TOPIC,
20
+ BrightspaceAPIClient,
21
+ TokenProvider,
22
+ )
23
+
24
+ __all__ = [
25
+ "BrightspaceAPIClient",
26
+ "TokenProvider",
27
+ "SCOPE_CONTENT_READ",
28
+ "SCOPE_CONTENT_TOPICS_READ",
29
+ "SCOPE_CONTENT_FILE_READ",
30
+ "SCOPE_ENROLLMENT_READ",
31
+ "TYPE_MODULE",
32
+ "TYPE_TOPIC",
33
+ "TOPIC_FILE",
34
+ "TOPIC_LINK",
35
+ ]
@@ -0,0 +1,176 @@
1
+ """A thin Brightspace (D2L) Valence REST API client — Layer 3, NOT portable.
2
+
3
+ Reads course content (modules / topics / files) from the Brightspace **Learning
4
+ Environment (LE)** API, authenticated by an OAuth2 *client-credentials* Bearer
5
+ token minted for a Brightspace **Service User** — no user login. The token's
6
+ scopes are Brightspace scopes (``content:modules:read`` …) approved once on the
7
+ registered OAuth client; the admin does that at install, the student does nothing.
8
+
9
+ Brightspace's client-credentials assertion (``iss``=``sub``=client_id, ``aud``=
10
+ token endpoint, RS256 + ``kid``) is exactly what
11
+ :class:`ltitoolkit.token.AccessTokenService` already produces — only the token
12
+ endpoint differs (``https://auth.brightspace.com/core/connect/token``).
13
+
14
+ Endpoints (per docs.valence.desire2learn.com):
15
+
16
+ GET /d2l/api/le/{ver}/{orgUnitId}/content/root/
17
+ GET /d2l/api/le/{ver}/{orgUnitId}/content/modules/{moduleId}/structure/
18
+ GET /d2l/api/le/{ver}/{orgUnitId}/content/topics/{topicId}
19
+ GET /d2l/api/le/{ver}/{orgUnitId}/content/topics/{topicId}/file
20
+
21
+ Works **only** on Brightspace. Other LMSs need their own adapter.
22
+
23
+ .. note::
24
+ Response shapes follow the Valence docs. Verify against the client's instance
25
+ and its LE ``version`` (see ``GET /d2l/api/le/versions/``) before the demo.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import typing as t
31
+
32
+ import requests
33
+
34
+ from ...exceptions import ExternalRequestError
35
+ from ...http import build_session
36
+
37
+ # OAuth2 scopes (format: ``<group>:<resource>:<action>``), approved on the
38
+ # registered OAuth client / Service User by the admin once.
39
+ SCOPE_CONTENT_READ = "content:modules:read"
40
+ SCOPE_CONTENT_TOPICS_READ = "content:topics:read"
41
+ SCOPE_CONTENT_FILE_READ = "content:file:read"
42
+ SCOPE_ENROLLMENT_READ = "enrollment:orgunit:read"
43
+
44
+ # ContentObject.Type values.
45
+ TYPE_MODULE = 0
46
+ TYPE_TOPIC = 1
47
+
48
+ # Topic.TopicType values.
49
+ TOPIC_FILE = 1
50
+ TOPIC_LINK = 3
51
+
52
+ # Default LE API version; override per instance.
53
+ _DEFAULT_LE_VERSION = "1.74"
54
+ # Recursion guard against pathological / cyclic module structures.
55
+ _MAX_MODULE_DEPTH = 25
56
+
57
+
58
+ @t.runtime_checkable
59
+ class TokenProvider(t.Protocol):
60
+ """Anything that can mint a Bearer token for a set of scopes."""
61
+
62
+ def get_token(self, scopes: t.Sequence[str]) -> str: ...
63
+
64
+
65
+ class BrightspaceAPIClient:
66
+ """Read-only convenience wrapper over the Brightspace LE content API."""
67
+
68
+ def __init__(
69
+ self,
70
+ base_url: str,
71
+ token_provider: TokenProvider,
72
+ scopes: t.Sequence[str],
73
+ *,
74
+ le_version: str = _DEFAULT_LE_VERSION,
75
+ session: requests.Session | None = None,
76
+ ) -> None:
77
+ self._base = base_url.rstrip("/")
78
+ self._tokens = token_provider
79
+ self._scopes = tuple(scopes)
80
+ self._ver = le_version
81
+ self._session = session if session is not None else build_session()
82
+
83
+ # -- public API --------------------------------------------------------
84
+
85
+ def get_content_root(self, org_unit_id: str | int) -> list[dict[str, t.Any]]:
86
+ """Top-level modules of a course (``Type == TYPE_MODULE``)."""
87
+ return self._get_list(self._le(org_unit_id, "content/root/"))
88
+
89
+ def get_module_structure(
90
+ self, org_unit_id: str | int, module_id: str | int
91
+ ) -> list[dict[str, t.Any]]:
92
+ """Direct children of a module — a mix of submodules and topics."""
93
+ return self._get_list(
94
+ self._le(org_unit_id, f"content/modules/{module_id}/structure/")
95
+ )
96
+
97
+ def get_topic(self, org_unit_id: str | int, topic_id: str | int) -> dict[str, t.Any]:
98
+ """Metadata for a single topic (``Title``, ``TopicType``, ``Url`` …)."""
99
+ return self._get_json(self._le(org_unit_id, f"content/topics/{topic_id}"))
100
+
101
+ def download_topic_file(
102
+ self, org_unit_id: str | int, topic_id: str | int, *, stream: bool = False
103
+ ) -> bytes:
104
+ """Raw bytes of a file-type topic (feed to NeuralMentor's ingestion)."""
105
+ params = {"stream": "true"} if stream else None
106
+ response = self._request(
107
+ self._le(org_unit_id, f"content/topics/{topic_id}/file"), params
108
+ )
109
+ return response.content
110
+
111
+ def list_course_topics(self, org_unit_id: str | int) -> list[dict[str, t.Any]]:
112
+ """Flat list of every topic in a course (walks the full module tree).
113
+
114
+ Each topic is the raw ContentObject; useful as "the course's lessons" to
115
+ feed into a lesson-generation pipeline. Submodules are traversed; cycles
116
+ and excessive depth are guarded against.
117
+ """
118
+ topics: list[dict[str, t.Any]] = []
119
+ seen: set[t.Any] = set()
120
+
121
+ def walk(entries: list[dict[str, t.Any]], depth: int) -> None:
122
+ if depth > _MAX_MODULE_DEPTH:
123
+ return
124
+ for obj in entries:
125
+ if obj.get("Type") == TYPE_TOPIC:
126
+ topics.append(obj)
127
+ elif obj.get("Type") == TYPE_MODULE:
128
+ module_id = obj.get("Id")
129
+ if module_id is None or module_id in seen:
130
+ continue
131
+ seen.add(module_id)
132
+ walk(self.get_module_structure(org_unit_id, module_id), depth + 1)
133
+
134
+ walk(self.get_content_root(org_unit_id), 0)
135
+ return topics
136
+
137
+ # -- internals ---------------------------------------------------------
138
+
139
+ def _le(self, org_unit_id: str | int, path: str) -> str:
140
+ return f"{self._base}/d2l/api/le/{self._ver}/{org_unit_id}/{path}"
141
+
142
+ def _headers(self) -> dict[str, str]:
143
+ token = self._tokens.get_token(self._scopes)
144
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
145
+
146
+ def _request(
147
+ self, url: str, params: dict[str, t.Any] | None = None
148
+ ) -> requests.Response:
149
+ try:
150
+ response = self._session.get(url, params=params, headers=self._headers())
151
+ except requests.Timeout as exc:
152
+ raise ExternalRequestError(
153
+ f"Timed out calling Brightspace: {exc}", url=url, is_timeout=True
154
+ ) from exc
155
+ except requests.RequestException as exc:
156
+ raise ExternalRequestError(
157
+ f"Error calling Brightspace: {exc}", url=url
158
+ ) from exc
159
+
160
+ if not response.ok:
161
+ raise ExternalRequestError(
162
+ "Brightspace API request failed",
163
+ status_code=response.status_code,
164
+ url=url,
165
+ response_text=response.text,
166
+ )
167
+ return response
168
+
169
+ def _get_json(self, url: str) -> dict[str, t.Any]:
170
+ return self._request(url).json()
171
+
172
+ def _get_list(self, url: str) -> list[dict[str, t.Any]]:
173
+ body = self._request(url).json()
174
+ if not isinstance(body, list):
175
+ raise ExternalRequestError("Expected a list response from Brightspace", url=url)
176
+ return body
@@ -0,0 +1,27 @@
1
+ """Canvas LMS proprietary REST API adapter (Layer 3 — NOT portable).
2
+
3
+ Calls Canvas's own REST API (files, quizzes, …) using the tool's LTI
4
+ client-credentials token — no user login. Requires the tool's developer key to
5
+ carry the matching Canvas API scopes (e.g. ``url:GET|/api/v1/courses/:id/files``);
6
+ the LMS admin approves those once at install.
7
+
8
+ This works **only** on Canvas. Other LMSs need their own adapter.
9
+ """
10
+
11
+ from .client import (
12
+ SCOPE_GET_FILE,
13
+ SCOPE_GET_FILE_PUBLIC_URL,
14
+ SCOPE_LIST_COURSE_FILES,
15
+ SCOPE_LIST_QUIZZES,
16
+ CanvasAPIClient,
17
+ TokenProvider,
18
+ )
19
+
20
+ __all__ = [
21
+ "CanvasAPIClient",
22
+ "TokenProvider",
23
+ "SCOPE_LIST_COURSE_FILES",
24
+ "SCOPE_GET_FILE",
25
+ "SCOPE_GET_FILE_PUBLIC_URL",
26
+ "SCOPE_LIST_QUIZZES",
27
+ ]
@@ -0,0 +1,142 @@
1
+ """A thin Canvas REST API client authenticated by the LTI tool token.
2
+
3
+ Authentication reuses :class:`ltitoolkit.token.AccessTokenService` — the tool
4
+ signs with its own key and exchanges it (client-credentials) for a Bearer token.
5
+ The token's scopes are Canvas API scopes (``url:METHOD|/api/v1/...``) declared on
6
+ the developer key, so **no user login is involved**.
7
+
8
+ Only a few high-value, read-only endpoints are wrapped here; add more as needed
9
+ following the same pattern. Note Canvas has *two* quiz systems: classic quizzes
10
+ (``/api/v1/courses/:id/quizzes``, wrapped below) and New Quizzes (a separate
11
+ ``/api/quiz/v1/...`` API) — extend with a dedicated method if you need the latter.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import typing as t
17
+
18
+ import requests
19
+
20
+ from ...exceptions import ExternalRequestError
21
+ from ...http import build_session
22
+
23
+ # Canvas API scopes (declared on the developer key, approved by the admin once).
24
+ SCOPE_LIST_COURSE_FILES = "url:GET|/api/v1/courses/:course_id/files"
25
+ SCOPE_GET_FILE = "url:GET|/api/v1/files/:id"
26
+ SCOPE_GET_FILE_PUBLIC_URL = "url:GET|/api/v1/files/:id/public_url"
27
+ SCOPE_LIST_QUIZZES = "url:GET|/api/v1/courses/:course_id/quizzes"
28
+
29
+ _DEFAULT_PER_PAGE = 50
30
+ _MAX_PAGES = 100 # safety bound against pathological pagination
31
+
32
+
33
+ @t.runtime_checkable
34
+ class TokenProvider(t.Protocol):
35
+ """Anything that can mint a Bearer token for a set of scopes."""
36
+
37
+ def get_token(self, scopes: t.Sequence[str]) -> str: ...
38
+
39
+
40
+ class CanvasAPIClient:
41
+ """Read-only convenience wrapper over a subset of the Canvas REST API."""
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ token_provider: TokenProvider,
47
+ scopes: t.Sequence[str],
48
+ *,
49
+ session: requests.Session | None = None,
50
+ per_page: int = _DEFAULT_PER_PAGE,
51
+ ) -> None:
52
+ self._base = base_url.rstrip("/")
53
+ self._tokens = token_provider
54
+ self._scopes = tuple(scopes)
55
+ self._session = session if session is not None else build_session()
56
+ self._per_page = per_page
57
+
58
+ # -- public API --------------------------------------------------------
59
+
60
+ def list_course_files(
61
+ self, course_id: str | int, **params: t.Any
62
+ ) -> list[dict[str, t.Any]]:
63
+ """List files in a course (all pages). Extra kwargs become query params."""
64
+ return self._get_paginated(f"/api/v1/courses/{course_id}/files", params)
65
+
66
+ def list_quizzes(self, course_id: str | int) -> list[dict[str, t.Any]]:
67
+ """List classic quizzes in a course (all pages)."""
68
+ return self._get_paginated(f"/api/v1/courses/{course_id}/quizzes")
69
+
70
+ def get_file(self, file_id: str | int) -> dict[str, t.Any]:
71
+ """Get a single file's metadata."""
72
+ return self._get(f"/api/v1/files/{file_id}")
73
+
74
+ def get_file_public_url(self, file_id: str | int) -> str:
75
+ """Get a temporary, directly-downloadable URL for a file's contents.
76
+
77
+ Preferred over proxying bytes: hand this URL to the browser to open the
78
+ PDF/document directly.
79
+ """
80
+ body = self._get(f"/api/v1/files/{file_id}/public_url")
81
+ url = body.get("public_url")
82
+ if not url:
83
+ raise ExternalRequestError(
84
+ "Canvas did not return a public_url for the file",
85
+ url=f"{self._base}/api/v1/files/{file_id}/public_url",
86
+ )
87
+ return url
88
+
89
+ # -- internals ---------------------------------------------------------
90
+
91
+ def _headers(self) -> dict[str, str]:
92
+ token = self._tokens.get_token(self._scopes)
93
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
94
+
95
+ def _request(self, url: str, params: dict[str, t.Any] | None = None) -> requests.Response:
96
+ try:
97
+ response = self._session.get(url, params=params, headers=self._headers())
98
+ except requests.Timeout as exc:
99
+ raise ExternalRequestError(
100
+ f"Timed out calling Canvas: {exc}", url=url, is_timeout=True
101
+ ) from exc
102
+ except requests.RequestException as exc:
103
+ raise ExternalRequestError(f"Error calling Canvas: {exc}", url=url) from exc
104
+
105
+ if not response.ok:
106
+ raise ExternalRequestError(
107
+ "Canvas API request failed",
108
+ status_code=response.status_code,
109
+ url=url,
110
+ response_text=response.text,
111
+ )
112
+ return response
113
+
114
+ def _get(self, path: str, params: dict[str, t.Any] | None = None) -> dict[str, t.Any]:
115
+ response = self._request(self._base + path, params)
116
+ return response.json()
117
+
118
+ def _get_paginated(
119
+ self, path: str, params: dict[str, t.Any] | None = None
120
+ ) -> list[dict[str, t.Any]]:
121
+ query = dict(params or {})
122
+ query.setdefault("per_page", self._per_page)
123
+
124
+ results: list[dict[str, t.Any]] = []
125
+ url: str | None = self._base + path
126
+ pages = 0
127
+ # First page carries query params; subsequent `next` links are absolute
128
+ # and already include them.
129
+ next_params: dict[str, t.Any] | None = query
130
+ while url and pages < _MAX_PAGES:
131
+ response = self._request(url, next_params)
132
+ body = response.json()
133
+ if not isinstance(body, list):
134
+ raise ExternalRequestError(
135
+ "Expected a list response from Canvas", url=url
136
+ )
137
+ results.extend(body)
138
+ next_link = response.links.get("next")
139
+ url = next_link["url"] if next_link else None
140
+ next_params = None
141
+ pages += 1
142
+ return results
@@ -0,0 +1,9 @@
1
+ """LTI Advantage convenience layer (AGS + NRPS).
2
+
3
+ Surfaces the portable Advantage services of a validated launch through a small,
4
+ documented facade so applications never reach into ``ltitoolkit.core``.
5
+ """
6
+
7
+ from .service import AdvantageServiceUnavailable, LaunchAdvantage
8
+
9
+ __all__ = ["LaunchAdvantage", "AdvantageServiceUnavailable"]