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.
- ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
- ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
- ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
- ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ltitoolkit/__init__.py +20 -0
- ltitoolkit/adapters/__init__.py +11 -0
- ltitoolkit/adapters/brightspace/__init__.py +35 -0
- ltitoolkit/adapters/brightspace/client.py +176 -0
- ltitoolkit/adapters/canvas/__init__.py +27 -0
- ltitoolkit/adapters/canvas/client.py +142 -0
- ltitoolkit/advantage/__init__.py +9 -0
- ltitoolkit/advantage/service.py +96 -0
- ltitoolkit/core/__init__.py +19 -0
- ltitoolkit/core/actions.py +6 -0
- ltitoolkit/core/assignments_grades.py +300 -0
- ltitoolkit/core/contrib/__init__.py +0 -0
- ltitoolkit/core/contrib/django/__init__.py +5 -0
- ltitoolkit/core/contrib/django/cookie.py +56 -0
- ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
- ltitoolkit/core/contrib/django/message_launch.py +39 -0
- ltitoolkit/core/contrib/django/oidc_login.py +41 -0
- ltitoolkit/core/contrib/django/redirect.py +34 -0
- ltitoolkit/core/contrib/django/request.py +32 -0
- ltitoolkit/core/contrib/django/session.py +5 -0
- ltitoolkit/core/contrib/flask/__init__.py +7 -0
- ltitoolkit/core/contrib/flask/cookie.py +34 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
- ltitoolkit/core/contrib/flask/message_launch.py +32 -0
- ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
- ltitoolkit/core/contrib/flask/redirect.py +34 -0
- ltitoolkit/core/contrib/flask/request.py +40 -0
- ltitoolkit/core/contrib/flask/session.py +5 -0
- ltitoolkit/core/contrib/py.typed +0 -0
- ltitoolkit/core/cookie.py +17 -0
- ltitoolkit/core/cookies_allowed_check.py +151 -0
- ltitoolkit/core/course_groups.py +115 -0
- ltitoolkit/core/deep_link.py +100 -0
- ltitoolkit/core/deep_link_resource.py +96 -0
- ltitoolkit/core/deployment.py +13 -0
- ltitoolkit/core/exception.py +16 -0
- ltitoolkit/core/grade.py +143 -0
- ltitoolkit/core/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/launch_data_storage/base.py +75 -0
- ltitoolkit/core/launch_data_storage/cache.py +43 -0
- ltitoolkit/core/launch_data_storage/session.py +29 -0
- ltitoolkit/core/lineitem.py +205 -0
- ltitoolkit/core/message_launch.py +828 -0
- ltitoolkit/core/message_validators/__init__.py +13 -0
- ltitoolkit/core/message_validators/abstract.py +25 -0
- ltitoolkit/core/message_validators/deep_link.py +34 -0
- ltitoolkit/core/message_validators/privacy_launch.py +40 -0
- ltitoolkit/core/message_validators/resource_message.py +21 -0
- ltitoolkit/core/message_validators/submission_review.py +45 -0
- ltitoolkit/core/names_roles.py +97 -0
- ltitoolkit/core/oidc_login.py +275 -0
- ltitoolkit/core/py.typed +0 -0
- ltitoolkit/core/redirect.py +24 -0
- ltitoolkit/core/registration.py +119 -0
- ltitoolkit/core/request.py +17 -0
- ltitoolkit/core/roles.py +109 -0
- ltitoolkit/core/service_connector.py +144 -0
- ltitoolkit/core/session.py +70 -0
- ltitoolkit/core/tool_config/__init__.py +4 -0
- ltitoolkit/core/tool_config/abstract.py +117 -0
- ltitoolkit/core/tool_config/dict.py +253 -0
- ltitoolkit/core/tool_config/json_file.py +100 -0
- ltitoolkit/core/tool_config/py.typed +0 -0
- ltitoolkit/core/utils.py +10 -0
- ltitoolkit/dynamic_registration/__init__.py +39 -0
- ltitoolkit/dynamic_registration/models.py +192 -0
- ltitoolkit/dynamic_registration/service.py +156 -0
- ltitoolkit/dynamic_registration/store.py +40 -0
- ltitoolkit/dynamic_registration/tool_conf.py +102 -0
- ltitoolkit/exceptions.py +42 -0
- ltitoolkit/fastapi/__init__.py +30 -0
- ltitoolkit/fastapi/cookie.py +53 -0
- ltitoolkit/fastapi/dynamic_registration.py +40 -0
- ltitoolkit/fastapi/message_launch.py +60 -0
- ltitoolkit/fastapi/oidc_login.py +47 -0
- ltitoolkit/fastapi/redirect.py +54 -0
- ltitoolkit/fastapi/request.py +77 -0
- ltitoolkit/fastapi/session.py +13 -0
- ltitoolkit/http.py +80 -0
- ltitoolkit/token/__init__.py +20 -0
- ltitoolkit/token/cache.py +47 -0
- 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,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"]
|