lti-client-mock 0.0.1__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ LTI_CLIENT_MOCK__LTI = '{"http://localhost:8484": [{"client_id": "lti-provider-mock", "auth_login_url": "http://localhost:8484/authorize", "auth_token_url": "http://localhost:8484/token", "key_set_url": "http://localhost:8484/jwks", "deployment_ids": ["1"]}]}'
2
+ LTI_PROVIDER_MOCK__AUTH_USERS = []
3
+ LTI_PROVIDER_MOCK__USERS = [{"id": "1", "given_name": "The", "family_name": "Admin", "email": "admin@example.com"}, {"id": "2", "given_name": "A", "family_name": "User", "email": "user1@example.com"}]
4
+ LTI_PROVIDER_MOCK__COURSES = [{"id": "1", "name": "Course 1"}, {"id": "2", "name": "Course 2", "users": ["2"]}]
5
+ LTI_PROVIDER_MOCK__LTI__ISS = http://localhost:8484
6
+ LTI_PROVIDER_MOCK__LTI__LOGIN_URL = http://localhost:8485/login
7
+ LTI_PROVIDER_MOCK__LTI__LAUNCH_URL = http://localhost:8485/launch
@@ -0,0 +1,13 @@
1
+ version: 2
2
+ updates:
3
+ # Check for updates of all GitHub Actions
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ interval: "weekly"
8
+
9
+ # Check for updates of all Python packages
10
+ - package-ecosystem: "pip"
11
+ directory: "/"
12
+ schedule:
13
+ interval: "weekly"
@@ -0,0 +1,41 @@
1
+ name: Code Style
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ ruff:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ python_version: ["3.11", "3.12", "3.13", "3.14"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+
20
+ - uses: actions/setup-python@v7
21
+ with:
22
+ python-version: ${{ matrix.python_version }}
23
+
24
+ - name: Add .local path
25
+ run: |
26
+ echo "PATH=$HOME/.local/bin:$PATH" >> $GITHUB_ENV
27
+ echo "PIPX_HOME=$HOME/.local/pipx" >> $GITHUB_ENV
28
+ echo "PIPX_BIN_DIR=$HOME/.local/bin" >> $GITHUB_ENV
29
+
30
+ - name: Install Base Dependencies
31
+ run: |
32
+ sudo apt-get install python3-venv pipx
33
+ pipx install hatch
34
+
35
+ - name: Run Ruff check
36
+ run: |
37
+ hatch run ruff-check
38
+
39
+ - name: Run Ruff format check
40
+ run: |
41
+ hatch run ruff-format
@@ -0,0 +1,39 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ id-token: write
10
+
11
+ jobs:
12
+ pypi:
13
+ runs-on: ubuntu-latest
14
+
15
+ environment:
16
+ name: pypi
17
+ url: https://pypi.org/project/lti-client-mock
18
+
19
+ permissions:
20
+ id-token: write
21
+
22
+ steps:
23
+ - uses: actions/checkout@v7
24
+
25
+ - uses: actions/setup-python@v7
26
+ with:
27
+ python-version: |
28
+ 3.11
29
+
30
+ - name: Install Base Dependencies
31
+ run: |
32
+ pip install --user build
33
+
34
+ - name: Build the package
35
+ run: |
36
+ python -m build
37
+
38
+ - name: Publish package distributions to PyPI
39
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,37 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ ruff:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ python_version: ["3.11", "3.12", "3.13", "3.14"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+
20
+ - uses: actions/setup-python@v7
21
+ with:
22
+ python-version: ${{ matrix.python_version }}
23
+
24
+ - name: Add .local path
25
+ run: |
26
+ echo "PATH=$HOME/.local/bin:$PATH" >> $GITHUB_ENV
27
+ echo "PIPX_HOME=$HOME/.local/pipx" >> $GITHUB_ENV
28
+ echo "PIPX_BIN_DIR=$HOME/.local/bin" >> $GITHUB_ENV
29
+
30
+ - name: Install Base Dependencies
31
+ run: |
32
+ sudo apt-get install python3-venv pipx
33
+ pipx install hatch
34
+
35
+ - name: Run tests
36
+ run: |
37
+ hatch run test
@@ -0,0 +1,2 @@
1
+ __pycache__
2
+ .coverage
@@ -0,0 +1,7 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.15.20
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
@@ -0,0 +1,9 @@
1
+ {
2
+ "python-envs.pythonProjects": [
3
+ {
4
+ "path": ".",
5
+ "envManager": "ms-python.python:system",
6
+ "packageManager": "ms-python.python:pip"
7
+ }
8
+ ]
9
+ }
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Mark Hall <mark.hall@work.room3b.eu>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.5
2
+ Name: lti-client-mock
3
+ Version: 0.0.1
4
+ Project-URL: Documentation, https://github.com/Mark Hall/lti-client-mock#readme
5
+ Project-URL: Issues, https://github.com/Mark Hall/lti-client-mock/issues
6
+ Project-URL: Source, https://github.com/Mark Hall/lti-client-mock
7
+ Author-email: Mark Hall <mark.hall@work.room3b.eu>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.txt
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
19
+ Requires-Python: >=3.8
20
+ Requires-Dist: fastapi
21
+ Requires-Dist: itsdangerous
22
+ Requires-Dist: pydantic
23
+ Requires-Dist: pydantic-settings
24
+ Requires-Dist: pylti1p3next
25
+ Requires-Dist: python-multipart
26
+ Description-Content-Type: text/markdown
27
+
28
+ # lti-client-mock
29
+
30
+ [![PyPI - Version](https://img.shields.io/pypi/v/lti-client-mock.svg)](https://pypi.org/project/lti-client-mock)
31
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/lti-client-mock.svg)](https://pypi.org/project/lti-client-mock)
32
+
33
+ -----
34
+
35
+ ## Table of Contents
36
+
37
+ - [Installation](#installation)
38
+ - [License](#license)
39
+
40
+ ## Installation
41
+
42
+ ```console
43
+ pip install lti-client-mock
44
+ ```
45
+
46
+ ## License
47
+
48
+ `lti-client-mock` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,21 @@
1
+ # lti-client-mock
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/lti-client-mock.svg)](https://pypi.org/project/lti-client-mock)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/lti-client-mock.svg)](https://pypi.org/project/lti-client-mock)
5
+
6
+ -----
7
+
8
+ ## Table of Contents
9
+
10
+ - [Installation](#installation)
11
+ - [License](#license)
12
+
13
+ ## Installation
14
+
15
+ ```console
16
+ pip install lti-client-mock
17
+ ```
18
+
19
+ ## License
20
+
21
+ `lti-client-mock` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2026-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """About this package."""
5
+
6
+ __version__ = "0.0.1"
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """A mock LTI client server."""
@@ -0,0 +1,314 @@
1
+ """LTI Client (Tool) mock server."""
2
+
3
+ import logging
4
+ from datetime import datetime, timezone
5
+ from secrets import token_hex
6
+ from typing import Annotated
7
+
8
+ from fastapi import Cookie, FastAPI
9
+ from fastapi.exceptions import HTTPException
10
+ from fastapi.requests import Request
11
+ from fastapi.responses import RedirectResponse, Response
12
+ from itsdangerous.exc import BadData
13
+ from itsdangerous.serializer import Serializer
14
+ from pylti1p3.cookie import CookieService as LTICookieService
15
+ from pylti1p3.exception import LtiException, OIDCException
16
+ from pylti1p3.message_launch import MessageLaunch as LTIMessageLaunch
17
+ from pylti1p3.oidc_login import OIDCLogin as LTILogin
18
+ from pylti1p3.request import Request as LTIRequest
19
+ from pylti1p3.session import SessionService as LTISessionService
20
+ from pylti1p3.tool_config.dict import ToolConfDict
21
+
22
+ from lti_client_mock.settings import settings
23
+
24
+ logger = logging.getLogger(__name__)
25
+ app = FastAPI()
26
+ lti_session_serializer = Serializer(token_hex(32), salt="lti-login")
27
+
28
+
29
+ class FastAPILTIRequest(LTIRequest):
30
+ """Request wrapper for the LTI authentication processes."""
31
+
32
+ def __init__(
33
+ self: "FastAPILTIRequest",
34
+ request: Request,
35
+ parameters: dict,
36
+ clear_session: bool = False, # noqa: FBT001, FBT002
37
+ ) -> None:
38
+ """Create a new :class:`~container_launcher.server.handlers.lti.FastAPILTIRequest`.
39
+
40
+ :param request: The request handler for parameter and session access.
41
+ :type request: :class:`~fastapi.requests.Request`
42
+ :param parameters: The parameters sent with the request
43
+ :type params: dict
44
+ :param clear_session: Whether to clear the session first. Default: `False`
45
+ :type clear_session: bool
46
+ """
47
+ self._parameters = parameters
48
+ if "lti-session" in request.cookies and not clear_session:
49
+ try:
50
+ self._session = lti_session_serializer.loads(request.cookies["lti-session"])
51
+ except BadData as e:
52
+ logger.error(f"Invalid session data received: {e}")
53
+ self._session = {}
54
+ else:
55
+ self._session = {}
56
+
57
+ def get_param(self: "FastAPILTIRequest", key: str) -> str:
58
+ """Get the request parameter for the ``key``.
59
+
60
+ :param key: The key of the request parameter
61
+ :type key: str
62
+ :return: The value for the request parameter
63
+ :rtype: str | None
64
+ """
65
+ return self._parameters[key]
66
+
67
+ def is_secure(self: "FastAPILTIRequest") -> bool:
68
+ """Determine whether the request is secure.
69
+
70
+ Currently defaults to ``True``.
71
+ """
72
+ return True
73
+
74
+ @property
75
+ def session(self: "FastAPILTIRequest") -> dict:
76
+ """Get the session for the request.
77
+
78
+ :return: The current session.
79
+ :rtype: dict
80
+ """
81
+ return self._session
82
+
83
+
84
+ class FastAPILTICookieService(LTICookieService):
85
+ """Cookie wrapper for the LTI authentication process."""
86
+
87
+ def __init__(self: "FastAPILTICookieService", request: Request) -> None:
88
+ """Create a new :class:`~container_launcher.server.handlers.lti.FastAPILTICookieService`.
89
+
90
+ :param request: The request to use for getting cookies.
91
+ :type request: :class:`~fastapi.requests.Request`
92
+ """
93
+ super().__init__()
94
+ self._request = request
95
+ self.cookies = {}
96
+
97
+ def set_cookie(self: "FastAPILTICookieService", name: str, value: str | int, exp: int | None = 3600) -> None:
98
+ """Set a cookie value.
99
+
100
+ :param name: The name of the cookie to set.
101
+ :type name: str
102
+ :param value: The value of the cookie to set.
103
+ :type value: str
104
+ :param expires: The number of seconds until the cookie expires.
105
+ :type expires: int
106
+ """
107
+ logger.debug(f"Set cookie {name} to {value}")
108
+ if exp is not None:
109
+ self.cookies[name] = {"value": str(value), "expires": exp}
110
+ else:
111
+ self.cookies[name] = {"value": str(value)}
112
+
113
+ def get_cookie(self: "FastAPILTICookieService", name: str) -> str | None:
114
+ """Get a cookie value.
115
+
116
+ :param name: The name of the cookie to get.
117
+ :type name: str
118
+ :return: The value of the cookie
119
+ :rtype: str
120
+ """
121
+ logger.debug(f"Fetch cookie {name}")
122
+ return self._request.cookies.get(name)
123
+
124
+
125
+ class FastAPILTIRedirect:
126
+ """Redirection object required for the LTI authentication process."""
127
+
128
+ def __init__(
129
+ self: "FastAPILTIRedirect", request: "FastAPILTIRequest", cookie_service: "FastAPILTICookieService", url: str
130
+ ) -> None:
131
+ """Create a new :class:`~container_launcher.server.api.auth.lti.FastAPILTIRedirect`.
132
+
133
+ :param request: The request for accessing the session
134
+ :type request: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRequest`
135
+ :param cookie_service: The service used to set cookies
136
+ :type cookie_service: :class:`~container_launcher.server.api.auth.lti.FastAPILTICookieService`
137
+ :param url: The URL to redirect to.
138
+ :type url: str
139
+ """
140
+ self._request = request
141
+ self._cookie_service = cookie_service
142
+ self._url = url
143
+
144
+ def do_redirect(self: "FastAPILTIRedirect") -> RedirectResponse:
145
+ """Perform the actual redirect."""
146
+ response = RedirectResponse(self._url)
147
+ for name, content in self._cookie_service.cookies.items():
148
+ response.set_cookie(
149
+ name,
150
+ content["value"],
151
+ expires=content["expires"],
152
+ samesite="lax",
153
+ secure=False,
154
+ )
155
+ session_data = lti_session_serializer.dumps(self._request.session)
156
+ response.set_cookie("lti-session", session_data, expires=300, samesite="lax", secure=False)
157
+ return response
158
+
159
+
160
+ class FastAPILTILogin(LTILogin):
161
+ """LTI authentication process step 1: authentication."""
162
+
163
+ def __init__(
164
+ self: "FastAPILTILogin",
165
+ request: FastAPILTIRequest,
166
+ tool_config: ToolConfDict,
167
+ cookie_service: FastAPILTICookieService,
168
+ ) -> None:
169
+ """Create a new :class:`~container_launcher.server.api.auth.lti.FastAPILTILogin`.
170
+
171
+ :param request: The request containing the login initiation data.
172
+ :type request: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRequest`
173
+ :param tool_config: The LTI tool configuration.
174
+ :type tool_config: :class:`~pylti1p3.tool_config.ToolConfigDict`
175
+ :param cookie_service: The service used to set cookies
176
+ :type cookie_service: :class:`~container_launcher.server.api.auth.lti.FastAPILTICookieService`
177
+ """
178
+ super().__init__(
179
+ request=request,
180
+ tool_config=tool_config,
181
+ session_service=LTISessionService(request),
182
+ cookie_service=cookie_service,
183
+ )
184
+
185
+ def get_redirect(self: "FastAPILTILogin", url: str) -> FastAPILTIRedirect:
186
+ """Get the authentication redirect.
187
+
188
+ :param url: The URL to redirect to.
189
+ :type url: str
190
+ :return: The redirection proxy.
191
+ :rtype: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRedirect`
192
+ """
193
+ return FastAPILTIRedirect(self._request, self._cookie_service, url)
194
+
195
+
196
+ class FastAPILTIMessageLaunch(LTIMessageLaunch):
197
+ """LTI authentication process step 2: launch."""
198
+
199
+ def __init__(
200
+ self: "FastAPILTIMessageLaunch",
201
+ request: FastAPILTIRequest,
202
+ tool_config: ToolConfDict,
203
+ cookie_service: FastAPILTICookieService,
204
+ ) -> None:
205
+ """Create a new :class:`~container_launcher.server.handlers.lti.FastAPILTIMessageLaunch`.
206
+
207
+ :param request: The request containing the launch data.
208
+ :type request: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRequest`
209
+ :param tool_config: The LTI tool configuration.
210
+ :type tool_config: :class:`~pylti1p3.tool_config.ToolConfigDict`
211
+ :param cookie_service: The service used to get cookies
212
+ :type cookie_service: :class:`~container_launcher.server.api.auth.lti.FastAPILTICookieService`
213
+ """
214
+ super().__init__(
215
+ request=request,
216
+ tool_config=tool_config,
217
+ session_service=LTISessionService(request),
218
+ cookie_service=cookie_service,
219
+ )
220
+
221
+ def _get_request_param(self: "FastAPILTIMessageLaunch", key: str) -> str:
222
+ """Get a request parameter value.
223
+
224
+ :param key: The key of the request parameter to fetch.
225
+ :type key: str
226
+ :return: The request parameter value.
227
+ :rtype: str
228
+ """
229
+ return self._request.get_param(key)
230
+
231
+
232
+ def login(request: Request, response: Response, parameters: dict) -> RedirectResponse: # noqa: ARG001
233
+ """Start the LTI login process."""
234
+ logger.debug("Starting LTI login process")
235
+ oidc_request = FastAPILTIRequest(request, parameters, clear_session=True)
236
+ oidc_login = FastAPILTILogin(
237
+ request=oidc_request,
238
+ tool_config=ToolConfDict(settings.model_dump()["lti"]),
239
+ cookie_service=FastAPILTICookieService(request),
240
+ )
241
+ logger.debug("Redirecting to the LTI platform")
242
+ if "target_link_uri" in parameters:
243
+ return oidc_login.redirect(parameters["target_link_uri"])
244
+ else:
245
+ return oidc_login.redirect("")
246
+
247
+
248
+ @app.post("/login", response_class=RedirectResponse)
249
+ async def login_post(request: Request, response: Response) -> RedirectResponse:
250
+ """Start the LTI login process from a POST request."""
251
+ try:
252
+ return login(request, response, dict(await request.form()))
253
+ except OIDCException as e:
254
+ logger.error(e)
255
+ raise HTTPException(422, "No valid LTI login data found") from e
256
+
257
+
258
+ @app.get("/login", response_class=RedirectResponse)
259
+ def login_get(request: Request, response: Response) -> RedirectResponse:
260
+ """Start the LTI login process from a GET request."""
261
+ try:
262
+ return login(request, response, dict(request.query_params))
263
+ except OIDCException as e:
264
+ logger.error(e)
265
+ raise HTTPException(422, "No valid LTI login data found") from e
266
+
267
+
268
+ async def launch(request: Request, response: Response, parameters: dict) -> str:
269
+ """Launch the LTI tool for the user."""
270
+ logger.debug("Starting the LTI launch process")
271
+ tool_config = ToolConfDict(settings.model_dump()["lti"])
272
+ oidc_request = FastAPILTIRequest(request, parameters)
273
+ message_launch = FastAPILTIMessageLaunch(
274
+ request=oidc_request,
275
+ tool_config=tool_config,
276
+ cookie_service=FastAPILTICookieService(request),
277
+ )
278
+ message_launch.set_jwt_verify_options({"verify_iat": False, "verify_aud": False})
279
+ logger.debug("LTI launch validated - logging in")
280
+ data = message_launch.get_launch_data()
281
+ logger.debug(data)
282
+ response.set_cookie(
283
+ "lti_client_mock_user", lti_session_serializer.dumps(data), max_age=None, secure=False, samesite="lax"
284
+ )
285
+ return "/success"
286
+
287
+
288
+ @app.post("/launch", response_class=RedirectResponse, status_code=303)
289
+ async def launch_post(request: Request, response: Response) -> str:
290
+ """Launch the LTI tool from a POST request."""
291
+ try:
292
+ return await launch(request, response, dict(await request.form()))
293
+ except LtiException as e:
294
+ logger.error(e)
295
+ logger.error(str(int(datetime.now(tz=timezone.utc).timestamp())))
296
+ raise HTTPException(422, "No valid LTI launch data found") from e
297
+
298
+
299
+ @app.get("/launch", response_class=RedirectResponse, status_code=303)
300
+ async def launch_get(request: Request, response: Response) -> str:
301
+ """Launch the LTI tool from a GET request."""
302
+ try:
303
+ return await launch(request, response, dict(request.query_params))
304
+ except LtiException as e:
305
+ logger.error(e)
306
+ logger.error(str(int(datetime.now(tz=timezone.utc).timestamp())))
307
+ raise HTTPException(422, "No valid LTI launch data found") from e
308
+
309
+
310
+ @app.get("/success")
311
+ def success(lti_client_mock_user: Annotated[str, Cookie()]):
312
+ """Show the successfull LTI login information."""
313
+ mock_user = lti_session_serializer.loads(lti_client_mock_user)
314
+ return mock_user
@@ -0,0 +1,31 @@
1
+ """LTI client mock settings."""
2
+
3
+ from pydantic import BaseModel
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class LTIModel(BaseModel):
8
+ """Validation model for a single LTI configuration."""
9
+
10
+ client_id: str
11
+ auth_login_url: str
12
+ auth_token_url: str
13
+ key_set_url: str
14
+ deployment_ids: list[str]
15
+
16
+
17
+ class Settings(BaseSettings):
18
+ """LTI provider mock settings."""
19
+
20
+ lti: dict[str, list[LTIModel]] = {}
21
+
22
+ model_config = SettingsConfigDict(
23
+ env_file=".env",
24
+ env_file_encoding="utf-8",
25
+ env_prefix="LTI_CLIENT_MOCK__",
26
+ env_nested_delimiter="__",
27
+ extra="ignore",
28
+ )
29
+
30
+
31
+ settings = Settings()
@@ -0,0 +1,163 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lti-client-mock"
7
+ dynamic = ["version"]
8
+ description = ''
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ keywords = []
13
+ authors = [
14
+ { name = "Mark Hall", email = "mark.hall@work.room3b.eu" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3.8",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: Implementation :: CPython",
25
+ "Programming Language :: Python :: Implementation :: PyPy",
26
+ ]
27
+ dependencies = [
28
+ "fastapi",
29
+ "itsdangerous",
30
+ "pydantic",
31
+ "pydantic-settings",
32
+ "pylti1p3next",
33
+ "python-multipart",
34
+ ]
35
+
36
+ [project.urls]
37
+ Documentation = "https://github.com/Mark Hall/lti-client-mock#readme"
38
+ Issues = "https://github.com/Mark Hall/lti-client-mock/issues"
39
+ Source = "https://github.com/Mark Hall/lti-client-mock"
40
+
41
+ [tool.hatch.version]
42
+ path = "lti_client_mock/__about__.py"
43
+
44
+ [tool.hatch.envs.default]
45
+ extra-dependencies = [
46
+ "beautifulsoup4",
47
+ "httpx2",
48
+ "lti-provider-mock",
49
+ "pytest",
50
+ "pytest-cov",
51
+ "ruff==0.15.20",
52
+ "uvicorn",
53
+ ]
54
+
55
+ [tool.hatch.envs.default.scripts]
56
+ server = "uvicorn --port 8485 --reload --reload-dir lti_client_mock --log-level debug --host=0.0.0.0 lti_client_mock.server:app"
57
+ lti-provider = "uvicorn --port 8484 lti_provider_mock.server:app"
58
+
59
+ test = "pytest {args:tests}"
60
+ test-cov = "coverage run -m pytest {args:tests}"
61
+ cov-report = ["- coverage combine", "coverage report"]
62
+ cov = ["test-cov", "cov-report"]
63
+
64
+ ruff-check = "ruff check {args:.}"
65
+ ruff-format = "ruff format {args:.}"
66
+ ruff-style = ["ruff-format", "ruff-check"]
67
+
68
+ [tool.hatch.envs.types]
69
+ extra-dependencies = [
70
+ "mypy>=1.0.0",
71
+ ]
72
+ [tool.hatch.envs.types.scripts]
73
+ check = "mypy --install-types --non-interactive {args:lti_client_mock tests}"
74
+
75
+ [tool.coverage.run]
76
+ source_pkgs = ["lti_client_mock", "tests"]
77
+ branch = true
78
+ parallel = true
79
+ omit = [
80
+ "lti_client_mock/__about__.py",
81
+ ]
82
+
83
+ [tool.coverage.paths]
84
+ lti_client_mock = ["lti_client_mock", "*/lti-client-mock/lti_client_mock"]
85
+ tests = ["tests", "*/lti-client-mock/tests"]
86
+
87
+ [tool.coverage.report]
88
+ exclude_lines = [
89
+ "no cov",
90
+ "if __name__ == .__main__.:",
91
+ "if TYPE_CHECKING:",
92
+ ]
93
+ skip_covered = true
94
+ show_missing = true
95
+
96
+ [tool.ruff]
97
+ target-version = "py39"
98
+ line-length = 120
99
+ lint.select = [
100
+ "A",
101
+ "ARG",
102
+ "B",
103
+ "C",
104
+ "D",
105
+ "DTZ",
106
+ "E",
107
+ "EM",
108
+ "F",
109
+ "FBT",
110
+ "I",
111
+ "ICN",
112
+ "ISC",
113
+ "N",
114
+ "PLC",
115
+ "PLE",
116
+ "PLR",
117
+ "PLW",
118
+ "Q",
119
+ "RUF",
120
+ "S",
121
+ "T",
122
+ "TID",
123
+ "UP",
124
+ "W",
125
+ "YTT",
126
+ ]
127
+ lint.ignore = [
128
+ # Allow non-abstract empty methods in abstract base classes
129
+ "B027",
130
+ # Allow boolean positional values in function calls, like `dict.get(... True)`
131
+ "FBT003",
132
+ # Ignore single-line concatenation
133
+ "ISC001",
134
+ # Ignore checks for possible passwords
135
+ "S105",
136
+ "S106",
137
+ "S107",
138
+ # Ignore complexity
139
+ "C901",
140
+ "PLR0911",
141
+ "PLR0912",
142
+ "PLR0913",
143
+ "PLR0915",
144
+ # Docstring settings
145
+ "D203",
146
+ "D211",
147
+ "D213",
148
+ ]
149
+ lint.unfixable = [
150
+ # Don't touch unused imports
151
+ "F401",
152
+ ]
153
+
154
+ [tool.ruff.lint.isort]
155
+ known-first-party = ["container_launcher"]
156
+
157
+ [tool.ruff.lint.flake8-tidy-imports]
158
+ ban-relative-imports = "all"
159
+
160
+ [tool.ruff.lint.per-file-ignores]
161
+ # Tests can use magic values, assertions, and relative imports
162
+ "tests/**/*" = ["ARG001", "PLR2004", "S101", "TID252"]
163
+ "dev/jupyterhub_config.py" = ["F821", "S104"]
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Test cases."""
@@ -0,0 +1,45 @@
1
+ # SPDX-FileCopyrightText: 2026-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Test fixtures."""
5
+
6
+ from threading import Thread
7
+ from time import sleep
8
+
9
+ import pytest
10
+ import uvicorn
11
+ from lti_provider_mock.server import app as lti_server_app
12
+
13
+ from lti_client_mock.server import app as lti_client_app
14
+
15
+
16
+ @pytest.fixture()
17
+ def lti_client_mock():
18
+ """Run the LTI Client Mock for a single test."""
19
+ config = uvicorn.Config(lti_client_app, host="0.0.0.0", port=8485) # noqa: S104
20
+ server = uvicorn.Server(config)
21
+
22
+ def run_server():
23
+ server.run()
24
+
25
+ thread = Thread(target=run_server)
26
+ thread.start()
27
+ yield server
28
+ server.should_exit = True
29
+ sleep(0.2)
30
+
31
+
32
+ @pytest.fixture()
33
+ def lti_provider_mock():
34
+ """Run the LTI Provider Mock for a single test."""
35
+ config = uvicorn.Config(lti_server_app, host="0.0.0.0", port=8484) # noqa: S104
36
+ server = uvicorn.Server(config)
37
+
38
+ def run_server():
39
+ server.run()
40
+
41
+ thread = Thread(target=run_server)
42
+ thread.start()
43
+ yield server
44
+ server.should_exit = True
45
+ sleep(0.2)
@@ -0,0 +1,92 @@
1
+ # SPDX-FileCopyrightText: 2026-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Test cases."""
5
+
6
+ from bs4 import BeautifulSoup
7
+ from bs4.element import Tag
8
+ from httpx2 import Client
9
+
10
+
11
+ def serialise_form(form: Tag) -> dict:
12
+ """Serialise a HTML form parsed by BeautifulSoup into a dict for submission."""
13
+ data = {}
14
+ for element in form.children:
15
+ if isinstance(element, Tag) and element.name == "input":
16
+ data[element["name"]] = element["value"]
17
+ return data
18
+
19
+
20
+ def test_login_flow_admin_user(lti_client_mock, lti_provider_mock):
21
+ """Test the login flow with the admin user."""
22
+ with Client(follow_redirects=True) as client:
23
+ client.post("http://localhost:8484/login", data={"userid": "1"})
24
+ response = client.post("http://localhost:8484/courses/1")
25
+ doc = BeautifulSoup(response.text, "html.parser")
26
+ form = doc.find("form")
27
+ url = str(form["action"])
28
+ data = serialise_form(form)
29
+ response = client.post(url, data=data)
30
+ doc = BeautifulSoup(response.text, "html.parser")
31
+ form = doc.find("form")
32
+ url = str(form["action"])
33
+ data = serialise_form(form)
34
+ response = client.post(url, data=data)
35
+ assert response.status_code == 200
36
+ userinfo = response.json()
37
+ assert userinfo["aud"] == "lti-provider-mock"
38
+ assert userinfo["family_name"] == "Admin"
39
+ assert userinfo["given_name"] == "The"
40
+ assert userinfo["sub"] == "1"
41
+ assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["id"] == "1"
42
+ assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["label"] == "Course 1"
43
+
44
+
45
+ def test_login_flow_user_course_1(lti_client_mock, lti_provider_mock):
46
+ """Test the login flow with the a generic user and course 1."""
47
+ with Client(follow_redirects=True) as client:
48
+ client.post("http://localhost:8484/login", data={"userid": "2"})
49
+ response = client.post("http://localhost:8484/courses/1")
50
+ doc = BeautifulSoup(response.text, "html.parser")
51
+ form = doc.find("form")
52
+ url = str(form["action"])
53
+ data = serialise_form(form)
54
+ response = client.post(url, data=data)
55
+ doc = BeautifulSoup(response.text, "html.parser")
56
+ form = doc.find("form")
57
+ url = str(form["action"])
58
+ data = serialise_form(form)
59
+ response = client.post(url, data=data)
60
+ assert response.status_code == 200
61
+ userinfo = response.json()
62
+ assert userinfo["aud"] == "lti-provider-mock"
63
+ assert userinfo["family_name"] == "User"
64
+ assert userinfo["given_name"] == "A"
65
+ assert userinfo["sub"] == "2"
66
+ assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["id"] == "1"
67
+ assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["label"] == "Course 1"
68
+
69
+
70
+ def test_login_flow_user_course_2(lti_client_mock, lti_provider_mock):
71
+ """Test the login flow with the a generic user and course 2."""
72
+ with Client(follow_redirects=True) as client:
73
+ client.post("http://localhost:8484/login", data={"userid": "2"})
74
+ response = client.post("http://localhost:8484/courses/2")
75
+ doc = BeautifulSoup(response.text, "html.parser")
76
+ form = doc.find("form")
77
+ url = str(form["action"])
78
+ data = serialise_form(form)
79
+ response = client.post(url, data=data)
80
+ doc = BeautifulSoup(response.text, "html.parser")
81
+ form = doc.find("form")
82
+ url = str(form["action"])
83
+ data = serialise_form(form)
84
+ response = client.post(url, data=data)
85
+ assert response.status_code == 200
86
+ userinfo = response.json()
87
+ assert userinfo["aud"] == "lti-provider-mock"
88
+ assert userinfo["family_name"] == "User"
89
+ assert userinfo["given_name"] == "A"
90
+ assert userinfo["sub"] == "2"
91
+ assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["id"] == "2"
92
+ assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["label"] == "Course 2"