mytutor-sdk 0.1.0__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,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: mytutor-sdk
3
+ Version: 0.1.0
4
+ Summary: Python client for the My Tutor developer API
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # My Tutor Python SDK
9
+
10
+ The `mytutor-sdk` distribution provides a typed, synchronous Python client for
11
+ the stable My Tutor developer API v1 endpoints: subjects, tutoring sessions,
12
+ and tutor messages. It has no runtime dependencies.
13
+
14
+ > **Preview:** This package is not published to PyPI yet. The source and local
15
+ > package are available in this repository; `pip install mytutor-sdk` will work
16
+ > after the first PyPI release. Publishing is intentionally not automated in
17
+ > this repository.
18
+
19
+ ## Requirements
20
+
21
+ - Python 3.11 or newer
22
+ - A My Tutor developer API key
23
+ - A trusted server environment; never put the secret key in browser or mobile
24
+ application code
25
+
26
+ ## Install from a local checkout
27
+
28
+ ```sh
29
+ python -m pip install ./packages/sdk-python
30
+ ```
31
+
32
+ After PyPI publication, install with:
33
+
34
+ ```sh
35
+ python -m pip install mytutor-sdk
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ Set `MYTUTOR_API_KEY` in your server environment. Supply a subject slug to
41
+ start a session; keep the returned `session_id` to continue the conversation.
42
+
43
+ ```python
44
+ import os
45
+
46
+ from mytutor import MyTutor
47
+
48
+ client = MyTutor(api_key=os.environ["MYTUTOR_API_KEY"])
49
+ subjects = client.subjects.list()
50
+ result = client.tutor.ask(
51
+ subject_slug=subjects[0]["slug"],
52
+ topic="Fractions",
53
+ question="Explain how to add fractions with different denominators.",
54
+ )
55
+
56
+ print(result["response"]["message"]["content"])
57
+
58
+ next_turn = client.tutor.ask(
59
+ session_id=result["session_id"],
60
+ question="Can you show another example?",
61
+ )
62
+ ```
63
+
64
+ `tutor.ask` requires exactly one of `session_id` or `subject_slug`. A
65
+ `subject_slug` creates a new tutoring session before sending the question. Use
66
+ `client.tutor.create_session()` and `client.tutor.send_message()` separately
67
+ when you need full control.
68
+
69
+ ## Errors and retries
70
+
71
+ Catch `MyTutorError` to inspect the HTTP `status`, API `code`, optional
72
+ `details`, and `correlation_id`. Requests are not retried automatically. For a
73
+ retry of the same tutor message, pass the same `idempotency_key`.
74
+
75
+ If a new-session `ask` creates the session but cannot complete the message, it
76
+ raises `TutorAskError`, which includes `session_id` so the message can be
77
+ retried against that session.
78
+
79
+ ## Configuration
80
+
81
+ `base_url` defaults to `https://api.mytutor.site`. Override it with your
82
+ deployment's HTTPS API origin when needed. Plain HTTP is permitted only for
83
+ loopback development URLs. `timeout` defaults to 120 seconds and is expressed
84
+ in seconds.
@@ -0,0 +1,77 @@
1
+ # My Tutor Python SDK
2
+
3
+ The `mytutor-sdk` distribution provides a typed, synchronous Python client for
4
+ the stable My Tutor developer API v1 endpoints: subjects, tutoring sessions,
5
+ and tutor messages. It has no runtime dependencies.
6
+
7
+ > **Preview:** This package is not published to PyPI yet. The source and local
8
+ > package are available in this repository; `pip install mytutor-sdk` will work
9
+ > after the first PyPI release. Publishing is intentionally not automated in
10
+ > this repository.
11
+
12
+ ## Requirements
13
+
14
+ - Python 3.11 or newer
15
+ - A My Tutor developer API key
16
+ - A trusted server environment; never put the secret key in browser or mobile
17
+ application code
18
+
19
+ ## Install from a local checkout
20
+
21
+ ```sh
22
+ python -m pip install ./packages/sdk-python
23
+ ```
24
+
25
+ After PyPI publication, install with:
26
+
27
+ ```sh
28
+ python -m pip install mytutor-sdk
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ Set `MYTUTOR_API_KEY` in your server environment. Supply a subject slug to
34
+ start a session; keep the returned `session_id` to continue the conversation.
35
+
36
+ ```python
37
+ import os
38
+
39
+ from mytutor import MyTutor
40
+
41
+ client = MyTutor(api_key=os.environ["MYTUTOR_API_KEY"])
42
+ subjects = client.subjects.list()
43
+ result = client.tutor.ask(
44
+ subject_slug=subjects[0]["slug"],
45
+ topic="Fractions",
46
+ question="Explain how to add fractions with different denominators.",
47
+ )
48
+
49
+ print(result["response"]["message"]["content"])
50
+
51
+ next_turn = client.tutor.ask(
52
+ session_id=result["session_id"],
53
+ question="Can you show another example?",
54
+ )
55
+ ```
56
+
57
+ `tutor.ask` requires exactly one of `session_id` or `subject_slug`. A
58
+ `subject_slug` creates a new tutoring session before sending the question. Use
59
+ `client.tutor.create_session()` and `client.tutor.send_message()` separately
60
+ when you need full control.
61
+
62
+ ## Errors and retries
63
+
64
+ Catch `MyTutorError` to inspect the HTTP `status`, API `code`, optional
65
+ `details`, and `correlation_id`. Requests are not retried automatically. For a
66
+ retry of the same tutor message, pass the same `idempotency_key`.
67
+
68
+ If a new-session `ask` creates the session but cannot complete the message, it
69
+ raises `TutorAskError`, which includes `session_id` so the message can be
70
+ retried against that session.
71
+
72
+ ## Configuration
73
+
74
+ `base_url` defaults to `https://api.mytutor.site`. Override it with your
75
+ deployment's HTTPS API origin when needed. Plain HTTP is permitted only for
76
+ loopback development URLs. `timeout` defaults to 120 seconds and is expressed
77
+ in seconds.
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mytutor-sdk"
7
+ version = "0.1.0"
8
+ description = "Python client for the My Tutor developer API"
9
+ requires-python = ">=3.11"
10
+ readme = "README.md"
11
+
12
+ [tool.setuptools]
13
+ package-dir = {"" = "src"}
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ from .client import MyTutor
2
+ from .errors import MyTutorError, TutorAskError
3
+ from .types import (
4
+ Session,
5
+ Subject,
6
+ TeachingMode,
7
+ Topic,
8
+ TutorAskResult,
9
+ TutorMessage,
10
+ TutorResponse,
11
+ Whiteboard,
12
+ )
13
+
14
+ __all__ = [
15
+ "MyTutor",
16
+ "MyTutorError",
17
+ "Session",
18
+ "Subject",
19
+ "TeachingMode",
20
+ "Topic",
21
+ "TutorAskError",
22
+ "TutorAskResult",
23
+ "TutorMessage",
24
+ "TutorResponse",
25
+ "Whiteboard",
26
+ ]
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ from typing import Any
6
+ from urllib.error import HTTPError, URLError
7
+ from urllib.parse import quote, urlsplit
8
+ from urllib.request import Request, urlopen
9
+
10
+ from .errors import MyTutorError, TutorAskError
11
+ from .types import (
12
+ Session,
13
+ Subject,
14
+ TeachingMode,
15
+ TutorAskResult,
16
+ TutorResponse,
17
+ )
18
+
19
+ DEFAULT_BASE_URL = "https://api.mytutor.site"
20
+ DEFAULT_TIMEOUT = 120.0
21
+ TEACHING_MODES = {"guided", "explain", "practice", "check_my_work"}
22
+
23
+
24
+ def _validate_text(value: str, name: str, maximum: int | None = None) -> None:
25
+ if not isinstance(value, str) or not value.strip():
26
+ raise ValueError(f"{name} must be a non-empty string.")
27
+ if maximum is not None and len(value) > maximum:
28
+ raise ValueError(f"{name} must be {maximum} characters or fewer.")
29
+
30
+
31
+ def _normalize_base_url(value: str) -> str:
32
+ try:
33
+ parsed = urlsplit(value)
34
+ hostname = parsed.hostname
35
+ port = parsed.port
36
+ except ValueError as exc:
37
+ raise ValueError("base_url must be an absolute HTTP(S) API origin.") from exc
38
+
39
+ is_loopback = hostname in {"localhost", "127.0.0.1", "::1"}
40
+ if (
41
+ parsed.scheme not in {"https", "http"}
42
+ or not hostname
43
+ or (parsed.scheme == "http" and not is_loopback)
44
+ or parsed.username
45
+ or parsed.password
46
+ or parsed.path not in {"", "/"}
47
+ or parsed.query
48
+ or parsed.fragment
49
+ ):
50
+ raise ValueError(
51
+ "base_url must be an HTTPS API origin without credentials, path, query, or fragment."
52
+ )
53
+
54
+ netloc = hostname if port is None else f"{hostname}:{port}"
55
+ if ":" in hostname and not hostname.startswith("["):
56
+ netloc = f"[{hostname}]" if port is None else f"[{hostname}]:{port}"
57
+ return f"{parsed.scheme}://{netloc}"
58
+
59
+
60
+ def _api_error(status: int, payload: Any) -> MyTutorError:
61
+ envelope = payload if isinstance(payload, dict) else {}
62
+ error = envelope.get("error")
63
+ if not isinstance(error, dict):
64
+ error = {}
65
+ message = error.get("message")
66
+ code = error.get("code")
67
+ correlation_id = envelope.get("correlation_id")
68
+ return MyTutorError(
69
+ message if isinstance(message, str) else f"The My Tutor API request failed ({status}).",
70
+ status=status,
71
+ code=code if isinstance(code, str) else "http_error",
72
+ details=error.get("details"),
73
+ correlation_id=correlation_id if isinstance(correlation_id, str) else None,
74
+ )
75
+
76
+
77
+ def _decode_response(status: int, raw: bytes) -> Any:
78
+ payload: Any = None
79
+ if raw:
80
+ try:
81
+ payload = json.loads(raw.decode("utf-8"))
82
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
83
+ if 200 <= status < 300 and status != 204:
84
+ raise MyTutorError(
85
+ "The My Tutor API returned an invalid JSON response.",
86
+ status=status,
87
+ code="invalid_response",
88
+ ) from exc
89
+
90
+ if not 200 <= status < 300:
91
+ raise _api_error(status, payload)
92
+ if status == 204:
93
+ return None
94
+ if not raw:
95
+ raise MyTutorError(
96
+ "The My Tutor API returned an empty response.",
97
+ status=status,
98
+ code="invalid_response",
99
+ )
100
+ return payload
101
+
102
+
103
+ class _Subjects:
104
+ def __init__(self, client: MyTutor) -> None:
105
+ self._client = client
106
+
107
+ def list(self) -> list[Subject]:
108
+ return self._client._request("GET", "/subjects")
109
+
110
+
111
+ class _Tutor:
112
+ def __init__(self, client: MyTutor) -> None:
113
+ self._client = client
114
+
115
+ def create_session(
116
+ self,
117
+ *,
118
+ subject_slug: str,
119
+ topic: str = "",
120
+ teaching_mode: TeachingMode | None = None,
121
+ title: str | None = None,
122
+ assignment_question_id: str | None = None,
123
+ assignment_id: str | None = None,
124
+ education_level: str | None = None,
125
+ ) -> Session:
126
+ _validate_text(subject_slug, "subject_slug", 60)
127
+ normalized_topic = topic.strip() if isinstance(topic, str) else ""
128
+ if normalized_topic:
129
+ _validate_text(normalized_topic, "topic", 160)
130
+ if title is not None and len(title) > 160:
131
+ raise ValueError("title must be 160 characters or fewer.")
132
+ if education_level is not None and len(education_level) > 60:
133
+ raise ValueError("education_level must be 60 characters or fewer.")
134
+ if teaching_mode is not None and teaching_mode not in TEACHING_MODES:
135
+ raise ValueError("teaching_mode is not a supported tutoring mode.")
136
+
137
+ payload: dict[str, Any] = {"subject_slug": subject_slug, "topic": normalized_topic}
138
+ if teaching_mode is not None:
139
+ payload["teaching_mode"] = teaching_mode
140
+ if title is not None:
141
+ payload["title"] = title
142
+ if assignment_question_id is not None:
143
+ payload["assignment_question_id"] = assignment_question_id
144
+ if assignment_id is not None:
145
+ payload["assignment_id"] = assignment_id
146
+ if education_level is not None:
147
+ payload["education_level"] = education_level
148
+ return self._client._request("POST", "/tutoring/sessions", body=payload)
149
+
150
+ def send_message(
151
+ self,
152
+ *,
153
+ session_id: str,
154
+ content: str,
155
+ retry: bool | None = None,
156
+ idempotency_key: str | None = None,
157
+ ) -> TutorResponse:
158
+ _validate_text(session_id, "session_id")
159
+ _validate_text(content, "content", 4000)
160
+ if idempotency_key is not None:
161
+ _validate_text(idempotency_key, "idempotency_key", 120)
162
+
163
+ payload: dict[str, Any] = {"content": content}
164
+ if retry is not None:
165
+ payload["retry"] = retry
166
+ return self._client._request(
167
+ "POST",
168
+ f"/tutoring/sessions/{quote(session_id, safe='')}/messages",
169
+ body=payload,
170
+ idempotency_key=idempotency_key,
171
+ )
172
+
173
+ def ask(
174
+ self,
175
+ *,
176
+ question: str,
177
+ session_id: str | None = None,
178
+ subject_slug: str | None = None,
179
+ topic: str = "",
180
+ teaching_mode: TeachingMode | None = None,
181
+ idempotency_key: str | None = None,
182
+ ) -> TutorAskResult:
183
+ _validate_text(question, "question", 4000)
184
+ if (session_id is None) == (subject_slug is None):
185
+ raise ValueError("Provide exactly one of session_id or subject_slug.")
186
+ if idempotency_key is not None:
187
+ _validate_text(idempotency_key, "idempotency_key", 120)
188
+
189
+ if session_id is not None:
190
+ response = self.send_message(
191
+ session_id=session_id,
192
+ content=question,
193
+ idempotency_key=idempotency_key,
194
+ )
195
+ return {"session_id": session_id, "response": response}
196
+
197
+ if subject_slug is None:
198
+ raise ValueError("Provide a subject_slug to start a new tutoring session.")
199
+ session = self.create_session(
200
+ subject_slug=subject_slug,
201
+ topic=topic,
202
+ teaching_mode=teaching_mode,
203
+ )
204
+ try:
205
+ response = self.send_message(
206
+ session_id=session["id"],
207
+ content=question,
208
+ idempotency_key=idempotency_key,
209
+ )
210
+ except MyTutorError as exc:
211
+ raise TutorAskError(session["id"], exc) from exc
212
+ return {"session_id": session["id"], "response": response}
213
+
214
+
215
+ class MyTutor:
216
+ """Synchronous client for the server-to-server My Tutor developer API."""
217
+
218
+ def __init__(
219
+ self,
220
+ *,
221
+ api_key: str,
222
+ base_url: str = DEFAULT_BASE_URL,
223
+ timeout: float = DEFAULT_TIMEOUT,
224
+ ) -> None:
225
+ _validate_text(api_key, "api_key")
226
+ if "\r" in api_key or "\n" in api_key:
227
+ raise ValueError("api_key must not contain newline characters.")
228
+ if not isinstance(timeout, (int, float)) or isinstance(timeout, bool):
229
+ raise TypeError("timeout must be a positive finite number of seconds.")
230
+ if not math.isfinite(timeout) or timeout <= 0:
231
+ raise ValueError("timeout must be a positive finite number of seconds.")
232
+
233
+ self._api_key = api_key.strip()
234
+ self._base_url = _normalize_base_url(base_url)
235
+ self._timeout = float(timeout)
236
+ self.subjects = _Subjects(self)
237
+ self.tutor = _Tutor(self)
238
+
239
+ def _request(
240
+ self,
241
+ method: str,
242
+ path: str,
243
+ *,
244
+ body: dict[str, Any] | None = None,
245
+ idempotency_key: str | None = None,
246
+ ) -> Any:
247
+ headers = {
248
+ "Accept": "application/json",
249
+ "Authorization": f"Bearer {self._api_key}",
250
+ }
251
+ data = None
252
+ if body is not None:
253
+ headers["Content-Type"] = "application/json"
254
+ data = json.dumps(body).encode("utf-8")
255
+ if idempotency_key is not None:
256
+ headers["Idempotency-Key"] = idempotency_key
257
+
258
+ request = Request(
259
+ f"{self._base_url}/api/v1{path}",
260
+ data=data,
261
+ headers=headers,
262
+ method=method,
263
+ )
264
+ try:
265
+ with urlopen(request, timeout=self._timeout) as response:
266
+ return _decode_response(response.status, response.read())
267
+ except HTTPError as exc:
268
+ try:
269
+ return _decode_response(exc.code, exc.read())
270
+ finally:
271
+ exc.close()
272
+ except TimeoutError as exc:
273
+ raise MyTutorError(
274
+ f"The My Tutor API request timed out after {self._timeout:g} seconds.",
275
+ status=None,
276
+ code="timeout",
277
+ ) from exc
278
+ except URLError as exc:
279
+ raise MyTutorError(
280
+ "The My Tutor API request could not be completed.",
281
+ status=None,
282
+ code="network_error",
283
+ ) from exc
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class MyTutorError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ status: int | None,
12
+ code: str,
13
+ details: Any = None,
14
+ correlation_id: str | None = None,
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.code = code
19
+ self.details = details
20
+ self.correlation_id = correlation_id
21
+
22
+
23
+ class TutorAskError(MyTutorError):
24
+ def __init__(self, session_id: str, cause: MyTutorError) -> None:
25
+ super().__init__(
26
+ "The tutoring session was created, but the question could not be completed. "
27
+ "Retry by sending the question to this session.",
28
+ status=cause.status,
29
+ code=cause.code,
30
+ details=cause.details,
31
+ correlation_id=cause.correlation_id,
32
+ )
33
+ self.session_id = session_id
34
+ self.cause = cause
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, TypedDict
4
+
5
+ TeachingMode = Literal["guided", "explain", "practice", "check_my_work"]
6
+
7
+
8
+ class Topic(TypedDict):
9
+ id: str
10
+ title: str
11
+ slug: str
12
+ curriculum_id: str
13
+
14
+
15
+ class Subject(TypedDict):
16
+ id: str
17
+ slug: str
18
+ name: str
19
+ description: str
20
+ accent: str
21
+ topics: list[Topic]
22
+
23
+
24
+ class Session(TypedDict):
25
+ id: str
26
+ title: str
27
+ subject_slug: str | None
28
+ subject_name: str | None
29
+ teaching_mode: TeachingMode
30
+ education_level: str
31
+ status: str
32
+ topic: str
33
+ assignment_id: str | None
34
+ assignment_filename: str
35
+ created_at: str
36
+ updated_at: str
37
+ last_message_at: str | None
38
+ message_count: int
39
+ preview: str
40
+
41
+
42
+ class TutorMessage(TypedDict):
43
+ id: str
44
+ role: str
45
+ content: str
46
+ structured: dict[str, Any] | None
47
+ created_at: str
48
+
49
+
50
+ class Whiteboard(TypedDict):
51
+ schema_version: int
52
+ title: str
53
+ subject: str
54
+ steps: list[dict[str, Any]]
55
+ annotations: list[dict[str, Any]]
56
+ updated_at: str | None
57
+ boards: list[dict[str, Any]]
58
+ active_board_id: str
59
+
60
+
61
+ class TutorResponse(TypedDict):
62
+ message: TutorMessage
63
+ user_message: TutorMessage | None
64
+ steps: list[dict[str, Any]]
65
+ follow_up: str
66
+ check_question: str | None
67
+ topic: str
68
+ verification_summary: dict[str, int]
69
+ whiteboard: Whiteboard
70
+ prompt_version: str
71
+ model: str
72
+ credit_action: str
73
+ credits_charged: int
74
+ credits_available: int | None
75
+
76
+
77
+ class TutorAskResult(TypedDict):
78
+ session_id: str
79
+ response: TutorResponse
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: mytutor-sdk
3
+ Version: 0.1.0
4
+ Summary: Python client for the My Tutor developer API
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # My Tutor Python SDK
9
+
10
+ The `mytutor-sdk` distribution provides a typed, synchronous Python client for
11
+ the stable My Tutor developer API v1 endpoints: subjects, tutoring sessions,
12
+ and tutor messages. It has no runtime dependencies.
13
+
14
+ > **Preview:** This package is not published to PyPI yet. The source and local
15
+ > package are available in this repository; `pip install mytutor-sdk` will work
16
+ > after the first PyPI release. Publishing is intentionally not automated in
17
+ > this repository.
18
+
19
+ ## Requirements
20
+
21
+ - Python 3.11 or newer
22
+ - A My Tutor developer API key
23
+ - A trusted server environment; never put the secret key in browser or mobile
24
+ application code
25
+
26
+ ## Install from a local checkout
27
+
28
+ ```sh
29
+ python -m pip install ./packages/sdk-python
30
+ ```
31
+
32
+ After PyPI publication, install with:
33
+
34
+ ```sh
35
+ python -m pip install mytutor-sdk
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ Set `MYTUTOR_API_KEY` in your server environment. Supply a subject slug to
41
+ start a session; keep the returned `session_id` to continue the conversation.
42
+
43
+ ```python
44
+ import os
45
+
46
+ from mytutor import MyTutor
47
+
48
+ client = MyTutor(api_key=os.environ["MYTUTOR_API_KEY"])
49
+ subjects = client.subjects.list()
50
+ result = client.tutor.ask(
51
+ subject_slug=subjects[0]["slug"],
52
+ topic="Fractions",
53
+ question="Explain how to add fractions with different denominators.",
54
+ )
55
+
56
+ print(result["response"]["message"]["content"])
57
+
58
+ next_turn = client.tutor.ask(
59
+ session_id=result["session_id"],
60
+ question="Can you show another example?",
61
+ )
62
+ ```
63
+
64
+ `tutor.ask` requires exactly one of `session_id` or `subject_slug`. A
65
+ `subject_slug` creates a new tutoring session before sending the question. Use
66
+ `client.tutor.create_session()` and `client.tutor.send_message()` separately
67
+ when you need full control.
68
+
69
+ ## Errors and retries
70
+
71
+ Catch `MyTutorError` to inspect the HTTP `status`, API `code`, optional
72
+ `details`, and `correlation_id`. Requests are not retried automatically. For a
73
+ retry of the same tutor message, pass the same `idempotency_key`.
74
+
75
+ If a new-session `ask` creates the session but cannot complete the message, it
76
+ raises `TutorAskError`, which includes `session_id` so the message can be
77
+ retried against that session.
78
+
79
+ ## Configuration
80
+
81
+ `base_url` defaults to `https://api.mytutor.site`. Override it with your
82
+ deployment's HTTPS API origin when needed. Plain HTTP is permitted only for
83
+ loopback development URLs. `timeout` defaults to 120 seconds and is expressed
84
+ in seconds.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/mytutor/__init__.py
4
+ src/mytutor/client.py
5
+ src/mytutor/errors.py
6
+ src/mytutor/types.py
7
+ src/mytutor_sdk.egg-info/PKG-INFO
8
+ src/mytutor_sdk.egg-info/SOURCES.txt
9
+ src/mytutor_sdk.egg-info/dependency_links.txt
10
+ src/mytutor_sdk.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import unittest
6
+ from urllib.error import HTTPError
7
+ from unittest.mock import patch
8
+
9
+ from mytutor import MyTutor, MyTutorError, TutorAskError
10
+
11
+
12
+ def tutor_response() -> dict:
13
+ return {
14
+ "message": {
15
+ "id": "message-1",
16
+ "role": "assistant",
17
+ "content": "Try a common denominator.",
18
+ "structured": None,
19
+ "created_at": "2026-01-01T00:00:00Z",
20
+ },
21
+ "user_message": None,
22
+ "steps": [],
23
+ "follow_up": "",
24
+ "check_question": None,
25
+ "topic": "Fractions",
26
+ "verification_summary": {},
27
+ "whiteboard": {
28
+ "schema_version": 1,
29
+ "title": "Fractions",
30
+ "subject": "Mathematics",
31
+ "steps": [],
32
+ "annotations": [],
33
+ "updated_at": None,
34
+ "boards": [],
35
+ "active_board_id": "",
36
+ },
37
+ "prompt_version": "test",
38
+ "model": "test",
39
+ "credit_action": "tutor_message",
40
+ "credits_charged": 1,
41
+ "credits_available": 9,
42
+ }
43
+
44
+
45
+ class FakeResponse(io.BytesIO):
46
+ def __init__(self, payload: dict | list, status: int = 200) -> None:
47
+ super().__init__(json.dumps(payload).encode())
48
+ self.status = status
49
+
50
+
51
+ class MyTutorClientTests(unittest.TestCase):
52
+ def test_lists_subjects_with_bearer_authentication(self) -> None:
53
+ client = MyTutor(api_key="mt_dev_test")
54
+ with patch("mytutor.client.urlopen", return_value=FakeResponse([])) as open_url:
55
+ self.assertEqual(client.subjects.list(), [])
56
+
57
+ request = open_url.call_args.args[0]
58
+ self.assertEqual(request.full_url, "https://api.mytutor.site/api/v1/subjects")
59
+ self.assertEqual(request.get_method(), "GET")
60
+ self.assertEqual(request.get_header("Authorization"), "Bearer mt_dev_test")
61
+
62
+ def test_ask_creates_a_session_and_sends_a_question(self) -> None:
63
+ client = MyTutor(api_key="mt_dev_test")
64
+ session = {"id": "session-1"}
65
+ with patch(
66
+ "mytutor.client.urlopen",
67
+ side_effect=[FakeResponse(session, 201), FakeResponse(tutor_response())],
68
+ ) as open_url:
69
+ result = client.tutor.ask(
70
+ subject_slug="mathematics",
71
+ topic="Fractions",
72
+ question="How do I add 1/2 and 1/3?",
73
+ idempotency_key="turn-1",
74
+ )
75
+
76
+ self.assertEqual(result["session_id"], "session-1")
77
+ self.assertEqual(result["response"]["message"]["content"], "Try a common denominator.")
78
+ self.assertEqual(open_url.call_count, 2)
79
+
80
+ create_request = open_url.call_args_list[0].args[0]
81
+ self.assertEqual(
82
+ create_request.full_url,
83
+ "https://api.mytutor.site/api/v1/tutoring/sessions",
84
+ )
85
+ self.assertEqual(
86
+ json.loads(create_request.data),
87
+ {"subject_slug": "mathematics", "topic": "Fractions"},
88
+ )
89
+
90
+ message_request = open_url.call_args_list[1].args[0]
91
+ self.assertEqual(
92
+ message_request.full_url,
93
+ "https://api.mytutor.site/api/v1/tutoring/sessions/session-1/messages",
94
+ )
95
+ self.assertEqual(message_request.get_header("Idempotency-key"), "turn-1")
96
+ self.assertEqual(
97
+ json.loads(message_request.data),
98
+ {"content": "How do I add 1/2 and 1/3?"},
99
+ )
100
+
101
+ def test_ask_continues_an_existing_session(self) -> None:
102
+ client = MyTutor(api_key="mt_dev_test")
103
+ with patch(
104
+ "mytutor.client.urlopen",
105
+ return_value=FakeResponse(tutor_response()),
106
+ ) as open_url:
107
+ result = client.tutor.ask(
108
+ session_id="session/with space",
109
+ question="Why do the denominators need to match?",
110
+ )
111
+
112
+ self.assertEqual(result["session_id"], "session/with space")
113
+ self.assertEqual(open_url.call_count, 1)
114
+ request = open_url.call_args.args[0]
115
+ self.assertTrue(request.full_url.endswith("session%2Fwith%20space/messages"))
116
+
117
+ def test_maps_api_errors_to_typed_error(self) -> None:
118
+ client = MyTutor(api_key="mt_dev_test")
119
+ error_body = {
120
+ "error": {
121
+ "code": "insufficient_credits",
122
+ "message": "Add credits before continuing.",
123
+ "details": {"available": 0},
124
+ },
125
+ "correlation_id": "request-123",
126
+ }
127
+ http_error = HTTPError(
128
+ "https://api.mytutor.site/api/v1/subjects",
129
+ 402,
130
+ "Payment required",
131
+ {},
132
+ io.BytesIO(json.dumps(error_body).encode()),
133
+ )
134
+ with patch("mytutor.client.urlopen", side_effect=http_error):
135
+ with self.assertRaises(MyTutorError) as raised:
136
+ client.subjects.list()
137
+
138
+ self.assertEqual(raised.exception.status, 402)
139
+ self.assertEqual(raised.exception.code, "insufficient_credits")
140
+ self.assertEqual(raised.exception.correlation_id, "request-123")
141
+ self.assertEqual(raised.exception.details, {"available": 0})
142
+
143
+ def test_new_session_error_preserves_session_id(self) -> None:
144
+ client = MyTutor(api_key="mt_dev_test")
145
+ provider_error = HTTPError(
146
+ "https://api.mytutor.site/api/v1/tutoring/sessions/session-1/messages",
147
+ 503,
148
+ "Unavailable",
149
+ {},
150
+ io.BytesIO(
151
+ json.dumps(
152
+ {"error": {"code": "provider_unavailable", "message": "Try again later."}}
153
+ ).encode()
154
+ ),
155
+ )
156
+ with patch(
157
+ "mytutor.client.urlopen",
158
+ side_effect=[FakeResponse({"id": "session-1"}, 201), provider_error],
159
+ ):
160
+ with self.assertRaises(TutorAskError) as raised:
161
+ client.tutor.ask(subject_slug="mathematics", question="Explain fractions.")
162
+
163
+ self.assertEqual(raised.exception.session_id, "session-1")
164
+ self.assertEqual(raised.exception.code, "provider_unavailable")
165
+ self.assertIsInstance(raised.exception.cause, MyTutorError)
166
+
167
+ def test_ask_requires_exactly_one_context(self) -> None:
168
+ client = MyTutor(api_key="mt_dev_test")
169
+ with self.assertRaisesRegex(ValueError, "exactly one"):
170
+ client.tutor.ask(question="Explain fractions.")
171
+ with self.assertRaisesRegex(ValueError, "exactly one"):
172
+ client.tutor.ask(
173
+ question="Explain fractions.",
174
+ session_id="session-1",
175
+ subject_slug="mathematics",
176
+ )
177
+
178
+ def test_rejects_non_https_remote_api_origins(self) -> None:
179
+ with self.assertRaisesRegex(ValueError, "HTTPS API origin"):
180
+ MyTutor(api_key="mt_dev_test", base_url="http://api.mytutor.site")
181
+
182
+
183
+ if __name__ == "__main__":
184
+ unittest.main()