myprepcourse 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,22 @@
1
+ **/*.env
2
+ **/*.env.*
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ *.egg
8
+ .eggs/
9
+ __pycache__/
10
+ **/__pycache__/
11
+ *.pyc
12
+ *.pyo
13
+ *.pyd
14
+ *.so
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .tox/
19
+ .coverage
20
+ htmlcov/
21
+ .idea/
22
+ json_api_responses/
@@ -0,0 +1,7 @@
1
+ Copyright 2026 North Star Tutors Inc
2
+
3
+ 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:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ 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,518 @@
1
+ Metadata-Version: 2.5
2
+ Name: myprepcourse
3
+ Version: 0.1.0
4
+ Summary: A tool for making API calls and programatic changes to MyPrepCourse
5
+ Project-URL: Homepage, https://github.com/north-star-tutors/MyPrepCourse
6
+ Project-URL: Repository, https://github.com/north-star-tutors/MyPrepCourse
7
+ Project-URL: Issues, https://github.com/north-star-tutors/MyPrepCourse/issues
8
+ Project-URL: Documentation, https://github.com/north-star-tutors/MyPrepCourse#readme
9
+ Author-email: Zachary Mason <zachary@northstar-tutors.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 4 - Beta
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: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: python-dateutil
24
+ Requires-Dist: requests
25
+ Provides-Extra: dev
26
+ Requires-Dist: build; extra == 'dev'
27
+ Requires-Dist: mypy; extra == 'dev'
28
+ Requires-Dist: pytest; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Requires-Dist: ruff; extra == 'dev'
31
+ Requires-Dist: twine; extra == 'dev'
32
+ Requires-Dist: types-requests; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # MyPrepCourse SDK
36
+
37
+ A Python client for the [MyPrepCourse](https://myprepcourse.com) learning management API. Manage students, courses, test scores, and classrooms programmatically.
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install myprepcourse
43
+ ```
44
+
45
+ **Requirements:** Python 3.9+
46
+
47
+ ## Quick Start
48
+
49
+ ```python
50
+ from MyPrepCourse import MyPrepCourse
51
+
52
+ client = MyPrepCourse(
53
+ email="admin@example.com",
54
+ password="your-password",
55
+ origin="https://yourorg.myprepcourse.com",
56
+ )
57
+
58
+ # ...or let the SDK read the same three values from the environment:
59
+ # client = MyPrepCourse()
60
+
61
+ # Look up a student
62
+ student = client.get_student_by_email("student@example.com")
63
+ print(student)
64
+
65
+ # Get test scores for a student's course
66
+ tests = client.get_student_tests_by_course(student)
67
+ for test in tests:
68
+ print(f"{test.test.name}: {test.total_score}")
69
+ ```
70
+
71
+ ## Authentication
72
+
73
+ The SDK supports three ways to configure a client:
74
+
75
+ **Credentials** -- the SDK handles login and token refresh automatically:
76
+
77
+ ```python
78
+ client = MyPrepCourse(
79
+ email="admin@example.com",
80
+ password="your-password",
81
+ origin="https://yourorg.myprepcourse.com",
82
+ )
83
+ ```
84
+
85
+ **Environment variables** -- omit an argument and the SDK reads it from the environment:
86
+
87
+ ```bash
88
+ export MYPREPCOURSE_EMAIL="admin@example.com"
89
+ export MYPREPCOURSE_PASSWORD="your-password"
90
+ export MYPREPCOURSE_ORIGIN="https://yourorg.myprepcourse.com"
91
+ ```
92
+
93
+ ```python
94
+ client = MyPrepCourse() # email, password, and origin all come from the environment
95
+ ```
96
+
97
+ Explicit arguments take precedence over environment variables, so you can override a
98
+ single value without unsetting anything:
99
+
100
+ ```python
101
+ # email and password still come from the environment
102
+ client = MyPrepCourse(origin="https://staging.myprepcourse.com")
103
+ ```
104
+
105
+ See [Environment Variables](#environment-variables) for the full list.
106
+
107
+ **Existing token** -- bring your own bearer token via a pre-configured session:
108
+
109
+ ```python
110
+ from MyPrepCourse import MPCSession, MyPrepCourse
111
+
112
+ session = MPCSession(
113
+ token="your-bearer-token",
114
+ origin="https://yourorg.myprepcourse.com",
115
+ )
116
+ client = MyPrepCourse(session=session)
117
+ ```
118
+
119
+ The `origin` parameter is required and must match your organization's MyPrepCourse
120
+ tenant URL. It may be supplied as an argument or via `MYPREPCOURSE_ORIGIN`, and applies
121
+ equally to `MyPrepCourse(...)` and to an `MPCSession` you build yourself:
122
+
123
+ ```python
124
+ session = MPCSession(token="your-bearer-token") # origin from MYPREPCOURSE_ORIGIN
125
+ ```
126
+
127
+ ## Usage
128
+
129
+ ### Students
130
+
131
+ ```python
132
+ # Get a student by email or ID
133
+ student = client.get_student_by_email("jane@example.com")
134
+ student = client.get_student_by_id("12345")
135
+
136
+ # Search students
137
+ results = client.search_students(search="Jane")
138
+
139
+ # Create a student
140
+ student = client.create_student(
141
+ first_name="Jane",
142
+ last_name="Doe",
143
+ email="jane@example.com",
144
+ )
145
+
146
+ # Get or create (idempotent)
147
+ student = client.get_or_create_student(
148
+ first_name="Jane",
149
+ last_name="Doe",
150
+ email="jane@example.com",
151
+ )
152
+ ```
153
+
154
+ ### Courses
155
+
156
+ ```python
157
+ # Course IDs are loaded per account onto the client instance
158
+ client.course_options.ACT # the account's ACT course ID
159
+ client.course_options.available # {name: course_id} for what this account has
160
+ "eACT" in client.course_options # does this account have eACT?
161
+
162
+ # Select a course by name
163
+ course_name, course_id = client.select_course("ACT")
164
+
165
+ # Assign a course to a student
166
+ client.assign_course_to_student(student, course_id)
167
+ ```
168
+
169
+ ### The course context
170
+
171
+ Most endpoints are course-scoped: without an `X-Course-Id` header the API
172
+ answers **`401 Unauthenticated`**, which looks like a credentials problem but
173
+ isn't. The client therefore selects a course as soon as it has loaded the
174
+ catalog, the same way the web app always has one selected.
175
+
176
+ By default it picks `dSAT`, falling back to the first course your account has
177
+ from `DEFAULT_COURSE_PREFERENCE`. Override it per client or per environment:
178
+
179
+ ```python
180
+ client = MyPrepCourse(email=..., password=..., origin=..., default_course="ACT")
181
+ ```
182
+
183
+ ```bash
184
+ export MYPREPCOURSE_DEFAULT_COURSE="ACT"
185
+ ```
186
+
187
+ Naming a course your account does not have raises `ValueError` at construction
188
+ rather than failing later. A session you built yourself with `course_id=` keeps
189
+ that context untouched, and `select_course()` still changes it at any time.
190
+
191
+ Course IDs differ per account, so they live on the client instance, never on
192
+ the class. Both of these raise `AttributeError` rather than quietly returning
193
+ an empty string that would be sent to the API as a course ID:
194
+
195
+ ```python
196
+ client.CourseOptions.eACT # read off the class -- no IDs live there
197
+ client.course_options.eACT # when the account has no eACT course
198
+ ```
199
+
200
+ Use `hasattr` or `in` for a course the account may not have, and note the
201
+ names are case-sensitive (`eACT`, not `eact`).
202
+
203
+ ### Test Scores
204
+
205
+ The SDK supports four test types: **dSAT**, **ACT**, **Old SAT**, and **eACT**. Each returns a typed score object with test-specific fields.
206
+
207
+ ```python
208
+ # Get tests for the currently selected course
209
+ client.select_course("ACT")
210
+ tests = client.get_student_tests_by_course(student)
211
+
212
+ # ...or every course at once, without managing the selection yourself
213
+ tests = client.get_all_student_tests(student)
214
+
215
+ # Get detailed score breakdown for a specific test
216
+ score = client.get_test_score_details(student, test_id="abc123")
217
+
218
+ # Access scores through a common interface
219
+ print(score.total_score)
220
+ print(score.section_scores) # {"Math": 720, "Reading & Writing": 680}
221
+
222
+ # Access test-type-specific fields
223
+ if isinstance(score, DSATTestScore):
224
+ for subject in score.subjects:
225
+ print(f"{subject.name}: {subject.current_score}")
226
+ ```
227
+
228
+ #### Score report PDFs
229
+
230
+ `get_test_score_report` returns the rendered report as bytes. Writing or
231
+ uploading it is left to the caller, so the SDK does no filesystem work:
232
+
233
+ ```python
234
+ from pathlib import Path
235
+
236
+ pdf = client.get_test_score_report(student, test.test_id, notes="Great progress")
237
+ Path(f"{test.name}.pdf").write_bytes(pdf)
238
+ ```
239
+
240
+ #### eACT scores
241
+
242
+ The score endpoint returns no field distinguishing an eACT from a dSAT -- both
243
+ send an empty `sub_section_score` list -- so an eACT score is reported as a
244
+ `DSATTestScore` unless you say otherwise. The test type is on the student test
245
+ as `test.subtype`, so pass it through:
246
+
247
+ ```python
248
+ for test in client.get_student_tests_by_course(student):
249
+ score = client.get_test_score_details(
250
+ student, test.test_id, test_type=test.test.subtype
251
+ )
252
+ ```
253
+
254
+ eACT responses also differ structurally from the other test types:
255
+ `sequence`, `source_id`, `active` and `reference_id` arrive as integers rather
256
+ than strings. The domain models accept both forms.
257
+
258
+ **There are two kinds of eACT**, and they are not structurally identical. Both
259
+ report `test.subtype == "eACT"`; `test.type` tells them apart:
260
+
261
+ | | `DIGITAL` (software-authored) | `PAPER` (ACT-authored) |
262
+ |---|---|---|
263
+ | `test.course_id` | the ACT course | the eACT course |
264
+ | `test.paces` | populated | `null` |
265
+ | `section.abv` | `"ENG"`, `"MATH"`, ... | `null` |
266
+ | `section.description` | rich-text `introPages` dict | `null` |
267
+ | `section.sequence` | 0-based (`0..3`) | 1-based (`1..4`) |
268
+
269
+ Sections are not returned in the order they are taken, and the sequence base
270
+ differs between the two, so sort rather than assuming either:
271
+
272
+ ```python
273
+ for student_section in sorted(test.sections, key=lambda s: s.section.sequence):
274
+ print(student_section.section.name, student_section.section.abv)
275
+ ```
276
+
277
+ Because eACT sequences can start at 0, never test one for truthiness --
278
+ `if section.sequence:` skips the first section of a DIGITAL eACT.
279
+
280
+ #### eACT composite scores
281
+
282
+ On the new eACT, Science is scored separately and is **not** part of the
283
+ composite. `total_score` is the mean of English, Math and Reading, rounded.
284
+ The Science score is still reported in `scores[]` -- it just does not feed the
285
+ composite. Classic ACT composites do include Science, so the same arithmetic
286
+ does not carry across test types:
287
+
288
+ ```python
289
+ scores = {s.section.name: s.section.subject_id for s in test.sections}
290
+ # eACT: total_score == round(mean(English, Math, Reading))
291
+ # classic ACT: total_score == round(mean(English, Math, Reading, Science))
292
+ ```
293
+
294
+ The SDK does not compute composites -- `total_score` comes from the API.
295
+
296
+ ### Classrooms
297
+
298
+ ```python
299
+ # Fetch a classroom with its location, students and instructors
300
+ classroom = client.get_class("class-123")
301
+
302
+ print(classroom.name, classroom.active_students)
303
+ print(classroom.location.name)
304
+
305
+ for student in classroom.students:
306
+ print(student.first_name, student.last_name, student.email)
307
+
308
+ for instructor in classroom.instructors:
309
+ print(instructor.first_name, instructor.email)
310
+
311
+ # Assign/remove students from a classroom
312
+ client.assign_student_to_class(student, class_id="class-123")
313
+ client.remove_student_from_class(student_id="12345", class_id="class-123")
314
+
315
+ # Remove several at once, choosing what happens to their assignments
316
+ from MyPrepCourse import RemovalStrategy
317
+
318
+ client.remove_students_from_class(
319
+ [student_a, student_b],
320
+ class_id="class-123",
321
+ strategy=RemovalStrategy.KEEP_STARTED,
322
+ )
323
+ ```
324
+
325
+ | Strategy | Effect on the class's assignments |
326
+ |----------|-----------------------------------|
327
+ | `KEEP_NONE` (default) | Removed from the student entirely, completed ones included |
328
+ | `KEEP_ALL` | All left available to the student |
329
+ | `KEEP_STARTED` | Only those already started or completed are kept |
330
+
331
+ A classroom that has not been scheduled yet returns `None` for `start_date`
332
+ and `end_date`, and empty lists for `students`, `instructors` and `sessions`.
333
+
334
+ The students embedded in a classroom come from the classroom payload, which
335
+ carries fewer fields than the student endpoint does. Attributes it omits --
336
+ `timezone`, `locations`, `instructors`, `stats` -- take their empty defaults;
337
+ call `get_student_by_id` for a fully populated profile.
338
+
339
+ ### Locations
340
+
341
+ ```python
342
+ locations = client.get_locations()
343
+ client.set_default_location(locations[0].id)
344
+ ```
345
+
346
+ Calls that need a location, such as `create_student`, use `client.default_location`
347
+ when they are not given one explicitly. It can also be set at construction or
348
+ through the environment, which avoids hardcoding an ID that differs per account:
349
+
350
+ ```python
351
+ client = MyPrepCourse(email=..., password=..., origin=..., default_location="loc-123")
352
+ ```
353
+
354
+ ```bash
355
+ export MYPREPCOURSE_DEFAULT_LOCATION="loc-123"
356
+ ```
357
+
358
+ The ID is not validated at construction -- an unknown one surfaces when a call
359
+ uses it. `client.default_location` is a plain attribute, so assigning to it works
360
+ the same as calling `set_default_location()`.
361
+
362
+ ## Domain Models
363
+
364
+ All API responses are parsed into typed, immutable dataclass objects. Key types:
365
+
366
+ | Type | Description |
367
+ |------|-------------|
368
+ | `Student` | Full student profile with courses, locations, instructors |
369
+ | `StudentCourse` | Course enrollment with subjects and stats |
370
+ | `StudentTest` | A test attempt with metadata and section breakdown |
371
+ | `DSATTestScore` | dSAT score with subjects and categories |
372
+ | `ACTTestScore` | ACT score with subjects and sub-sections |
373
+ | `OldSATTestScore` | Old SAT score with cross-test scores |
374
+ | `EACTTestScore` | eACT score with subjects and categories |
375
+ | `Classroom` | A class with its location, students and instructors |
376
+ | `Location` | Testing location |
377
+ | `Instructor` | Instructor profile |
378
+
379
+ All domain types are importable from the top-level package:
380
+
381
+ ```python
382
+ from MyPrepCourse import Student, DSATTestScore, ACTTestScore, Location
383
+ ```
384
+
385
+ Calls that act on a student take a reference rather than a bare string, so an
386
+ email cannot be mistaken for an ID. `Email`, `StudentId`, `StudentSummary` and
387
+ the `StudentRef` union are exported from the top-level package too:
388
+
389
+ ```python
390
+ from MyPrepCourse import Email, StudentId
391
+
392
+ client.get_student_tests_by_course(Email("jane@example.com"))
393
+ client.remove_students_from_class([StudentId("12345")], class_id)
394
+ ```
395
+
396
+ A `Student` or `StudentSummary` the client already gave you is a reference as
397
+ well, so anything it returns can be passed straight back in:
398
+
399
+ ```python
400
+ for student in client.get_class(class_id).students:
401
+ client.get_all_student_tests(student)
402
+ ```
403
+
404
+ `resolve_student_id()` turns any of them into a `StudentId`, looking the
405
+ student up by email when that is all you have.
406
+
407
+ ## Errors
408
+
409
+ Every error the SDK raises derives from `MyPrepCourseError`, so one `except`
410
+ clause covers the package:
411
+
412
+ ```
413
+ MyPrepCourseError
414
+ ├── AuthenticationError # login failed, or the token was rejected
415
+ └── ObjectFetchError # a fetch did not produce the expected object
416
+ ├── NoSuchObjectError # nothing matched
417
+ └── TooManyObjectsFoundError # several matched where one was expected
418
+ ```
419
+
420
+ They are importable from the top-level package alongside the domain types:
421
+
422
+ ```python
423
+ from MyPrepCourse import MyPrepCourseError, NoSuchObjectError
424
+
425
+ try:
426
+ student = client.get_student(Email("nobody@example.com"))
427
+ except NoSuchObjectError:
428
+ ...
429
+ except MyPrepCourseError as exc:
430
+ ...
431
+ ```
432
+
433
+ Configuration mistakes -- missing credentials, an unknown course name, a call
434
+ that needs a location when none is set -- raise the built-in `ValueError`
435
+ rather than a package exception, since they are caller errors rather than API
436
+ failures.
437
+
438
+ ## Configuration
439
+
440
+ | Parameter | Environment variable | Default | Description |
441
+ |-----------|----------------------|---------|-------------|
442
+ | `origin` | `MYPREPCOURSE_ORIGIN` | *(required)* | Your tenant URL (e.g., `https://yourorg.myprepcourse.com`) |
443
+ | `email` | `MYPREPCOURSE_EMAIL` | `None` | Admin email for authentication |
444
+ | `password` | `MYPREPCOURSE_PASSWORD` | `None` | Admin password for authentication |
445
+ | `base_url` | `MYPREPCOURSE_BASE_URL` | `https://api.myprepcourse.com` | API base URL |
446
+ | `default_course` | `MYPREPCOURSE_DEFAULT_COURSE` | `dSAT`, then the first course you have | Course selected at startup |
447
+ | `default_location` | `MYPREPCOURSE_DEFAULT_LOCATION` | `None` | Location used by calls that need one and were not given it |
448
+ | `session` | -- | `None` | Pre-configured `MPCSession` instance |
449
+ | `timeout` | -- | `30` | Request timeout in seconds |
450
+
451
+ ### Environment Variables
452
+
453
+ Any of the variables above may be set in the environment instead of being passed to
454
+ `MyPrepCourse(...)` or `MPCSession(...)`. Values are resolved in this order:
455
+
456
+ 1. An explicit keyword argument
457
+ 2. The matching `MYPREPCOURSE_*` environment variable
458
+ 3. The built-in default (`base_url` and `default_course` only -- the rest have none)
459
+
460
+ Missing credentials raise a `ValueError` naming what is absent, so a misconfigured
461
+ environment fails at construction rather than on the first request:
462
+
463
+ ```python
464
+ >>> MyPrepCourse()
465
+ ValueError: Email and password are required when no session is provided.
466
+ >>> MPCSession()
467
+ ValueError: Origin URL is required (e.g., 'https://yourorg.myprepcourse.com').
468
+ Pass origin= or set MYPREPCOURSE_ORIGIN.
469
+ ```
470
+
471
+ ### Using a `.env` File
472
+
473
+ The SDK reads the process environment and does not parse `.env` files itself, which keeps
474
+ its dependencies to `requests` and `python-dateutil`. To use a `.env` file, load it in your
475
+ application before constructing the client:
476
+
477
+ ```bash
478
+ pip install python-dotenv
479
+ ```
480
+
481
+ ```bash
482
+ # .env
483
+ MYPREPCOURSE_EMAIL=admin@example.com
484
+ MYPREPCOURSE_PASSWORD=your-password
485
+ MYPREPCOURSE_ORIGIN=https://yourorg.myprepcourse.com
486
+ ```
487
+
488
+ ```python
489
+ from dotenv import load_dotenv
490
+
491
+ load_dotenv() # loads .env into the environment
492
+
493
+ from MyPrepCourse import MyPrepCourse
494
+
495
+ client = MyPrepCourse()
496
+ ```
497
+
498
+ Never commit a `.env` file holding real credentials. This repository's `.gitignore`
499
+ already excludes `.env` and `.env.*`.
500
+
501
+ ## Development
502
+
503
+ ```bash
504
+ # Clone and install in editable mode with dev dependencies
505
+ git clone https://github.com/north-star-tutors/MyPrepCourse.git
506
+ cd MyPrepCourse
507
+ pip install -e ".[dev]"
508
+
509
+ # Run tests
510
+ python -m pytest tests/ -v
511
+
512
+ # Run tests with coverage
513
+ python -m pytest tests/ -v --cov=src/MyPrepCourse --cov-report=term-missing
514
+ ```
515
+
516
+ ## License
517
+
518
+ MIT