Metadata-Version: 2.5
Name: myprepcourse
Version: 0.1.0
Summary: A tool for making API calls and programatic changes to MyPrepCourse
Project-URL: Homepage, https://github.com/north-star-tutors/MyPrepCourse
Project-URL: Repository, https://github.com/north-star-tutors/MyPrepCourse
Project-URL: Issues, https://github.com/north-star-tutors/MyPrepCourse/issues
Project-URL: Documentation, https://github.com/north-star-tutors/MyPrepCourse#readme
Author-email: Zachary Mason <zachary@northstar-tutors.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: python-dateutil
Requires-Dist: requests
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Requires-Dist: types-requests; extra == 'dev'
Description-Content-Type: text/markdown

# MyPrepCourse SDK

A Python client for the [MyPrepCourse](https://myprepcourse.com) learning management API. Manage students, courses, test scores, and classrooms programmatically.

## Installation

```bash
pip install myprepcourse
```

**Requirements:** Python 3.9+

## Quick Start

```python
from MyPrepCourse import MyPrepCourse

client = MyPrepCourse(
    email="admin@example.com",
    password="your-password",
    origin="https://yourorg.myprepcourse.com",
)

# ...or let the SDK read the same three values from the environment:
#   client = MyPrepCourse()

# Look up a student
student = client.get_student_by_email("student@example.com")
print(student)

# Get test scores for a student's course
tests = client.get_student_tests_by_course(student)
for test in tests:
    print(f"{test.test.name}: {test.total_score}")
```

## Authentication

The SDK supports three ways to configure a client:

**Credentials** -- the SDK handles login and token refresh automatically:

```python
client = MyPrepCourse(
    email="admin@example.com",
    password="your-password",
    origin="https://yourorg.myprepcourse.com",
)
```

**Environment variables** -- omit an argument and the SDK reads it from the environment:

```bash
export MYPREPCOURSE_EMAIL="admin@example.com"
export MYPREPCOURSE_PASSWORD="your-password"
export MYPREPCOURSE_ORIGIN="https://yourorg.myprepcourse.com"
```

```python
client = MyPrepCourse()  # email, password, and origin all come from the environment
```

Explicit arguments take precedence over environment variables, so you can override a
single value without unsetting anything:

```python
# email and password still come from the environment
client = MyPrepCourse(origin="https://staging.myprepcourse.com")
```

See [Environment Variables](#environment-variables) for the full list.

**Existing token** -- bring your own bearer token via a pre-configured session:

```python
from MyPrepCourse import MPCSession, MyPrepCourse

session = MPCSession(
    token="your-bearer-token",
    origin="https://yourorg.myprepcourse.com",
)
client = MyPrepCourse(session=session)
```

The `origin` parameter is required and must match your organization's MyPrepCourse
tenant URL. It may be supplied as an argument or via `MYPREPCOURSE_ORIGIN`, and applies
equally to `MyPrepCourse(...)` and to an `MPCSession` you build yourself:

```python
session = MPCSession(token="your-bearer-token")  # origin from MYPREPCOURSE_ORIGIN
```

## Usage

### Students

```python
# Get a student by email or ID
student = client.get_student_by_email("jane@example.com")
student = client.get_student_by_id("12345")

# Search students
results = client.search_students(search="Jane")

# Create a student
student = client.create_student(
    first_name="Jane",
    last_name="Doe",
    email="jane@example.com",
)

# Get or create (idempotent)
student = client.get_or_create_student(
    first_name="Jane",
    last_name="Doe",
    email="jane@example.com",
)
```

### Courses

```python
# Course IDs are loaded per account onto the client instance
client.course_options.ACT          # the account's ACT course ID
client.course_options.available    # {name: course_id} for what this account has
"eACT" in client.course_options    # does this account have eACT?

# Select a course by name
course_name, course_id = client.select_course("ACT")

# Assign a course to a student
client.assign_course_to_student(student, course_id)
```

### The course context

Most endpoints are course-scoped: without an `X-Course-Id` header the API
answers **`401 Unauthenticated`**, which looks like a credentials problem but
isn't. The client therefore selects a course as soon as it has loaded the
catalog, the same way the web app always has one selected.

By default it picks `dSAT`, falling back to the first course your account has
from `DEFAULT_COURSE_PREFERENCE`. Override it per client or per environment:

```python
client = MyPrepCourse(email=..., password=..., origin=..., default_course="ACT")
```

```bash
export MYPREPCOURSE_DEFAULT_COURSE="ACT"
```

Naming a course your account does not have raises `ValueError` at construction
rather than failing later. A session you built yourself with `course_id=` keeps
that context untouched, and `select_course()` still changes it at any time.

Course IDs differ per account, so they live on the client instance, never on
the class. Both of these raise `AttributeError` rather than quietly returning
an empty string that would be sent to the API as a course ID:

```python
client.CourseOptions.eACT     # read off the class -- no IDs live there
client.course_options.eACT    # when the account has no eACT course
```

Use `hasattr` or `in` for a course the account may not have, and note the
names are case-sensitive (`eACT`, not `eact`).

### Test Scores

The SDK supports four test types: **dSAT**, **ACT**, **Old SAT**, and **eACT**. Each returns a typed score object with test-specific fields.

```python
# Get tests for the currently selected course
client.select_course("ACT")
tests = client.get_student_tests_by_course(student)

# ...or every course at once, without managing the selection yourself
tests = client.get_all_student_tests(student)

# Get detailed score breakdown for a specific test
score = client.get_test_score_details(student, test_id="abc123")

# Access scores through a common interface
print(score.total_score)
print(score.section_scores)  # {"Math": 720, "Reading & Writing": 680}

# Access test-type-specific fields
if isinstance(score, DSATTestScore):
    for subject in score.subjects:
        print(f"{subject.name}: {subject.current_score}")
```

#### Score report PDFs

`get_test_score_report` returns the rendered report as bytes. Writing or
uploading it is left to the caller, so the SDK does no filesystem work:

```python
from pathlib import Path

pdf = client.get_test_score_report(student, test.test_id, notes="Great progress")
Path(f"{test.name}.pdf").write_bytes(pdf)
```

#### eACT scores

The score endpoint returns no field distinguishing an eACT from a dSAT -- both
send an empty `sub_section_score` list -- so an eACT score is reported as a
`DSATTestScore` unless you say otherwise. The test type is on the student test
as `test.subtype`, so pass it through:

```python
for test in client.get_student_tests_by_course(student):
    score = client.get_test_score_details(
        student, test.test_id, test_type=test.test.subtype
    )
```

eACT responses also differ structurally from the other test types:
`sequence`, `source_id`, `active` and `reference_id` arrive as integers rather
than strings. The domain models accept both forms.

**There are two kinds of eACT**, and they are not structurally identical. Both
report `test.subtype == "eACT"`; `test.type` tells them apart:

| | `DIGITAL` (software-authored) | `PAPER` (ACT-authored) |
|---|---|---|
| `test.course_id` | the ACT course | the eACT course |
| `test.paces` | populated | `null` |
| `section.abv` | `"ENG"`, `"MATH"`, ... | `null` |
| `section.description` | rich-text `introPages` dict | `null` |
| `section.sequence` | 0-based (`0..3`) | 1-based (`1..4`) |

Sections are not returned in the order they are taken, and the sequence base
differs between the two, so sort rather than assuming either:

```python
for student_section in sorted(test.sections, key=lambda s: s.section.sequence):
    print(student_section.section.name, student_section.section.abv)
```

Because eACT sequences can start at 0, never test one for truthiness --
`if section.sequence:` skips the first section of a DIGITAL eACT.

#### eACT composite scores

On the new eACT, Science is scored separately and is **not** part of the
composite. `total_score` is the mean of English, Math and Reading, rounded.
The Science score is still reported in `scores[]` -- it just does not feed the
composite. Classic ACT composites do include Science, so the same arithmetic
does not carry across test types:

```python
scores = {s.section.name: s.section.subject_id for s in test.sections}
# eACT:       total_score == round(mean(English, Math, Reading))
# classic ACT: total_score == round(mean(English, Math, Reading, Science))
```

The SDK does not compute composites -- `total_score` comes from the API.

### Classrooms

```python
# Fetch a classroom with its location, students and instructors
classroom = client.get_class("class-123")

print(classroom.name, classroom.active_students)
print(classroom.location.name)

for student in classroom.students:
    print(student.first_name, student.last_name, student.email)

for instructor in classroom.instructors:
    print(instructor.first_name, instructor.email)

# Assign/remove students from a classroom
client.assign_student_to_class(student, class_id="class-123")
client.remove_student_from_class(student_id="12345", class_id="class-123")

# Remove several at once, choosing what happens to their assignments
from MyPrepCourse import RemovalStrategy

client.remove_students_from_class(
    [student_a, student_b],
    class_id="class-123",
    strategy=RemovalStrategy.KEEP_STARTED,
)
```

| Strategy | Effect on the class's assignments |
|----------|-----------------------------------|
| `KEEP_NONE` (default) | Removed from the student entirely, completed ones included |
| `KEEP_ALL` | All left available to the student |
| `KEEP_STARTED` | Only those already started or completed are kept |

A classroom that has not been scheduled yet returns `None` for `start_date`
and `end_date`, and empty lists for `students`, `instructors` and `sessions`.

The students embedded in a classroom come from the classroom payload, which
carries fewer fields than the student endpoint does. Attributes it omits --
`timezone`, `locations`, `instructors`, `stats` -- take their empty defaults;
call `get_student_by_id` for a fully populated profile.

### Locations

```python
locations = client.get_locations()
client.set_default_location(locations[0].id)
```

Calls that need a location, such as `create_student`, use `client.default_location`
when they are not given one explicitly. It can also be set at construction or
through the environment, which avoids hardcoding an ID that differs per account:

```python
client = MyPrepCourse(email=..., password=..., origin=..., default_location="loc-123")
```

```bash
export MYPREPCOURSE_DEFAULT_LOCATION="loc-123"
```

The ID is not validated at construction -- an unknown one surfaces when a call
uses it. `client.default_location` is a plain attribute, so assigning to it works
the same as calling `set_default_location()`.

## Domain Models

All API responses are parsed into typed, immutable dataclass objects. Key types:

| Type | Description |
|------|-------------|
| `Student` | Full student profile with courses, locations, instructors |
| `StudentCourse` | Course enrollment with subjects and stats |
| `StudentTest` | A test attempt with metadata and section breakdown |
| `DSATTestScore` | dSAT score with subjects and categories |
| `ACTTestScore` | ACT score with subjects and sub-sections |
| `OldSATTestScore` | Old SAT score with cross-test scores |
| `EACTTestScore` | eACT score with subjects and categories |
| `Classroom` | A class with its location, students and instructors |
| `Location` | Testing location |
| `Instructor` | Instructor profile |

All domain types are importable from the top-level package:

```python
from MyPrepCourse import Student, DSATTestScore, ACTTestScore, Location
```

Calls that act on a student take a reference rather than a bare string, so an
email cannot be mistaken for an ID. `Email`, `StudentId`, `StudentSummary` and
the `StudentRef` union are exported from the top-level package too:

```python
from MyPrepCourse import Email, StudentId

client.get_student_tests_by_course(Email("jane@example.com"))
client.remove_students_from_class([StudentId("12345")], class_id)
```

A `Student` or `StudentSummary` the client already gave you is a reference as
well, so anything it returns can be passed straight back in:

```python
for student in client.get_class(class_id).students:
    client.get_all_student_tests(student)
```

`resolve_student_id()` turns any of them into a `StudentId`, looking the
student up by email when that is all you have.

## Errors

Every error the SDK raises derives from `MyPrepCourseError`, so one `except`
clause covers the package:

```
MyPrepCourseError
├── AuthenticationError        # login failed, or the token was rejected
└── ObjectFetchError           # a fetch did not produce the expected object
    ├── NoSuchObjectError      # nothing matched
    └── TooManyObjectsFoundError  # several matched where one was expected
```

They are importable from the top-level package alongside the domain types:

```python
from MyPrepCourse import MyPrepCourseError, NoSuchObjectError

try:
    student = client.get_student(Email("nobody@example.com"))
except NoSuchObjectError:
    ...
except MyPrepCourseError as exc:
    ...
```

Configuration mistakes -- missing credentials, an unknown course name, a call
that needs a location when none is set -- raise the built-in `ValueError`
rather than a package exception, since they are caller errors rather than API
failures.

## Configuration

| Parameter | Environment variable | Default | Description |
|-----------|----------------------|---------|-------------|
| `origin` | `MYPREPCOURSE_ORIGIN` | *(required)* | Your tenant URL (e.g., `https://yourorg.myprepcourse.com`) |
| `email` | `MYPREPCOURSE_EMAIL` | `None` | Admin email for authentication |
| `password` | `MYPREPCOURSE_PASSWORD` | `None` | Admin password for authentication |
| `base_url` | `MYPREPCOURSE_BASE_URL` | `https://api.myprepcourse.com` | API base URL |
| `default_course` | `MYPREPCOURSE_DEFAULT_COURSE` | `dSAT`, then the first course you have | Course selected at startup |
| `default_location` | `MYPREPCOURSE_DEFAULT_LOCATION` | `None` | Location used by calls that need one and were not given it |
| `session` | -- | `None` | Pre-configured `MPCSession` instance |
| `timeout` | -- | `30` | Request timeout in seconds |

### Environment Variables

Any of the variables above may be set in the environment instead of being passed to
`MyPrepCourse(...)` or `MPCSession(...)`. Values are resolved in this order:

1. An explicit keyword argument
2. The matching `MYPREPCOURSE_*` environment variable
3. The built-in default (`base_url` and `default_course` only -- the rest have none)

Missing credentials raise a `ValueError` naming what is absent, so a misconfigured
environment fails at construction rather than on the first request:

```python
>>> MyPrepCourse()
ValueError: Email and password are required when no session is provided.
>>> MPCSession()
ValueError: Origin URL is required (e.g., 'https://yourorg.myprepcourse.com').
            Pass origin= or set MYPREPCOURSE_ORIGIN.
```

### Using a `.env` File

The SDK reads the process environment and does not parse `.env` files itself, which keeps
its dependencies to `requests` and `python-dateutil`. To use a `.env` file, load it in your
application before constructing the client:

```bash
pip install python-dotenv
```

```bash
# .env
MYPREPCOURSE_EMAIL=admin@example.com
MYPREPCOURSE_PASSWORD=your-password
MYPREPCOURSE_ORIGIN=https://yourorg.myprepcourse.com
```

```python
from dotenv import load_dotenv

load_dotenv()  # loads .env into the environment

from MyPrepCourse import MyPrepCourse

client = MyPrepCourse()
```

Never commit a `.env` file holding real credentials. This repository's `.gitignore`
already excludes `.env` and `.env.*`.

## Development

```bash
# Clone and install in editable mode with dev dependencies
git clone https://github.com/north-star-tutors/MyPrepCourse.git
cd MyPrepCourse
pip install -e ".[dev]"

# Run tests
python -m pytest tests/ -v

# Run tests with coverage
python -m pytest tests/ -v --cov=src/MyPrepCourse --cov-report=term-missing
```

## License

MIT
