From 1b34fd31272ee52a32275ef2bf76d1035b7a1f36 Mon Sep 17 00:00:00 2001 From: Amaan Javed Date: Mon, 16 Mar 2026 23:56:55 -0400 Subject: [PATCH] initial changes --- README.md | 335 +---- docs/configuration.md | 42 +- docs/extras.md | 17 +- docs/index.md | 35 +- docs/reference.md | 93 +- docs/usage.md | 111 +- src/rmp_client/__init__.py | 23 +- src/rmp_client/client.py | 1298 +++++++---------- src/rmp_client/config.py | 35 +- .../__pycache__/__init__.cpython-313.pyc | Bin 202 -> 503 bytes .../__pycache__/sentiment.cpython-313.pyc | Bin 1765 -> 1652 bytes src/rmp_client/http.py | 70 +- src/rmp_client/models.py | 94 +- src/rmp_client/queries.py | 219 +++ src/rmp_client/relay_store.py | 516 ------- tests/test_client.py | 976 ++++++++----- tests/test_config.py | 47 +- tests/test_http.py | 60 +- tests/test_relay_store.py | 312 ---- 19 files changed, 1807 insertions(+), 2476 deletions(-) create mode 100644 src/rmp_client/queries.py delete mode 100644 src/rmp_client/relay_store.py delete mode 100644 tests/test_relay_store.py diff --git a/README.md b/README.md index 9e6ce96..a35e39f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -# RateMyProfessors API Client +# RateMyProfessors API Client (Python) -Typed, retrying, rate-limited unofficial client for RateMyProfessors, with built-in -helpers for ingestion workflows (sentiment, dedupe, course-code normalization). +A typed, retrying, rate-limited **unofficial** client for [RateMyProfessors](https://www.ratemyprofessors.com). -> Note: This library is **unofficial** and may break if RMP changes their internal API. -> This library has been made open-source so that if/when there are any changes, -> someone is able to take note of these changes and help to contribute an update. +> **Disclaimer:** This library is unofficial and may break if RMP changes their internal API. Use responsibly and respect rate limits. + +## Requirements + +- **Python 3.10** or later +- Works with type checkers (Pydantic models, fully typed API) ## Installation @@ -13,276 +15,98 @@ helpers for ingestion workflows (sentiment, dedupe, course-code normalization). pip install ratemyprofessors-client ``` -## Quickstart +## Available Functions -**Professor by ID** (data from professor page HTML): +Create a client and call any of these methods. See the [full docs](docs/) for parameters, return types, and examples. ```python from rmp_client import RMPClient with RMPClient() as client: - professor = client.get_professor("2823076") # legacy ID from URL - print(professor.name, professor.overall_rating, professor.num_ratings, professor.school.name) + ... ``` -**School by ID** (data from school page HTML): +**Schools** -```python -with RMPClient() as client: - school = client.get_school("1466") - print(school.name, school.location, school.overall_quality, school.num_ratings) -``` +- `search_schools(query)` — Search schools by name. Returns paginated results. +- `get_school(school_id)` — Get a single school by its numeric ID. +- `get_compare_schools(school_id_1, school_id_2)` — Fetch two schools side by side. +- `get_school_ratings_page(school_id)` — Get one page of school ratings (cached after first fetch). +- `iter_school_ratings(school_id)` — Iterator over all ratings for a school. -**Search professors or schools** (data from search page HTML): +**Professors** -```python -with RMPClient() as client: - profs = client.search_professors("test") - print(profs.total, profs.has_next_page) - for p in profs.professors[:5]: - print(p.name, p.school.name if p.school else "") +- `search_professors(query)` — Search professors by name. Returns paginated results. +- `list_professors_for_school(school_id)` — List professors at a given school. +- `iter_professors_for_school(school_id)` — Iterator over all professors at a school. +- `get_professor(professor_id)` — Get a single professor by their numeric ID. +- `get_professor_ratings_page(professor_id)` — Get one page of professor ratings (cached after first fetch). +- `iter_professor_ratings(professor_id)` — Iterator over all ratings for a professor. - schools = client.search_schools("queens") - for s in schools.schools: - print(s.name, s.location) -``` +**Low-level** -**Compare two schools** (data from compare page HTML): +- `raw_query(payload)` — Send a raw GraphQL payload to the RMP endpoint. -```python -with RMPClient() as client: - result = client.get_compare_schools("1466", "1491") - print(result.school_1.name, result.school_2.name) -``` +**Lifecycle** -**Iterate professor ratings** (first page from HTML, further pages via GraphQL): +- `close()` — Close the client and clear caches. Safe to call multiple times. -```python -from datetime import date -from rmp_client import RMPClient +## Errors and What They Mean -with RMPClient() as client: - for rating in client.iter_professor_ratings("2823076", since=date(2024, 1, 1)): - print(rating.date, rating.quality, rating.comment) -``` +All errors extend `RMPError`. Catch and narrow with `isinstance`: -<<<<<<< Updated upstream -**Verify the client** (run the script to hit the live site and print sample data): - -```bash -pip install -e . -python scripts/verify_client.py # up to 3 pages of ratings per section (default) -python scripts/verify_client.py --max-pages 10 --page-size 20 # scrape more pages -``` - -**Scrape all ratings** for a professor or school: the client fetches the first page from HTML and subsequent pages via the site’s GraphQL API. Use the iterators to get every rating: +- **`HttpError`** — The server returned a non-2xx status code (e.g. 404, 500). +- **`ParsingError`** — The response couldn't be parsed (e.g. professor/school not found). +- **`RateLimitError`** — The client's local rate limiter blocked the request. +- **`RetryError`** — The request failed after all retry attempts. Contains the last underlying error. +- **`RMPAPIError`** — The GraphQL API returned an `errors` array in the response. +- **`ConfigurationError`** — Invalid client configuration. ```python -with RMPClient() as client: - for rating in client.iter_professor_ratings("2823076"): - print(rating.date, rating.comment) - for rating in client.iter_school_ratings("1466"): - print(rating.date, rating.comment) -``` - -## How it works - -### Package architecture - -```mermaid -flowchart TB - subgraph Your code - User["Your script / app"] - end - - subgraph rmp_client [rmp_client package] - Client["RMPClient\n(client.py)"] - Config["RMPClientConfig\n(config.py)"] - Models["Models\n(School, Professor, Rating)\n(models.py)"] - Errors["RMPError hierarchy\n(errors.py)"] - end - - subgraph HTTP layer - HttpCtx["HttpClientContext\n(http.py)"] - Http["HttpClient\n(retries, headers)"] - Bucket["TokenBucket\n(rate_limit.py)"] - end - - subgraph External - RMP["RMP pages\n(ratemyprofessors.com)"] - end - - User --> Client - Client --> Config - Client --> HttpCtx - HttpCtx --> Http - Http --> Bucket - Http --> RMP - Client --> Models - Client --> Errors -``` - -### Request flow - -Professor, school, compare-schools, and search endpoints **fetch the relevant RMP page HTML** (GET), extract `window.__RELAY_STORE__` from the response, and parse it into `Professor`, `School`, `Rating`, or search result lists. - -**Ratings pagination (Relay):** The first page of professor or school ratings comes from the same HTML (Relay store). The store’s connection includes: - -- **`pageInfo.endCursor`** — opaque cursor for “start after this item” -- **`pageInfo.hasNextPage`** — whether more ratings exist +from rmp_client import RMPClient, HttpError, ParsingError -The client then requests the next page by POSTing to `/graphql` with the same query and variables: - -- `id` — Relay node id (base64 of `Teacher-{legacyId}` or `School-{legacyId}`) -- `first` — page size (e.g. 20) -- `after` — `pageInfo.endCursor` from the previous response - -Loop until `hasNextPage` is false. The cursor is typically base64 for an internal offset (e.g. `YXJyYXljb25uZWN0aW9uOjQ=` decodes to `arrayconnection:4`, meaning “after item 4”). RMP does not rotate or expire these cursors, so you can paginate with plain HTTP requests without a browser. This client sends the **full GraphQL query** in each request; if the site ever required persisted queries (e.g. `doc_id` only), you’d capture the real request from the browser and reuse that format. - -```mermaid -sequenceDiagram - participant User - participant RMPClient - participant HttpClient - participant TokenBucket - participant httpx - participant RMP - - User->>RMPClient: e.g. get_professor(id), get_school(id), search_professors(q), get_compare_schools(id1, id2) - RMPClient->>HttpClient: get_html(url) - HttpClient->>TokenBucket: consume() - TokenBucket-->>HttpClient: (blocks until token available) - HttpClient->>httpx: GET page URL - httpx->>RMP: HTTPS request - RMP-->>httpx: HTML (with __RELAY_STORE__) - httpx-->>HttpClient: response - HttpClient-->>RMPClient: HTML text - RMPClient->>RMPClient: Extract and parse __RELAY_STORE__, resolve refs - RMPClient->>RMPClient: Map to Professor / School / Rating / SearchResult - RMPClient-->>User: Professor, School, list, or CompareSchoolsResult -``` - -### Data models - -```mermaid -erDiagram - School ||--o{ Professor : "has" - Professor ||--o{ Rating : "has" - - School { - string id - string name - string location - float overall_quality - int num_ratings - } - - Professor { - string id - string name - string department - float overall_rating - int num_ratings - School school - } - - Rating { - date date - string comment - float quality - float difficulty - string course_raw - } - - ProfessorSearchResult { - Professor[] professors - int total - bool has_next_page - string next_cursor - } - - SchoolSearchResult { - School[] schools - int total - bool has_next_page - string next_cursor - } - - CompareSchoolsResult { - School school_1 - School school_2 - } - - ProfessorRatingsPage { - Professor professor - Rating[] ratings - bool has_next_page - string next_cursor - } +with RMPClient() as client: + try: + prof = client.get_professor("2823076") + except ParsingError: + print("Professor not found") + except HttpError as e: + print(f"HTTP error: {e.status_code}") ``` -### Extras and ingestion pipeline - -```mermaid -flowchart LR - subgraph RMPClient - iter_professors["iter_professors_for_school"] - iter_ratings["iter_professor_ratings"] - end +## Types - subgraph extras [rmp_client.extras] - dedupe["dedupe\n(normalize_comment,\n is_valid_comment)"] - sentiment["sentiment\n(analyze_sentiment)"] - course_codes["course_codes\n(build_course_mapping)"] - end +All methods return Pydantic models. Import any of these: - subgraph Your pipeline [Your pipeline e.g. ingest_supabase] - filter["Filter comments"] - store["Supabase / DB"] - end - - iter_professors --> iter_ratings - iter_ratings --> filter - filter --> dedupe - dedupe --> sentiment - iter_ratings --> course_codes - sentiment --> store - course_codes --> store +```python +from rmp_client.models import ( + School, + Professor, + Rating, + SchoolRating, + ProfessorSearchResult, + SchoolSearchResult, + ProfessorRatingsPage, + SchoolRatingsPage, + CompareSchoolsResult, +) ``` -### CI/CD (publish to PyPI) - -```mermaid -flowchart LR - subgraph On any push - T[Run tests\npytest] - end - - subgraph On main push - B[Build wheel + sdist] - TestPyPI[Publish to TestPyPI] - end - - subgraph On release published - PyPI[Publish to PyPI] - end - - T --> B - B --> TestPyPI - B --> PyPI -``` +- **`School`** — ID, name, location, overall quality, category ratings (reputation, safety, etc.) +- **`Professor`** — ID, name, department, school, overall rating, difficulty, percent take again +- **`Rating`** — Date, comment, quality, difficulty, tags, course, thumbs up/down +- **`SchoolRating`** — Date, comment, overall score, category ratings, thumbs up/down +- **`ProfessorSearchResult`** / **`SchoolSearchResult`** — Paginated list with `has_next_page` and `next_cursor` +- **`ProfessorRatingsPage`** / **`SchoolRatingsPage`** — One page of ratings with cursor pagination +- **`CompareSchoolsResult`** — A pair of schools ## Extras -======= -## Helpers ->>>>>>> Stashed changes -The package includes helpers for ingestion pipelines; import them from the main module: +Optional helpers for data pipelines: ```python from rmp_client import ( - RMPClient, - analyze_sentiment, # sentiment score/label (uses TextBlob) + analyze_sentiment, normalize_comment, is_valid_comment, build_course_mapping, @@ -290,23 +114,8 @@ from rmp_client import ( ) ``` -- **Sentiment:** `analyze_sentiment(text)` returns a score and label (e.g. positive, neutral). -- **Dedupe:** `normalize_comment(text)` and `is_valid_comment(text, min_len=10)` for filtering/normalizing comments. -- **Course codes:** `clean_course_label(raw)` and `build_course_mapping(scraped_labels, valid_courses)` for mapping RMP course strings to your catalog. - -See `docs/` and `examples/` for more. The repo includes `examples/ingest_supabase.py` for a Supabase-backed scraping pipeline. - -## Publishing to PyPI - -This project follows the [Python Packaging User Guide](https://packaging.python.org/en/latest/overview/) and uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) with GitHub Actions. - -1. **One-time setup**: On [pypi.org](https://pypi.org/manage/account/publishing/) add a trusted publisher for this repo (workflow `publish-to-pypi.yml`, environment `pypi`). Create a `pypi` environment in the repo and enable “Required reviewers” for production releases. -2. **Release**: Create and push a tag (e.g. `v0.1.0`). The workflow builds both a [wheel and an sdist](https://packaging.python.org/en/latest/overview/#python-binary-distributions) and publishes to PyPI. Any push builds and publishes to TestPyPI (use the `testpypi` environment). - -Local build (no publish): - -```bash -pip install build -python -m build -# Outputs in dist/: .whl (wheel) and .tar.gz (sdist) -``` +- `normalize_comment(text)` — Normalize text for deduplication (lowercase, collapse whitespace) +- `is_valid_comment(text, min_len=10)` — Check if a comment is non-empty and meets a minimum length +- `clean_course_label(raw)` — Clean scraped course labels (remove counts, normalize whitespace) +- `build_course_mapping(scraped, valid)` — Map scraped labels to known course codes +- `analyze_sentiment(text)` — Compute sentiment label from text (uses TextBlob) diff --git a/docs/configuration.md b/docs/configuration.md index 3744aa1..f215d2f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ ### Configuration -The client is configured via `RMPClientConfig` and environment variables. +The client is configured via `RMPClientConfig`. All fields have sensible defaults. ```python from rmp_client import RMPClientConfig, RMPClient @@ -12,13 +12,41 @@ config = RMPClientConfig( rate_limit_per_minute=60, ) -client = RMPClient(config) +with RMPClient(config) as client: + ... ``` -Environment variables (optional): +#### Available options -- `RMP_CLIENT_BASE_URL` -- `RMP_CLIENT_TIMEOUT_SECONDS` -- `RMP_CLIENT_MAX_RETRIES` -- `RMP_CLIENT_RATE_LIMIT_PER_MINUTE` +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `base_url` | `str` | `https://www.ratemyprofessors.com/graphql` | GraphQL endpoint URL | +| `timeout_seconds` | `float` | `10.0` | HTTP request timeout | +| `max_retries` | `int` | `3` | Number of retry attempts for failed requests | +| `rate_limit_per_minute` | `int` | `60` | Max requests per minute (token bucket) | +| `user_agent` | `str` | Firefox UA | User-Agent header sent with every request | +| `default_headers` | `Mapping[str, str]` | UA + Accept-Language | Default headers for all requests | +#### Rate limiting + +The client uses a token-bucket algorithm. Tokens replenish continuously at `rate_limit_per_minute / 60` tokens per second. Each request consumes one token. If no tokens are available, the request blocks until one becomes available. + +```python +config = RMPClientConfig(rate_limit_per_minute=30) # half the default rate +``` + +#### Retries + +On 5xx errors or network failures, the client retries up to `max_retries` times. 4xx errors are **not** retried. After exhausting retries, a `RetryError` is raised containing the last underlying exception. + +```python +config = RMPClientConfig(max_retries=5) # more retries for flaky networks +``` + +#### Timeouts + +The `timeout_seconds` value applies to each individual HTTP request (connect + read). + +```python +config = RMPClientConfig(timeout_seconds=30.0) # generous timeout +``` diff --git a/docs/extras.md b/docs/extras.md index b251b5f..e09c426 100644 --- a/docs/extras.md +++ b/docs/extras.md @@ -14,26 +14,35 @@ from rmp_client import ( #### Sentiment +Compute a sentiment score and label from comment text (uses TextBlob internally). + ```python result = analyze_sentiment("Great prof, explains concepts clearly.") -print(result.score, result.label) +print(result.score, result.label) # e.g. 0.65 "positive" ``` #### Dedupe helpers +Normalize comments for deduplication and filter out low-quality entries. + ```python raw = " This prof is AMAZING!!! " -normalized = normalize_comment(raw) -if is_valid_comment(normalized): - ... +normalized = normalize_comment(raw) # "this prof is amazing!!!" +if is_valid_comment(normalized, min_len=10): + print("Valid comment") ``` #### Course code helpers +Map scraped RMP course labels to your course catalog. + ```python scraped = ["ANAT 215 (12)", "phys115"] valid = ["ANAT 215", "PHYS 115"] mapping = build_course_mapping(scraped, valid) +# {"ANAT 215 (12)": "ANAT 215", "phys115": "PHYS 115"} + cleaned = clean_course_label("MATH 101 (5)") +# "MATH 101" ``` diff --git a/docs/index.md b/docs/index.md index a69108f..faae2f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,16 +1,35 @@ ### RateMyProfessors API Client -This is an unofficial, typed client for RateMyProfessors. +An unofficial, typed Python client for [RateMyProfessors](https://www.ratemyprofessors.com). + +All data is fetched via RMP's GraphQL API -- no HTML scraping or browser automation required. + +**Features:** - Strong typing via Pydantic models -- Automatic retries and simple rate limiting -- Clear error hierarchy +- Automatic retries with configurable max attempts +- Token-bucket rate limiting (default 60 req/min) +- In-memory caching for ratings pages (pre-fetches all ratings on first load) +- Cursor-based pagination for all list/search endpoints +- Clear error hierarchy for precise exception handling - Built-in helpers for ingestion workflows (sentiment, dedupe, course codes) -See: +**Quick start:** + +```python +from rmp_client import RMPClient + +with RMPClient() as client: + prof = client.get_professor("2823076") + print(prof.name, prof.overall_rating) + + for rating in client.iter_professor_ratings(prof.id): + print(rating.date, rating.quality, rating.comment) +``` -- `usage.md` for quickstart examples -- `configuration.md` for tuning retries, rate limits, and headers -- `extras.md` for ingestion helpers (sentiment, dedupe, course mapping) -- `reference.md` for generated API reference (once wired) +**Documentation:** +- [Usage](usage.md) — Quickstart examples for every endpoint +- [Configuration](configuration.md) — Tuning retries, rate limits, timeouts, and headers +- [API Reference](reference.md) — Full method and type reference +- [Extras](extras.md) — Ingestion helpers (sentiment, dedupe, course mapping) diff --git a/docs/reference.md b/docs/reference.md index c1713d8..bbe5356 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,12 +1,91 @@ ### API Reference -This file is a placeholder for generated API reference using a tool such as `mkdocstrings` -or Sphinx autodoc. +#### RMPClient -Key entry points: +The main entry point. Use as a context manager or call `close()` when done. -- `rmp_client.RMPClient` -- `rmp_client.RMPClientConfig` -- `rmp_client.errors` -- `rmp_client.models` +```python +from rmp_client import RMPClient, RMPClientConfig +with RMPClient(config=RMPClientConfig()) as client: + ... +``` + +**School methods:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `search_schools(query, *, page_size=20, cursor=None)` | `SchoolSearchResult` | Search schools by name | +| `get_school(school_id)` | `School` | Fetch a single school with category ratings | +| `get_compare_schools(school_id_1, school_id_2)` | `CompareSchoolsResult` | Fetch two schools side by side | +| `get_school_ratings_page(school_id, *, cursor=None, page_size=20)` | `SchoolRatingsPage` | Get one page of school ratings (cached) | +| `iter_school_ratings(school_id, *, page_size=20, since=None)` | `Iterator[SchoolRating]` | Iterate all school ratings | + +**Professor methods:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `search_professors(query, *, school_id=None, page_size=20, cursor=None)` | `ProfessorSearchResult` | Search professors by name | +| `list_professors_for_school(school_id, *, query=None, page_size=20, cursor=None)` | `ProfessorSearchResult` | List professors at a school | +| `iter_professors_for_school(school_id, *, query=None, page_size=20)` | `Iterator[Professor]` | Iterate all professors at a school | +| `get_professor(professor_id)` | `Professor` | Fetch a single professor | +| `get_professor_ratings_page(professor_id, *, cursor=None, page_size=20, course_filter=None)` | `ProfessorRatingsPage` | Get one page of professor ratings (cached) | +| `iter_professor_ratings(professor_id, *, page_size=20, since=None, course_filter=None)` | `Iterator[Rating]` | Iterate all professor ratings | + +**Low-level:** + +| Method | Returns | Description | +|--------|---------|-------------| +| `raw_query(payload)` | `dict` | Send a raw GraphQL payload | +| `close()` | `None` | Close the HTTP client and clear caches | + +--- + +#### Models + +All models are Pydantic `BaseModel` subclasses. + +**`School`** — `id`, `name`, `location`, `overall_quality`, `num_ratings`, `reputation`, `safety`, `happiness`, `facilities`, `social`, `location_rating`, `clubs`, `opportunities`, `internet`, `food` + +**`Professor`** — `id`, `name`, `department`, `school` (School), `url`, `overall_rating`, `num_ratings`, `percent_take_again`, `level_of_difficulty`, `tags`, `rating_distribution` + +**`Rating`** — `date`, `comment`, `quality`, `difficulty`, `tags`, `course_raw`, `details`, `thumbs_up`, `thumbs_down` + +**`SchoolRating`** — `date`, `comment`, `overall`, `category_ratings` (dict), `thumbs_up`, `thumbs_down` + +**`ProfessorSearchResult`** — `professors`, `total`, `page_size`, `has_next_page`, `next_cursor` + +**`SchoolSearchResult`** — `schools`, `total`, `page_size`, `has_next_page`, `next_cursor` + +**`ProfessorRatingsPage`** — `professor`, `ratings`, `has_next_page`, `next_cursor` + +**`SchoolRatingsPage`** — `school`, `ratings`, `has_next_page`, `next_cursor` + +**`CompareSchoolsResult`** — `school_1`, `school_2` + +--- + +#### Errors + +All errors extend `RMPError`. + +| Error | Description | +|-------|-------------| +| `HttpError` | Non-2xx HTTP response. Has `status_code`, `url`, `body`. | +| `ParsingError` | Could not parse the GraphQL response (e.g. entity not found). | +| `RateLimitError` | Local rate limiter blocked the request. | +| `RetryError` | All retry attempts exhausted. Wraps the last exception. | +| `RMPAPIError` | GraphQL API returned an `errors` array. Has `details`. | +| `ConfigurationError` | Invalid client configuration. | + +```python +from rmp_client import RMPClient, HttpError, ParsingError + +with RMPClient() as client: + try: + prof = client.get_professor("999999") + except ParsingError: + print("Professor not found") + except HttpError as e: + print(f"HTTP {e.status_code}") +``` diff --git a/docs/usage.md b/docs/usage.md index 8905245..cfecfa7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,38 +1,127 @@ ### Usage -#### Basic client +All examples use the `RMPClient` context manager, which handles connection setup and teardown. + +#### Search schools ```python from rmp_client import RMPClient with RMPClient() as client: - result = client.search_professors("Smith", page_size=10) + result = client.search_schools("queens") + for school in result.schools: + print(school.name, school.location, school.overall_quality) + + # Cursor pagination + if result.has_next_page: + page2 = client.search_schools("queens", cursor=result.next_cursor) +``` + +#### Get a school by ID + +```python +with RMPClient() as client: + school = client.get_school("1466") + print(school.name, school.location, school.overall_quality) + print(f"Reputation: {school.reputation}, Safety: {school.safety}") +``` + +#### Compare two schools + +```python +with RMPClient() as client: + result = client.get_compare_schools("1466", "1491") + print(result.school_1.name, "vs", result.school_2.name) +``` + +#### Search professors + +```python +with RMPClient() as client: + result = client.search_professors("Smith") for prof in result.professors: - print(prof.name, prof.overall_rating) + print(prof.name, prof.overall_rating, prof.school.name if prof.school else "") + + # Filter by school + result = client.search_professors("Smith", school_id="1530") ``` -#### Iterate professors for a school +#### List professors at a school ```python -from rmp_client import RMPClient +with RMPClient() as client: + result = client.list_professors_for_school(1466, page_size=20) + for prof in result.professors: + print(prof.name, prof.department) +``` -SCHOOL_ID = 1466 # Queen's University at Kingston, for example +#### Iterate all professors at a school +```python with RMPClient() as client: - for prof in client.iter_professors_for_school(SCHOOL_ID, page_size=50): + for prof in client.iter_professors_for_school(1466, page_size=50): print(prof.name, prof.num_ratings) ``` -#### Fetch professor details and ratings +#### Get a professor by ID + +```python +with RMPClient() as client: + prof = client.get_professor("2823076") + print(prof.name, prof.department, prof.overall_rating) + print(f"Difficulty: {prof.level_of_difficulty}") + print(f"Would take again: {prof.percent_take_again}%") +``` + +#### Fetch professor ratings (paginated, cached) + +```python +with RMPClient() as client: + page = client.get_professor_ratings_page("2823076", page_size=10) + print(f"Professor: {page.professor.name}") + for rating in page.ratings: + print(rating.date, rating.quality, rating.comment[:50]) + + # Load more (served from cache, no extra network request) + if page.has_next_page: + page2 = client.get_professor_ratings_page("2823076", cursor=page.next_cursor) +``` + +#### Iterate all professor ratings ```python from datetime import date from rmp_client import RMPClient with RMPClient() as client: - professor = client.get_professor("PROFESSOR_ID") - - for rating in client.iter_professor_ratings(professor.id, since=date(2024, 1, 1)): + for rating in client.iter_professor_ratings("2823076", since=date(2024, 1, 1)): print(rating.date, rating.quality, rating.comment) ``` +#### Fetch school ratings (paginated, cached) + +```python +with RMPClient() as client: + page = client.get_school_ratings_page("1466", page_size=10) + for rating in page.ratings: + print(rating.date, rating.overall, rating.category_ratings) +``` + +#### Iterate all school ratings + +```python +with RMPClient() as client: + for rating in client.iter_school_ratings("1466"): + print(rating.date, rating.overall, rating.comment[:50]) +``` + +#### Send a raw GraphQL query + +```python +with RMPClient() as client: + data = client.raw_query({ + "query": "query { viewer { id } }", + "variables": {}, + }) + print(data) +``` diff --git a/src/rmp_client/__init__.py b/src/rmp_client/__init__.py index ca20efb..3174647 100644 --- a/src/rmp_client/__init__.py +++ b/src/rmp_client/__init__.py @@ -1,6 +1,17 @@ +"""RateMyProfessors API client -- public entry point.""" + from .client import RMPClient from .config import RMPClientConfig -from . import errors as _errors +from .errors import ( + ConfigurationError, + HttpError, + ParsingError, + RateLimitError, + RetryError, + RMPAPIError, + RMPError, +) +from .rate_limit import TokenBucket from .extras import ( SentimentResult, analyze_sentiment, @@ -10,12 +21,17 @@ clean_course_label, ) -RMPError = _errors.RMPError - __all__ = [ "RMPClient", "RMPClientConfig", "RMPError", + "ConfigurationError", + "HttpError", + "ParsingError", + "RateLimitError", + "RetryError", + "RMPAPIError", + "TokenBucket", "SentimentResult", "analyze_sentiment", "is_valid_comment", @@ -23,4 +39,3 @@ "build_course_mapping", "clean_course_label", ] - diff --git a/src/rmp_client/client.py b/src/rmp_client/client.py index acc4b46..ad7c1f0 100644 --- a/src/rmp_client/client.py +++ b/src/rmp_client/client.py @@ -1,10 +1,16 @@ +"""High-level client for the RateMyProfessors (RMP) GraphQL API. + +All data is fetched via POST to https://www.ratemyprofessors.com/graphql. +Rate limiting, retries, and timeouts are handled by :class:`HttpClient`. + +Call :meth:`RMPClient.close` when done to release resources and clear caches. +""" + from __future__ import annotations import base64 -import json from datetime import date -from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional -from urllib.parse import quote +from typing import Any, Dict, Iterator, List, Mapping, Optional, Tuple from .config import RMPClientConfig from .errors import ParsingError @@ -15,212 +21,87 @@ ProfessorRatingsPage, ProfessorSearchResult, Rating, - RatingDistributionBucket, School, SchoolRating, SchoolRatingsPage, SchoolSearchResult, ) -from .relay_store import ( - extract_relay_store, - get_all_rating_records, - get_all_school_rating_records, - get_professor_node, - get_professor_ratings_connection_page_info, - get_ratings_from_store, - get_school_node, - get_school_ratings_connection_page_info, - get_school_ratings_from_store, - get_school_search_connection, - get_school_search_page_info, - get_school_search_result_count, - get_teacher_search_connection, - get_teacher_search_page_info, - get_teacher_search_result_count, - _edges_to_school_records, - _edges_to_teacher_records, - _is_record_ref, - _resolve_ref, - _resolve_refs, +from .queries import ( + GET_SCHOOL_QUERY, + GET_TEACHER_QUERY, + RATINGS_LIST_QUERY, + SCHOOL_RATINGS_LIST_QUERY, + SCHOOL_SEARCH_RESULTS_QUERY, + TEACHER_SEARCH_RESULTS_QUERY, ) -# GraphQL query for teacher ratings with cursor-based pagination (RMP uses Relay). -# Node id must be base64("Teacher-{legacyId}"). -TEACHER_RATINGS_QUERY = """ -query TeacherRatings($id: ID!, $first: Int!, $after: String) { - node(id: $id) { - ... on Teacher { - id - legacyId - firstName - lastName - department - avgRating - avgDifficulty - numRatings - wouldTakeAgainPercent - school { - id - name - city - state - } - ratings(first: $first, after: $after) { - edges { - node { - comment - ratingTags - clarityRating - difficultyRating - date - grade - helpfulRating - thumbsUpTotal - thumbsDownTotal - class - attendanceMandatory - textbookUse - isForCredit - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -} -""" - def _teacher_node_id(professor_id: str) -> str: - """Relay global id for Teacher node: base64('Teacher-{legacyId}').""" - raw = f"Teacher-{professor_id}" - return base64.b64encode(raw.encode("utf-8")).decode("ascii") + """Relay global ID for a Teacher node: base64('Teacher-{legacyId}').""" + return base64.b64encode(f"Teacher-{professor_id}".encode()).decode("ascii") def _school_node_id(school_id: str) -> str: - """Relay global id for School node: base64('School-{legacyId}').""" - raw = f"School-{school_id}" - return base64.b64encode(raw.encode("utf-8")).decode("ascii") - - -# GraphQL query for school ratings with cursor-based pagination. -SCHOOL_RATINGS_QUERY = """ -query SchoolRatings($id: ID!, $first: Int!, $after: String) { - node(id: $id) { - ... on School { - id - legacyId - name - city - state - avgRatingRounded - numRatings - ratings(first: $first, after: $after) { - edges { - node { - comment - date - reputationRating - locationRating - opportunitiesRating - facilitiesRating - internetRating - foodRating - clubsRating - socialRating - happinessRating - safetyRating - thumbsUpTotal - thumbsDownTotal - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -} -""" + """Relay global ID for a School node: base64('School-{legacyId}').""" + return base64.b64encode(f"School-{school_id}".encode()).decode("ascii") def _format_location(record: Mapping[str, Any]) -> Optional[str]: - """Build the single location string for a record. - - Uses record['location'] if present; otherwise joins city, state, country - from the API into one string. - """ - loc = record.get("location") - if isinstance(loc, str) and loc.strip(): - return loc.strip() - parts = [ - record.get("city"), - record.get("state"), - record.get("country"), - ] + """Build a location string from city/state/country fields.""" + parts = [record.get("city"), record.get("state"), record.get("country")] joined = ", ".join(p for p in parts if isinstance(p, str) and p.strip()) return joined if joined else None -def _school_record_to_location_dict(record: Mapping[str, Any]) -> Dict[str, Any]: - """Build a minimal school dict with id, name, and location.""" - return { - "id": record.get("id") or record.get("__id"), - "name": record.get("name") or "", - "location": _format_location(record), - } +def _safe_float(value: Any) -> Optional[float]: + if value is None: + return None + try: + f = float(value) + return f if f == f else None # reject NaN + except (TypeError, ValueError): + return None -def _build_rating_distribution( - raw: Any, -) -> Optional[Dict[int, RatingDistributionBucket]]: - """Convert raw counts (dict 1-5 or r1..r5 -> count, or list) to dict with count + percentage.""" - if raw is None: - return None - counts: Dict[int, int] = {} - if isinstance(raw, dict): - # RMP uses r1, r2, r3, r4, r5 for rating distribution - if any(raw.get(f"r{i}") is not None for i in range(1, 6)): - for i in range(1, 6): - v = raw.get(f"r{i}") - counts[i] = int(v) if v is not None else 0 - else: - for k, v in raw.items(): - level = int(k) if isinstance(k, int) else (int(k) if isinstance(k, str) and k.isdigit() else None) - if level is not None and 1 <= level <= 5: - counts[level] = int(v) if v is not None else 0 - elif isinstance(raw, list) and len(raw) >= 5: - for i, v in enumerate(raw[:5], start=1): - counts[i] = int(v) if v is not None else 0 - if not counts: +def _safe_int(value: Any) -> Optional[int]: + if value is None: return None - total = sum(counts.values()) - if total <= 0: + try: + return int(value) + except (TypeError, ValueError): return None - return { - level: RatingDistributionBucket( - count=count, - percentage=round(100.0 * count / total, 2), - ) - for level, count in sorted(counts.items()) - } + + +def _parse_date(date_str: Any) -> date: + """Parse RMP date strings (e.g. '2026-03-03 21:20:35 +0000 UTC') to a date. + + Uses only the date part; invalid input yields today's date. + """ + if isinstance(date_str, str): + part = date_str.split(" ")[0] if " " in date_str else date_str + try: + return date.fromisoformat(part) + except ValueError: + pass + return date.today() class RMPClient: - """High-level client for RateMyProfessors. + """Main client for the RateMyProfessors GraphQL API. - This is intentionally small for now; we will extend as we learn more about - the underlying API shapes. + Use as a context manager or call :meth:`close` when finished. """ def __init__(self, config: Optional[RMPClientConfig] = None) -> None: self._config = config or RMPClientConfig() self._http_ctx = HttpClientContext(self._config) self._http: Optional[HttpClient] = None + self._professor_ratings_cache: Dict[ + str, Tuple[Professor, List[Rating]] + ] = {} + self._school_ratings_cache: Dict[ + str, Tuple[School, List[SchoolRating]] + ] = {} def __enter__(self) -> "RMPClient": self._http = self._http_ctx.__enter__() @@ -229,137 +110,134 @@ def __enter__(self) -> "RMPClient": def __exit__(self, *args: Any) -> None: self._http_ctx.__exit__(*args) self._http = None + self._professor_ratings_cache.clear() + self._school_ratings_cache.clear() @property def _client(self) -> HttpClient: if self._http is None: - # Lazily create a client if not used as context manager self._http = HttpClient(self._config) return self._http - # ---- Low-level escape hatch ------------------------------------------------- + def close(self) -> None: + """Close the HTTP client and clear all rating caches. Safe to call multiple times.""" + if self._http is not None: + self._http.close() + self._http = None + self._professor_ratings_cache.clear() + self._school_ratings_cache.clear() + + # ---- Low-level --------------------------------------------------------------- def raw_query(self, payload: Mapping[str, Any]) -> Dict[str, Any]: - """Send a raw JSON/GraphQL-style payload to the RMP backend.""" + """Send a raw JSON/GraphQL payload to the RMP endpoint.""" return self._client.post_json("", payload) - # ---- School search ---------------------------------------------------------- - - def _search_schools_page_url(self, query: str) -> str: - """URL for school search page: /search/schools?q=...""" - base = self._config.search_schools_page_url.rstrip("/") - return f"{base}?q={quote(query, safe='')}" - - def _fetch_relay_store_for_search_schools(self, query: str) -> Dict[str, Any]: - """GET school search page HTML and return parsed __RELAY_STORE__.""" - url = self._search_schools_page_url(query) - html = self._client.get_html(url) - try: - return extract_relay_store(html) - except (ValueError, json.JSONDecodeError) as exc: - raise ParsingError(f"Failed to extract __RELAY_STORE__ from school search page: {exc}") from exc + # ---- School search ----------------------------------------------------------- def search_schools( self, query: str, *, - page: int = 1, page_size: int = 20, + cursor: Optional[str] = None, ) -> SchoolSearchResult: - """Search schools by name. + """Search schools by name (SchoolSearchResultsPageQuery).""" + data = self.raw_query({ + "operationName": "SchoolSearchResultsPageQuery", + "query": SCHOOL_SEARCH_RESULTS_QUERY, + "variables": { + "query": {"text": query}, + "count": page_size, + "cursor": cursor or "", + }, + }) - Loads the search page HTML (/search/schools?q=...) and parses - __RELAY_STORE__ for the first page of results. total and has_next_page - come from the relay; page_size is the number of results on that page. - """ - store = self._fetch_relay_store_for_search_schools(query) - conn = get_school_search_connection(store) - if conn is None: + search = (data.get("data") or {}).get("search") + conn = search.get("schools") if isinstance(search, dict) else None + if not conn: return SchoolSearchResult( schools=[], total=None, - page=page, - page_size=page_size, + page_size=0, has_next_page=False, next_cursor=None, ) - school_records = _edges_to_school_records(store, conn.get("edges")) + + edges = conn.get("edges") or [] + page_info = conn.get("pageInfo") or {} schools: List[School] = [] - for rec in school_records: - node = self._relay_school_to_node(store, rec) - schools.append(self._parse_school_node(node)) - total = get_school_search_result_count(conn) - page_info = get_school_search_page_info(store, conn) - has_next = bool(page_info.get("hasNextPage", False)) if page_info else False - next_cursor = page_info.get("endCursor") if page_info else None + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if node: + schools.append(self._parse_school_node(node)) + return SchoolSearchResult( schools=schools, - total=total, - page=page, + total=_safe_int(conn.get("resultCount")), page_size=len(schools), - has_next_page=has_next, - next_cursor=next_cursor, + has_next_page=bool(page_info.get("hasNextPage")), + next_cursor=( + str(page_info["endCursor"]) + if page_info.get("endCursor") is not None + else None + ), ) - # ---- Professor search / listing -------------------------------------------- - - def _search_professors_page_url(self, query: str) -> str: - """URL for professor search page: /search/professors/?q=...""" - base = self._config.search_professors_page_url.rstrip("/") - return f"{base}?q={quote(query, safe='')}" - - def _fetch_relay_store_for_search_professors(self, query: str) -> Dict[str, Any]: - """GET professor search page HTML and return parsed __RELAY_STORE__.""" - url = self._search_professors_page_url(query) - html = self._client.get_html(url) - try: - return extract_relay_store(html) - except (ValueError, json.JSONDecodeError) as exc: - raise ParsingError(f"Failed to extract __RELAY_STORE__ from search page: {exc}") from exc + # ---- Professor search / listing ---------------------------------------------- def search_professors( self, query: str, *, school_id: Optional[str] = None, - page: int = 1, page_size: int = 20, + cursor: Optional[str] = None, ) -> ProfessorSearchResult: - """Search professors by name (and optional school filter). + """Search professors by name (TeacherSearchResultsPageQuery).""" + query_var: Dict[str, Any] = {"text": query} + if school_id is not None: + query_var["schoolID"] = school_id + + data = self.raw_query({ + "operationName": "TeacherSearchResultsPageQuery", + "query": TEACHER_SEARCH_RESULTS_QUERY, + "variables": { + "query": query_var, + "count": page_size, + "cursor": cursor or "", + }, + }) - Loads the search page HTML (/search/professors/?q=...) and parses - __RELAY_STORE__ for the first page of results. total and has_next_page - come from the relay; page_size is the number of results on that page. - For listing all professors at a school, use list_professors_for_school - or iter_professors_for_school. - """ - store = self._fetch_relay_store_for_search_professors(query) - conn = get_teacher_search_connection(store) - if conn is None: + search = (data.get("data") or {}).get("search") + conn = search.get("teachers") if isinstance(search, dict) else None + if not conn: return ProfessorSearchResult( professors=[], total=None, - page=page, - page_size=page_size, + page_size=0, has_next_page=False, next_cursor=None, ) - teacher_records = _edges_to_teacher_records(store, conn.get("edges")) + + edges = conn.get("edges") or [] + page_info = conn.get("pageInfo") or {} professors: List[Professor] = [] - for rec in teacher_records: - node = self._relay_professor_to_node(store, rec) - professors.append(self._parse_professor_node(node)) - total = get_teacher_search_result_count(conn) - page_info = get_teacher_search_page_info(store, conn) - has_next = bool(page_info.get("hasNextPage", False)) if page_info else False - next_cursor = page_info.get("endCursor") if page_info else None + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if node: + professors.append(self._parse_professor_node(node)) + return ProfessorSearchResult( professors=professors, - total=total, - page=page, + total=_safe_int(conn.get("resultCount")), page_size=len(professors), - has_next_page=has_next, - next_cursor=next_cursor, + has_next_page=bool(page_info.get("hasNextPage")), + next_cursor=( + str(page_info["endCursor"]) + if page_info.get("endCursor") is not None + else None + ), ) def list_professors_for_school( @@ -367,15 +245,15 @@ def list_professors_for_school( school_id: int, *, query: Optional[str] = None, - page: int = 1, page_size: int = 20, + cursor: Optional[str] = None, ) -> ProfessorSearchResult: - """Convenience wrapper to list professors for a given school.""" + """List professors at a school. Wrapper around :meth:`search_professors`.""" return self.search_professors( - query=query or "*", + query=query or "", school_id=str(school_id), - page=page, page_size=page_size, + cursor=cursor, ) def iter_professors_for_school( @@ -385,172 +263,42 @@ def iter_professors_for_school( query: Optional[str] = None, page_size: int = 20, ) -> Iterator[Professor]: - """Iterate all professors for a school, handling pagination for you.""" - page = 1 + """Iterate all professors at a school, handling cursor pagination.""" + cursor: Optional[str] = None while True: result = self.list_professors_for_school( school_id=school_id, query=query, - page=page, page_size=page_size, + cursor=cursor, ) - if not result.professors: - break for prof in result.professors: yield prof - if not result.has_next_page: + if ( + not result.has_next_page + or not result.next_cursor + or not result.professors + ): break - page += 1 + cursor = result.next_cursor - # ---- Professor details + ratings ------------------------------------------- - - def _professor_page_url(self, professor_id: str) -> str: - base = self._config.professors_page_url.rstrip("/") - return f"{base}/{professor_id}" - - def _fetch_relay_store_for_professor(self, professor_id: str) -> Dict[str, Any]: - """GET professor page HTML and return parsed __RELAY_STORE__.""" - url = self._professor_page_url(professor_id) - html = self._client.get_html(url) - try: - return extract_relay_store(html) - except (ValueError, json.JSONDecodeError) as exc: - raise ParsingError(f"Failed to extract __RELAY_STORE__ from professor page: {exc}") from exc - - def _relay_professor_to_node(self, store: Dict[str, Any], record: Mapping[str, Any]) -> Dict[str, Any]: - """Convert a Relay Professor/Teacher record to the shape _parse_professor_node expects.""" - # RMP professor page uses avgRating, avgDifficulty, wouldTakeAgainPercent - node: Dict[str, Any] = { - "id": record.get("id") or record.get("__id") or record.get("legacyId"), - "name": record.get("name") or " ".join(filter(None, [record.get("firstName"), record.get("lastName")])), - "department": record.get("department"), - "url": record.get("url"), - "overallRating": record.get("avgRating") or record.get("overallRating"), - "numRatings": record.get("numRatings"), - "percentTakeAgain": record.get("wouldTakeAgainPercent") or record.get("percentTakeAgain"), - "levelOfDifficulty": record.get("avgDifficulty") or record.get("levelOfDifficulty"), - "tags": record.get("tags") or [], - } - school_val = record.get("school") - if school_val is not None and isinstance(school_val, dict) and "__ref" in school_val: - school_record = _resolve_ref(store, school_val) - if school_record and isinstance(school_record, dict): - node["school"] = _school_record_to_location_dict(school_record) - elif isinstance(school_val, dict): - node["school"] = _school_record_to_location_dict(school_val) - # Rating distribution: RMP stores as __ref to record with r1..r5 - dist_raw = record.get("ratingsDistribution") or record.get("ratingDistribution") - if _is_record_ref(dist_raw): - dist_record = _resolve_ref(store, dist_raw) - node["rating_distribution"] = dist_record if isinstance(dist_record, dict) else dist_raw - else: - node["rating_distribution"] = dist_raw - # Tags: RMP uses teacherRatingTags as {"__refs": ["id1", ...]}; each ref is TeacherRatingTags with tagName - tags_refs = record.get("teacherRatingTags") - if isinstance(tags_refs, dict) and "__refs" in tags_refs: - ref_ids = tags_refs.get("__refs") or [] - tag_records = _resolve_refs(store, ref_ids) - node["tags"] = [str(r.get("tagName", "")) for r in tag_records if r.get("tagName")] - elif not node["tags"]: - node["tags"] = record.get("tags") or [] - return node - - def _relay_rating_to_node(self, record: Mapping[str, Any]) -> Dict[str, Any]: - """Convert a Relay Rating record to the shape _parse_rating_node expects.""" - # RMP uses clarityRating (quality), difficultyRating, class (course), helpfulRating, thumbsUpTotal/thumbsDownTotal - out: Dict[str, Any] = { - "date": record.get("date"), - "comment": record.get("comment") or "", - "quality": record.get("clarityRating") or record.get("quality"), - "difficulty": record.get("difficultyRating") or record.get("difficulty"), - "tags": record.get("tags") or [], - "course": record.get("class") or record.get("course") or record.get("courseName"), - } - # ratingTags is a single string "Tag1--Tag2--Tag3" - if isinstance(record.get("ratingTags"), str): - out["tags"] = [t.strip() for t in record["ratingTags"].split("--") if t.strip()] - # Details: RMP uses attendanceMandatory, textbookUse, isForCredit, grade - out["for_credit"] = record.get("isForCredit") if "isForCredit" in record else record.get("for_credit") or record.get("forCredit") - out["attendance"] = record.get("attendanceMandatory") or record.get("attendance") - out["grade"] = record.get("grade") - out["textbook"] = record.get("textbookUse") if "textbookUse" in record else record.get("textbook") - out["helpful"] = record.get("helpfulRating") or record.get("helpful") - out["thumbsUp"] = record.get("thumbsUpTotal") or record.get("thumbsUp") - out["thumbsDown"] = record.get("thumbsDownTotal") or record.get("thumbsDown") - return out + # ---- Professor details + ratings --------------------------------------------- def get_professor(self, professor_id: str) -> Professor: - """Fetch detailed information about a single professor. - - Data is loaded from the professor page HTML (server-side rendered); - no separate API call is made. - """ - store = self._fetch_relay_store_for_professor(professor_id) - record = get_professor_node(store, professor_id) - if record is None: - raise ParsingError(f"Professor record not found in __RELAY_STORE__ for id={professor_id!r}") - node = self._relay_professor_to_node(store, record) - return self._parse_professor_node(node) - - def _fetch_professor_ratings_via_graphql( - self, - professor_id: str, - *, - after: Optional[str] = None, - first: int = 20, - ) -> ProfessorRatingsPage: - """Fetch a page of professor ratings from the GraphQL API (for cursor-based next pages).""" + """Fetch a single professor by legacy numeric ID (GetTeacherQuery).""" node_id = _teacher_node_id(professor_id) - variables: Dict[str, Any] = {"id": node_id, "first": first} - if after is not None: - variables["after"] = after - payload: Dict[str, Any] = { - "query": TEACHER_RATINGS_QUERY, - "variables": variables, - } - data = self.raw_query(payload) + data = self.raw_query({ + "operationName": "GetTeacherQuery", + "query": GET_TEACHER_QUERY, + "variables": {"id": node_id}, + }) + node = (data.get("data") or {}).get("node") if not node: - raise ParsingError("GraphQL response missing data.node (teacher not found or invalid id)") - # Build Professor from Teacher fragment - school_obj = node.get("school") - school: Optional[School] = None - if isinstance(school_obj, dict): - loc = _format_location(school_obj) - school = School( - id=str(school_obj.get("id") or ""), - name=str(school_obj.get("name") or ""), - location=loc, + raise ParsingError( + f"Teacher not found in GraphQL response for id={professor_id}" ) - name = " ".join(filter(None, [node.get("firstName"), node.get("lastName")])).strip() - professor = Professor( - id=str(node.get("legacyId") or node.get("id") or professor_id), - name=name or "Unknown", - department=node.get("department"), - school=school, - overall_rating=node.get("avgRating"), - num_ratings=node.get("numRatings"), - percent_take_again=node.get("wouldTakeAgainPercent"), - level_of_difficulty=node.get("avgDifficulty"), - ) - ratings_conn = node.get("ratings") or {} - edges = ratings_conn.get("edges") or [] - page_info = ratings_conn.get("pageInfo") or {} - ratings_models: List[Rating] = [] - for edge in edges: - r = edge.get("node") if isinstance(edge, dict) else None - if not isinstance(r, dict): - continue - norm = self._relay_rating_to_node(r) - ratings_models.append(self._parse_rating_node(norm)) - has_next = bool(page_info.get("hasNextPage", False)) - next_cursor = page_info.get("endCursor") if page_info else None - return ProfessorRatingsPage( - professor=professor, - ratings=ratings_models, - has_next_page=has_next, - next_cursor=next_cursor, - ) + return self._parse_professor_node(node) def get_professor_ratings_page( self, @@ -558,57 +306,66 @@ def get_professor_ratings_page( *, cursor: Optional[str] = None, page_size: int = 20, + course_filter: Optional[str] = None, ) -> ProfessorRatingsPage: - """Fetch a single page of ratings/comments for a professor. + """Fetch one page of ratings for a professor. - First page is loaded from the professor page HTML. Subsequent pages are - fetched via the GraphQL API using the returned next_cursor, so you can - iterate all ratings with iter_professor_ratings or by calling this - repeatedly with cursor=page.next_cursor. + On the first call all ratings are pre-fetched via GraphQL and cached + in memory, so subsequent "Load More" calls with a cursor are served + instantly with no extra network requests. """ - # Relay cursor (from pageInfo.endCursor): use GraphQL for next page - if cursor is not None and not cursor.isdigit(): - return self._fetch_professor_ratings_via_graphql( - professor_id, after=cursor, first=page_size + # Serve from cache when cursor is a numeric offset + if cursor is not None: + cached = self._professor_ratings_cache.get(professor_id) + if cached: + professor, all_ratings = cached + start = max(0, int(cursor)) + page_slice = all_ratings[start : start + page_size] + has_next = start + page_size < len(all_ratings) + return ProfessorRatingsPage( + professor=professor, + ratings=page_slice, + has_next_page=has_next, + next_cursor=str(start + page_size) if has_next else None, + ) + + # Repeated first-page call: serve from cache + existing = self._professor_ratings_cache.get(professor_id) + if existing is not None and cursor is None: + professor, all_ratings = existing + page_slice = all_ratings[:page_size] + has_next = len(all_ratings) > page_size + return ProfessorRatingsPage( + professor=professor, + ratings=page_slice, + has_next_page=has_next, + next_cursor=str(page_size) if has_next else None, ) - # First page or legacy numeric offset: from HTML - store = self._fetch_relay_store_for_professor(professor_id) - record = get_professor_node(store, professor_id) - if record is None: - raise ParsingError(f"Professor record not found in __RELAY_STORE__ for id={professor_id!r}") - - professor = self._parse_professor_node(self._relay_professor_to_node(store, record)) - rating_records = get_ratings_from_store(store, record) - if not rating_records: - rating_records = get_all_rating_records(store) - - ratings_models: List[Rating] = [] - for r in rating_records: - node = self._relay_rating_to_node(r) - ratings_models.append(self._parse_rating_node(node)) - - page_info = get_professor_ratings_connection_page_info(store, record) - if cursor is not None and cursor.isdigit(): - # Legacy: in-memory offset pagination over the single HTML batch - start = max(0, int(cursor)) - page_slice = ratings_models[start : start + page_size] - has_next = (start + page_size) < len(ratings_models) - next_cursor = str(start + page_size) if has_next else None - else: - # First page: use Relay pageInfo when available so caller can fetch more via GraphQL; else in-memory - page_slice = ratings_models[:page_size] - if page_info and page_info.get("hasNextPage") and page_info.get("endCursor"): - has_next = True - next_cursor = page_info.get("endCursor") - else: - has_next = len(ratings_models) > page_size - next_cursor = str(page_size) if has_next else None + # First load: fetch ALL ratings via GraphQL and cache + first = self._fetch_professor_ratings_page( + professor_id, first=100, course_filter=course_filter + ) + all_ratings = list(first.ratings) + professor = first.professor + after = first.next_cursor if first.has_next_page else None + + while after is not None: + nxt = self._fetch_professor_ratings_page( + professor_id, after=after, first=100, course_filter=course_filter + ) + all_ratings.extend(nxt.ratings) + after = nxt.next_cursor if nxt.has_next_page else None + + self._professor_ratings_cache[professor_id] = (professor, all_ratings) + + page_slice = all_ratings[:page_size] + has_next = len(all_ratings) > page_size return ProfessorRatingsPage( professor=professor, ratings=page_slice, has_next_page=has_next, - next_cursor=next_cursor, + next_cursor=str(page_size) if has_next else None, ) def iter_professor_ratings( @@ -617,14 +374,16 @@ def iter_professor_ratings( *, page_size: int = 20, since: Optional[date] = None, + course_filter: Optional[str] = None, ) -> Iterator[Rating]: - """Iterate ratings for a professor, optionally stopping once `since` is reached.""" + """Iterate all ratings for a professor. Optional ``since`` stops early.""" cursor: Optional[str] = None while True: page = self.get_professor_ratings_page( - professor_id=professor_id, + professor_id, cursor=cursor, page_size=page_size, + course_filter=course_filter, ) for rating in page.ratings: if since is not None and rating.date <= since: @@ -634,231 +393,31 @@ def iter_professor_ratings( return cursor = page.next_cursor - # ---- School details + ratings ----------------------------------------------- - - def _school_page_url(self, school_id: str) -> str: - base = self._config.schools_page_url.rstrip("/") - return f"{base}/{school_id}" - - def _compare_school_page_url(self, school_id: str) -> str: - base = self._config.compare_schools_page_url.rstrip("/") - return f"{base}/{school_id}" - - def _compare_schools_page_url(self, school_id_1: str, school_id_2: str) -> str: - """URL for compare page: /compare/schools/id1/id2.""" - base = self._config.compare_schools_page_url.rstrip("/") - return f"{base}/{school_id_1}/{school_id_2}" - - def _fetch_relay_store_for_school(self, school_id: str, *, use_compare_url: bool = False) -> Dict[str, Any]: - """GET school page (or compare page) HTML and return parsed __RELAY_STORE__.""" - url = self._compare_school_page_url(school_id) if use_compare_url else self._school_page_url(school_id) - html = self._client.get_html(url) - try: - return extract_relay_store(html) - except (ValueError, json.JSONDecodeError) as exc: - raise ParsingError(f"Failed to extract __RELAY_STORE__ from school page: {exc}") from exc - - def _fetch_relay_store_for_compare_schools( - self, school_id_1: str, school_id_2: str - ) -> Dict[str, Any]: - """GET compare schools page HTML and return parsed __RELAY_STORE__.""" - url = self._compare_schools_page_url(school_id_1, school_id_2) - html = self._client.get_html(url) - try: - return extract_relay_store(html) - except (ValueError, json.JSONDecodeError) as exc: - raise ParsingError(f"Failed to extract __RELAY_STORE__ from compare schools page: {exc}") from exc - - def _relay_school_to_node(self, store: Dict[str, Any], record: Mapping[str, Any]) -> Dict[str, Any]: - """Convert a Relay School record to the shape _parse_school_node expects. - - RMP school page: overall from avgRatingRounded; category bars from summary __ref (SchoolSummary). - """ - node: Dict[str, Any] = { - "id": record.get("id") or record.get("__id") or record.get("legacyId"), - "name": record.get("name") or "", - "location": _format_location(record), - "overall_quality": record.get("avgRatingRounded") or record.get("overallQuality") or record.get("overall"), - "num_ratings": record.get("numRatings"), - "reputation": record.get("reputation"), - "safety": record.get("safety"), - "happiness": record.get("happiness"), - "facilities": record.get("facilities"), - "social": record.get("social"), - "location_rating": record.get("location"), - "clubs": record.get("clubs"), - "opportunities": record.get("opportunities"), - "internet": record.get("internet"), - "food": record.get("food"), - } - # RMP stores category bars in summary __ref (SchoolSummary): schoolReputation, schoolSafety, etc. - summary_ref = record.get("summary") - if _is_record_ref(summary_ref): - summary_record = _resolve_ref(store, summary_ref) - if isinstance(summary_record, dict): - node["reputation"] = node["reputation"] or _safe_float(summary_record.get("schoolReputation")) - node["safety"] = node["safety"] or _safe_float(summary_record.get("schoolSafety")) - node["happiness"] = node["happiness"] or _safe_float(summary_record.get("schoolSatisfaction")) - node["facilities"] = node["facilities"] or _safe_float(summary_record.get("campusCondition")) - node["social"] = node["social"] or _safe_float(summary_record.get("socialActivities")) - node["location_rating"] = node["location_rating"] or _safe_float(summary_record.get("campusLocation")) - node["clubs"] = node["clubs"] or _safe_float(summary_record.get("clubAndEventActivities")) - node["opportunities"] = node["opportunities"] or _safe_float(summary_record.get("careerOpportunities")) - node["internet"] = node["internet"] or _safe_float(summary_record.get("internetSpeed")) - node["food"] = node["food"] or _safe_float(summary_record.get("foodQuality")) - return node - - def _parse_school_node(self, node: Mapping[str, Any]) -> School: - """Build School from a dict (relay or nested).""" - return School( - id=str(node.get("id") or ""), - name=node.get("name") or "", - location=node.get("location") if isinstance(node.get("location"), str) else _format_location(node), - overall_quality=_safe_float(node.get("overall_quality") or node.get("overallQuality") or node.get("overall")), - num_ratings=_safe_int(node.get("num_ratings") or node.get("numRatings")), - reputation=_safe_float(node.get("reputation")), - safety=_safe_float(node.get("safety")), - happiness=_safe_float(node.get("happiness")), - facilities=_safe_float(node.get("facilities")), - social=_safe_float(node.get("social")), - location_rating=_safe_float(node.get("location_rating")) or (_safe_float(node.get("location")) if isinstance(node.get("location"), (int, float)) else None), - clubs=_safe_float(node.get("clubs")), - opportunities=_safe_float(node.get("opportunities")), - internet=_safe_float(node.get("internet")), - food=_safe_float(node.get("food")), - ) - - def _parse_school_rating_node(self, record: Mapping[str, Any]) -> SchoolRating: - # RMP sends "2026-03-05 16:00:35 +0000 UTC"; use date part only. - date_str = record.get("date") - if isinstance(date_str, str) and " " in date_str: - date_str = date_str.split(" ")[0] - try: - rating_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date.today() - except ValueError: - rating_date = date.today() - # RMP SchoolRating: reputationRating, locationRating, opportunitiesRating, facilitiesRating, - # internetRating, foodRating, clubsRating, socialRating, happinessRating, safetyRating - rmp_to_category = ( - ("reputationRating", "reputation"), - ("locationRating", "location"), - ("opportunitiesRating", "opportunities"), - ("facilitiesRating", "facilities"), - ("internetRating", "internet"), - ("foodRating", "food"), - ("clubsRating", "clubs"), - ("socialRating", "social"), - ("happinessRating", "happiness"), - ("safetyRating", "safety"), - ) - category_ratings: Optional[Dict[str, float]] = None - for rmp_key, cat_key in rmp_to_category: - val = record.get(rmp_key) - if val is not None: - f = _safe_float(val) - if f is not None: - if category_ratings is None: - category_ratings = {} - category_ratings[cat_key] = f - if category_ratings is None: - for key in ("reputation", "location", "opportunities", "facilities", "internet", "food", "clubs", "social", "happiness", "safety"): - val = record.get(key) or record.get(key.replace("_", "")) - if val is not None: - f = _safe_float(val) - if f is not None: - if category_ratings is None: - category_ratings = {} - category_ratings[key] = f - overall = _safe_float( - record.get("overall") or record.get("overallQuality") or record.get("quality") - ) - if overall is None and category_ratings: - overall = sum(category_ratings.values()) / len(category_ratings) - thumbs_up = _safe_int(record.get("thumbsUpTotal") or record.get("thumbsUp") or record.get("thumbs_up")) - thumbs_down = _safe_int(record.get("thumbsDownTotal") or record.get("thumbsDown") or record.get("thumbs_down")) - helpful = _safe_int(record.get("helpful")) - return SchoolRating( - date=rating_date, - comment=str(record.get("comment") or ""), - overall=overall, - category_ratings=category_ratings, - helpful=helpful, - thumbs_up=thumbs_up, - thumbs_down=thumbs_down, - ) - - def get_school(self, school_id: str, *, use_compare_page: bool = False) -> School: - """Fetch detailed information about a single school. - - Data is loaded from the school page HTML (or compare page if use_compare_page=True). - """ - store = self._fetch_relay_store_for_school(school_id, use_compare_url=use_compare_page) - record = get_school_node(store, school_id) - if record is None: - raise ParsingError(f"School record not found in __RELAY_STORE__ for id={school_id!r}") - node = self._relay_school_to_node(store, record) - return self._parse_school_node(node) - - def get_compare_schools(self, school_id_1: str, school_id_2: str) -> CompareSchoolsResult: - """Fetch and compare two schools from the compare page (/compare/schools/id1/id2). - - Data is loaded from the compare page HTML; both schools include summary - category ratings (reputation, safety, facilities, etc.) when present. - """ - store = self._fetch_relay_store_for_compare_schools(school_id_1, school_id_2) - record_1 = get_school_node(store, school_id_1) - record_2 = get_school_node(store, school_id_2) - if record_1 is None: - raise ParsingError(f"School record not found in __RELAY_STORE__ for id={school_id_1!r}") - if record_2 is None: - raise ParsingError(f"School record not found in __RELAY_STORE__ for id={school_id_2!r}") - node_1 = self._relay_school_to_node(store, record_1) - node_2 = self._relay_school_to_node(store, record_2) - return CompareSchoolsResult( - school_1=self._parse_school_node(node_1), - school_2=self._parse_school_node(node_2), - ) + # ---- School details + ratings ------------------------------------------------ - def _fetch_school_ratings_via_graphql( - self, - school_id: str, - *, - after: Optional[str] = None, - first: int = 20, - ) -> SchoolRatingsPage: - """Fetch a page of school ratings from the GraphQL API (for cursor-based next pages).""" + def get_school(self, school_id: str) -> School: + """Fetch a single school by legacy numeric ID (GetSchoolQuery).""" node_id = _school_node_id(school_id) - variables: Dict[str, Any] = {"id": node_id, "first": first} - if after is not None: - variables["after"] = after - payload = {"query": SCHOOL_RATINGS_QUERY, "variables": variables} - data = self.raw_query(payload) + data = self.raw_query({ + "operationName": "GetSchoolQuery", + "query": GET_SCHOOL_QUERY, + "variables": {"id": node_id}, + }) + node = (data.get("data") or {}).get("node") if not node: - raise ParsingError("GraphQL response missing data.node (school not found or invalid id)") - school = self._parse_school_node({ - "id": node.get("legacyId") or node.get("id"), - "name": node.get("name"), - "location": _format_location(node), - "overall_quality": node.get("avgRatingRounded"), - "num_ratings": node.get("numRatings"), - }) - ratings_conn = node.get("ratings") or {} - edges = ratings_conn.get("edges") or [] - page_info = ratings_conn.get("pageInfo") or {} - ratings_models: List[SchoolRating] = [] - for edge in edges: - r = edge.get("node") if isinstance(edge, dict) else None - if isinstance(r, dict): - ratings_models.append(self._parse_school_rating_node(r)) - has_next = bool(page_info.get("hasNextPage", False)) - next_cursor = page_info.get("endCursor") if page_info else None - return SchoolRatingsPage( - school=school, - ratings=ratings_models, - has_next_page=has_next, - next_cursor=next_cursor, - ) + raise ParsingError( + f"School not found in GraphQL response for id={school_id}" + ) + return self._parse_school_node(node) + + def get_compare_schools( + self, school_id_1: str, school_id_2: str + ) -> CompareSchoolsResult: + """Fetch two schools and return them as a pair.""" + school_1 = self.get_school(school_id_1) + school_2 = self.get_school(school_id_2) + return CompareSchoolsResult(school_1=school_1, school_2=school_2) def get_school_ratings_page( self, @@ -867,50 +426,52 @@ def get_school_ratings_page( cursor: Optional[str] = None, page_size: int = 20, ) -> SchoolRatingsPage: - """Fetch a single page of ratings for a school. - - First page is from the school page HTML. Subsequent pages are fetched - via the GraphQL API using the returned next_cursor. Use iter_school_ratings - to iterate all ratings. - """ - if cursor is not None and not cursor.isdigit(): - return self._fetch_school_ratings_via_graphql( - school_id, after=cursor, first=page_size + """Fetch one page of school ratings. Same caching pattern as professor ratings.""" + if cursor is not None: + cached = self._school_ratings_cache.get(school_id) + if cached: + school, all_ratings = cached + start = max(0, int(cursor)) + page_slice = all_ratings[start : start + page_size] + has_next = start + page_size < len(all_ratings) + return SchoolRatingsPage( + school=school, + ratings=page_slice, + has_next_page=has_next, + next_cursor=str(start + page_size) if has_next else None, + ) + + existing = self._school_ratings_cache.get(school_id) + if existing is not None and cursor is None: + school, all_ratings = existing + page_slice = all_ratings[:page_size] + has_next = len(all_ratings) > page_size + return SchoolRatingsPage( + school=school, + ratings=page_slice, + has_next_page=has_next, + next_cursor=str(page_size) if has_next else None, ) - store = self._fetch_relay_store_for_school(school_id) - record = get_school_node(store, school_id) - if record is None: - raise ParsingError(f"School record not found in __RELAY_STORE__ for id={school_id!r}") - - school = self._parse_school_node(self._relay_school_to_node(store, record)) - rating_records = get_school_ratings_from_store(store, record) - if not rating_records: - rating_records = get_all_school_rating_records(store) - - ratings_models: List[SchoolRating] = [] - for r in rating_records: - ratings_models.append(self._parse_school_rating_node(r)) - - page_info = get_school_ratings_connection_page_info(store, record) - if cursor is not None and cursor.isdigit(): - start = max(0, int(cursor)) - page_slice = ratings_models[start : start + page_size] - has_next = (start + page_size) < len(ratings_models) - next_cursor = str(start + page_size) if has_next else None - else: - page_slice = ratings_models[:page_size] - if page_info and page_info.get("hasNextPage") and page_info.get("endCursor"): - has_next = True - next_cursor = page_info.get("endCursor") - else: - has_next = len(ratings_models) > page_size - next_cursor = str(page_size) if has_next else None + first = self._fetch_school_ratings_page(school_id, first=100) + all_ratings = list(first.ratings) + school = first.school + after = first.next_cursor if first.has_next_page else None + + while after is not None: + nxt = self._fetch_school_ratings_page(school_id, after=after, first=100) + all_ratings.extend(nxt.ratings) + after = nxt.next_cursor if nxt.has_next_page else None + + self._school_ratings_cache[school_id] = (school, all_ratings) + + page_slice = all_ratings[:page_size] + has_next = len(all_ratings) > page_size return SchoolRatingsPage( school=school, ratings=page_slice, has_next_page=has_next, - next_cursor=next_cursor, + next_cursor=str(page_size) if has_next else None, ) def iter_school_ratings( @@ -920,13 +481,11 @@ def iter_school_ratings( page_size: int = 20, since: Optional[date] = None, ) -> Iterator[SchoolRating]: - """Iterate ratings for a school, optionally stopping once `since` is reached.""" + """Iterate all ratings for a school. Optional ``since`` stops early.""" cursor: Optional[str] = None while True: page = self.get_school_ratings_page( - school_id=school_id, - cursor=cursor, - page_size=page_size, + school_id, cursor=cursor, page_size=page_size ) for rating in page.ratings: if since is not None and rating.date <= since: @@ -936,88 +495,273 @@ def iter_school_ratings( return cursor = page.next_cursor - # ---- Internal helpers ------------------------------------------------------ + # ---- Private GraphQL page fetchers ------------------------------------------- - def _parse_professor_edge(self, edge: Mapping[str, Any]) -> Professor: - node = edge.get("node", {}) - return self._parse_professor_node(node) + def _fetch_professor_ratings_page( + self, + professor_id: str, + *, + after: Optional[str] = None, + first: int = 20, + course_filter: Optional[str] = None, + ) -> ProfessorRatingsPage: + node_id = _teacher_node_id(professor_id) + variables: Dict[str, Any] = { + "count": first, + "id": node_id, + "courseFilter": course_filter, + } + if after is not None: + variables["cursor"] = after - def _parse_professor_node(self, node: Mapping[str, Any]) -> Professor: - school_info = node.get("school") + data = self.raw_query({ + "operationName": "RatingsListQuery", + "query": RATINGS_LIST_QUERY, + "variables": variables, + }) + + node = (data.get("data") or {}).get("node") + if not node: + raise ParsingError( + "GraphQL response missing data.node (teacher not found or invalid id)" + ) + + school_obj = node.get("school") school: Optional[School] = None - if isinstance(school_info, Mapping): - school = School( - id=str(school_info.get("id") or ""), - name=school_info.get("name") or "", - location=school_info.get("location") if isinstance(school_info.get("location"), str) else _format_location(school_info), + if isinstance(school_obj, dict): + school = self._parse_school_node(school_obj) + + name = " ".join( + filter(None, [node.get("firstName"), node.get("lastName")]) + ).strip() + + professor = Professor( + id=str(node.get("legacyId") or node.get("id") or professor_id), + name=name or "Unknown", + department=node.get("department"), + school=school, + overall_rating=_safe_float(node.get("avgRating")), + num_ratings=_safe_int(node.get("numRatings")), + percent_take_again=_safe_float(node.get("wouldTakeAgainPercent")), + level_of_difficulty=_safe_float(node.get("avgDifficulty")), + ) + + ratings_conn = node.get("ratings") or {} + edges = ratings_conn.get("edges") or [] + page_info = ratings_conn.get("pageInfo") or {} + ratings: List[Rating] = [] + for edge in edges: + r = edge.get("node") if isinstance(edge, dict) else None + if isinstance(r, dict): + ratings.append(self._parse_rating_node(r)) + + return ProfessorRatingsPage( + professor=professor, + ratings=ratings, + has_next_page=bool(page_info.get("hasNextPage")), + next_cursor=( + str(page_info["endCursor"]) + if page_info.get("endCursor") is not None + else None + ), + ) + + def _fetch_school_ratings_page( + self, + school_id: str, + *, + after: Optional[str] = None, + first: int = 20, + ) -> SchoolRatingsPage: + node_id = _school_node_id(school_id) + variables: Dict[str, Any] = {"count": first, "id": node_id} + if after is not None: + variables["cursor"] = after + + data = self.raw_query({ + "operationName": "SchoolRatingsListQuery", + "query": SCHOOL_RATINGS_LIST_QUERY, + "variables": variables, + }) + + node = (data.get("data") or {}).get("node") + if not node: + raise ParsingError( + "GraphQL response missing data.node (school not found or invalid id)" ) - rating_dist = _build_rating_distribution(node.get("rating_distribution")) + + school = self._parse_school_node({ + "id": node.get("legacyId") or node.get("id") or school_id, + "name": node.get("name"), + "city": node.get("city"), + "state": node.get("state"), + "country": node.get("country"), + }) + + ratings_conn = node.get("ratings") or {} + edges = ratings_conn.get("edges") or [] + page_info = ratings_conn.get("pageInfo") or {} + ratings: List[SchoolRating] = [] + for edge in edges: + r = edge.get("node") if isinstance(edge, dict) else None + if isinstance(r, dict): + ratings.append(self._parse_school_rating_node(r)) + + return SchoolRatingsPage( + school=school, + ratings=ratings, + has_next_page=bool(page_info.get("hasNextPage")), + next_cursor=( + str(page_info["endCursor"]) + if page_info.get("endCursor") is not None + else None + ), + ) + + # ---- Internal parsers -------------------------------------------------------- + + def _parse_professor_node(self, node: Mapping[str, Any]) -> Professor: + first_name = node.get("firstName") + last_name = node.get("lastName") + name = ( + node.get("name") + or " ".join(filter(None, [first_name, last_name])).strip() + or "Unknown" + ) + + school_obj = node.get("school") + school: Optional[School] = None + if isinstance(school_obj, dict): + school = self._parse_school_node(school_obj) return Professor( - id=str(node.get("id")), - name=node.get("name") or "", + id=str(node.get("legacyId") or node.get("id") or ""), + name=name, department=node.get("department"), school=school, url=node.get("url"), - overall_rating=_safe_float(node.get("overallRating")), + overall_rating=_safe_float( + node.get("avgRating") or node.get("overallRating") + ), num_ratings=_safe_int(node.get("numRatings")), - percent_take_again=_safe_float(node.get("percentTakeAgain")), - level_of_difficulty=_safe_float(node.get("levelOfDifficulty")), - tags=[str(t) for t in (node.get("tags") or [])], - rating_distribution=rating_dist, + percent_take_again=_safe_float( + node.get("wouldTakeAgainPercent") or node.get("percentTakeAgain") + ), + level_of_difficulty=_safe_float( + node.get("avgDifficulty") or node.get("levelOfDifficulty") + ), + tags=[], + rating_distribution=None, ) - def _parse_rating_node(self, node: Mapping[str, Any]) -> Rating: - # RMP sends "2026-03-03 21:20:35 +0000 UTC"; use date part only. - date_str = node.get("date") - if isinstance(date_str, str) and " " in date_str: - date_str = date_str.split(" ")[0] - try: - rating_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date.today() - except ValueError: - rating_date = date.today() + def _parse_rating_node(self, record: Mapping[str, Any]) -> Rating: + tags: List[str] = [] + rating_tags = record.get("ratingTags") + if isinstance(rating_tags, str): + tags = [t.strip() for t in rating_tags.split("--") if t.strip()] - # Build details dict (for_credit, attendance, grade, textbook, etc.) details: Optional[Dict[str, Any]] = None - key_to_snake = {"forCredit": "for_credit"} - for key in ("for_credit", "forCredit", "attendance", "grade", "textbook"): - val = node.get(key) + for api_key, detail_key in ( + ("isForCredit", "for_credit"), + ("attendanceMandatory", "attendance"), + ("grade", "grade"), + ("textbookUse", "textbook"), + ): + val = record.get(api_key) if val is not None: if details is None: details = {} - details[key_to_snake.get(key, key)] = val - helpful = _safe_int(node.get("helpful")) - thumbs_up = _safe_int(node.get("thumbsUp") or node.get("thumbs_up")) - thumbs_down = _safe_int(node.get("thumbsDown") or node.get("thumbs_down")) + details[detail_key] = val return Rating( - date=rating_date, - comment=str(node.get("comment") or ""), - quality=_safe_float(node.get("quality")), - difficulty=_safe_float(node.get("difficulty")), - tags=[str(t) for t in (node.get("tags") or [])], - course_raw=node.get("course") or None, + date=_parse_date(record.get("date")), + comment=str(record.get("comment") or ""), + quality=_safe_float( + record.get("clarityRating") or record.get("helpfulRating") + ), + difficulty=_safe_float(record.get("difficultyRating")), + tags=tags, + course_raw=record.get("class") or None, details=details, - helpful=helpful, - thumbs_up=thumbs_up, - thumbs_down=thumbs_down, + thumbs_up=_safe_int(record.get("thumbsUpTotal")), + thumbs_down=_safe_int(record.get("thumbsDownTotal")), ) + def _parse_school_node(self, node: Mapping[str, Any]) -> School: + summary = node.get("summary") if isinstance(node.get("summary"), dict) else None + return School( + id=str(node.get("legacyId") or node.get("id") or ""), + name=str(node.get("name") or ""), + location=_format_location(node), + overall_quality=_safe_float( + node.get("avgRatingRounded") or node.get("avgRating") + ), + num_ratings=_safe_int(node.get("numRatings")), + reputation=_safe_float( + (summary or {}).get("schoolReputation") or node.get("reputation") + ), + safety=_safe_float( + (summary or {}).get("schoolSafety") or node.get("safety") + ), + happiness=_safe_float( + (summary or {}).get("schoolSatisfaction") or node.get("happiness") + ), + facilities=_safe_float( + (summary or {}).get("campusCondition") or node.get("facilities") + ), + social=_safe_float( + (summary or {}).get("socialActivities") or node.get("social") + ), + location_rating=_safe_float( + (summary or {}).get("campusLocation") or node.get("location_rating") + ), + clubs=_safe_float( + (summary or {}).get("clubAndEventActivities") or node.get("clubs") + ), + opportunities=_safe_float( + (summary or {}).get("careerOpportunities") + or node.get("opportunities") + ), + internet=_safe_float( + (summary or {}).get("internetSpeed") or node.get("internet") + ), + food=_safe_float( + (summary or {}).get("foodQuality") or node.get("food") + ), + ) -def _safe_float(value: Any) -> Optional[float]: - try: - if value is None: - return None - return float(value) - except (TypeError, ValueError): - return None + def _parse_school_rating_node(self, record: Mapping[str, Any]) -> SchoolRating: + rmp_to_category = ( + ("reputationRating", "reputation"), + ("locationRating", "location"), + ("opportunitiesRating", "opportunities"), + ("facilitiesRating", "facilities"), + ("internetRating", "internet"), + ("foodRating", "food"), + ("clubsRating", "clubs"), + ("socialRating", "social"), + ("happinessRating", "happiness"), + ("safetyRating", "safety"), + ) + category_ratings: Optional[Dict[str, float]] = None + for rmp_key, cat_key in rmp_to_category: + f = _safe_float(record.get(rmp_key)) + if f is not None: + if category_ratings is None: + category_ratings = {} + category_ratings[cat_key] = f -def _safe_int(value: Any) -> Optional[int]: - try: - if value is None: - return None - return int(value) - except (TypeError, ValueError): - return None + overall: Optional[float] = None + if category_ratings: + vals = list(category_ratings.values()) + overall = sum(vals) / len(vals) + return SchoolRating( + date=_parse_date(record.get("date")), + comment=str(record.get("comment") or ""), + overall=overall, + category_ratings=category_ratings, + thumbs_up=_safe_int(record.get("thumbsUpTotal")), + thumbs_down=_safe_int(record.get("thumbsDownTotal")), + ) diff --git a/src/rmp_client/config.py b/src/rmp_client/config.py index a2febe2..0eb1bec 100644 --- a/src/rmp_client/config.py +++ b/src/rmp_client/config.py @@ -1,18 +1,22 @@ +"""Configuration for the RateMyProfessors API client. + +All client behavior (base URL, timeouts, retries, rate limiting) is driven +by :class:`RMPClientConfig`. Pass an instance to :class:`RMPClient`. +""" + from __future__ import annotations from dataclasses import dataclass, field from typing import Mapping DEFAULT_BASE_URL = "https://www.ratemyprofessors.com/graphql" -DEFAULT_PROFESSORS_PAGE_URL = "https://www.ratemyprofessors.com/professor/" -DEFAULT_SCHOOLS_PAGE_URL = "https://www.ratemyprofessors.com/school/" -DEFAULT_COMPARE_SCHOOLS_PAGE_URL = "https://www.ratemyprofessors.com/compare/schools/" -DEFAULT_SEARCH_PROFESSORS_PAGE_URL = "https://www.ratemyprofessors.com/search/professors/" -DEFAULT_SEARCH_SCHOOLS_PAGE_URL = "https://www.ratemyprofessors.com/search/schools/" - -# Headers used for GET requests (e.g. professor page HTML). RMP uses server-side rendering. -DEFAULT_GET_HEADERS: Mapping[str, str] = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0", + +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0" +) + +DEFAULT_HEADERS: Mapping[str, str] = { + "User-Agent": DEFAULT_USER_AGENT, "Accept-Language": "en-US,en;q=0.5", } @@ -21,20 +25,15 @@ class RMPClientConfig: """Configuration for RMPClient. - This is deliberately small and serialisable so you can stash it in settings. + Build one and pass it to :class:`RMPClient`. Only override what you need; + everything has sensible defaults. """ base_url: str = DEFAULT_BASE_URL - professors_page_url: str = DEFAULT_PROFESSORS_PAGE_URL - schools_page_url: str = DEFAULT_SCHOOLS_PAGE_URL - compare_schools_page_url: str = DEFAULT_COMPARE_SCHOOLS_PAGE_URL - search_professors_page_url: str = DEFAULT_SEARCH_PROFESSORS_PAGE_URL - search_schools_page_url: str = DEFAULT_SEARCH_SCHOOLS_PAGE_URL timeout_seconds: float = 10.0 max_retries: int = 3 rate_limit_per_minute: int = 60 - user_agent: str = DEFAULT_GET_HEADERS["User-Agent"] + user_agent: str = DEFAULT_USER_AGENT default_headers: Mapping[str, str] = field( - default_factory=lambda: dict(DEFAULT_GET_HEADERS) + default_factory=lambda: dict(DEFAULT_HEADERS) ) - diff --git a/src/rmp_client/extras/__pycache__/__init__.cpython-313.pyc b/src/rmp_client/extras/__pycache__/__init__.cpython-313.pyc index f4c920b3cda0655f91b113070c43fde35a3fda3b..0bafe1a6dc40adddb30ac67c598199ef6c9944db 100644 GIT binary patch literal 503 zcmYL_yH3L}6o&0IP0~W4MM$h&I|N3gszQQJO$92g*dkeOVk#r&qT>WnHeP^@hu{f# z7cU){U_x|Y#V##9!>@Ds&c$9f8g-=YdGN+RF+%Sq*qF6;mObsfpb47bAqp_yzyhmn zVry6lY_NkWR9A6j=majffd^h2ouG+*jiENHcAO7A8+9zTKVfOX6D?yVOHrs6O{tha zG9otxO*OeB_f+thL|I~B)k-r+bVh^gl*SIHB^QP%K{AriJm=|59YliBbdwcy%0$P3 zirx>lk*X2Lh-<{ta@DD;+W!GtRyi??%baa{)b4s)l*LSLCn%Q?Dg+^~XsHJx#rxgx zMlz7$c|vI#jv%{ZQ6WP}a-Zhhj|A5b^YeK)rUmma=e|a6nUonw|9mv?`zs)n5SbqG z$A|2p04hU5c*+Yxj`O+ldPx?Sf}KHAZ#Ew-kFnYXATM delta 88 zcmey)e2UThGcPX}0}$LgwKlUGNIwQ~V1Nln delta 221 zcmeyu^OTqOGcPX}0}$LgwKntVM&8BDj3tvdGF#emaRsFomSz^E7Aurwq$=bWlw{`T zCFUq36sP8uWaa{i1clU!lA=U~l6-}vRE5mE;*!LioYWLOuByDrZCL zQuWgH6cP$D3t$=)iV{mwb1Msq^3zg_i}Q<$b(3>4fu={pwZ None: self._config = config self._client = httpx.Client(timeout=config.timeout_seconds) - # Simple per-process rate limiter self._bucket = TokenBucket( capacity=config.rate_limit_per_minute, refill_per_second=config.rate_limit_per_minute / 60.0, @@ -39,8 +40,12 @@ def post_json( *, headers: Optional[Mapping[str, str]] = None, ) -> Dict[str, Any]: - """POST JSON to `base_url + path` with retries and rate limiting.""" - url = self._config.base_url if path == "" else f"{self._config.base_url.rstrip('/')}/{path.lstrip('/')}" + """POST JSON to ``base_url + path`` with retries and rate limiting.""" + url = ( + self._config.base_url + if path == "" + else f"{self._config.base_url.rstrip('/')}/{path.lstrip('/')}" + ) attempt = 0 last_exc: Optional[Exception] = None @@ -63,53 +68,23 @@ def post_json( try: data = response.json() except json.JSONDecodeError as exc: - raise HttpError(response.status_code, str(response.url), body=response.text) from exc - # If GraphQL-like, surface errors if present + raise HttpError( + response.status_code, str(response.url), body=response.text + ) from exc if isinstance(data, dict) and "errors" in data: - raise RMPAPIError("RMP API returned errors", details=data["errors"]) + raise RMPAPIError( + "RMP API returned errors", details=data["errors"] + ) return data # type: ignore[return-value] - # Non-2xx - err = HttpError(response.status_code, str(response.url), body=response.text) + err = HttpError( + response.status_code, str(response.url), body=response.text + ) last_exc = err - # Retry on 5xx, fail fast on 4xx - if 500 <= response.status_code < 600 and attempt <= self._config.max_retries: - continue - raise err - - assert last_exc is not None - raise RetryError(last_exc) - - def get_html( - self, - url: str, - *, - headers: Optional[Mapping[str, str]] = None, - ) -> str: - """GET URL and return response text with retries and rate limiting.""" - attempt = 0 - last_exc: Optional[Exception] = None - - while attempt <= self._config.max_retries: - attempt += 1 - self._bucket.consume() - try: - response = self._client.get( - url, - headers=self._headers(headers), - ) - except httpx.HTTPError as exc: - last_exc = exc - if attempt > self._config.max_retries: - raise RetryError(exc) - continue - - if 200 <= response.status_code < 300: - return response.text - - err = HttpError(response.status_code, str(response.url), body=response.text) - last_exc = err - if 500 <= response.status_code < 600 and attempt <= self._config.max_retries: + if ( + 500 <= response.status_code < 600 + and attempt <= self._config.max_retries + ): continue raise err @@ -131,4 +106,3 @@ def __enter__(self) -> HttpClient: def __exit__(self, *_: Any) -> None: assert self._client is not None self._client.close() - diff --git a/src/rmp_client/models.py b/src/rmp_client/models.py index 3cf7849..58ab316 100644 --- a/src/rmp_client/models.py +++ b/src/rmp_client/models.py @@ -1,3 +1,9 @@ +"""Data models for RateMyProfessors API responses. + +All fields use snake_case. Numeric IDs are the legacy integer IDs visible +in RMP URLs; global Relay IDs are used internally only. +""" + from __future__ import annotations from datetime import date @@ -7,12 +13,15 @@ class School(BaseModel): - """A school on the RateMyProfessors website.""" + """A school (university or college). + + Category fields (reputation, safety, etc.) are populated by ``get_school()`` + but may be absent on search results where only basic data is returned. + """ + id: str name: str - # Single location string (e.g. "Kingston, ON") location: Optional[str] = None - # From school page: overall quality and category ratings (out of 5) overall_quality: Optional[float] = None num_ratings: Optional[int] = None reputation: Optional[float] = None @@ -20,7 +29,7 @@ class School(BaseModel): happiness: Optional[float] = None facilities: Optional[float] = None social: Optional[float] = None - location_rating: Optional[float] = None # "Location" category score out of 5 + location_rating: Optional[float] = None clubs: Optional[float] = None opportunities: Optional[float] = None internet: Optional[float] = None @@ -28,7 +37,12 @@ class School(BaseModel): class Professor(BaseModel): - """A professor on the RateMyProfessors website.""" + """A professor (teacher). + + ``tags`` and ``rating_distribution`` are always empty/null in the current + GraphQL API responses; kept for forward compatibility. + """ + id: str name: str department: Optional[str] = None @@ -39,11 +53,22 @@ class Professor(BaseModel): percent_take_again: Optional[float] = None level_of_difficulty: Optional[float] = None tags: List[str] = [] - # Rating distribution: key = level 1-5 (Awful=1 .. Awesome=5), value = {number of ratings, percentage of total ratings} rating_distribution: Optional[Dict[int, RatingDistributionBucket]] = None + +class RatingDistributionBucket(BaseModel): + """One bucket in a professor's star-rating distribution.""" + + count: int + percentage: float + + class Rating(BaseModel): - """A single professor rating/review.""" + """A single professor rating (review). + + ``quality`` maps to RMP's clarity/helpful rating; ``details`` may contain + for_credit, attendance, grade, textbook when the API returns them. + """ date: date comment: str @@ -51,20 +76,28 @@ class Rating(BaseModel): difficulty: Optional[float] = None tags: List[str] = [] course_raw: Optional[str] = None - # Extra metadata: for_credit, attendance, grade, textbook, etc. Keys lowercase. details: Optional[Dict[str, Any]] = None - helpful: Optional[int] = None thumbs_up: Optional[int] = None thumbs_down: Optional[int] = None -class RatingDistributionBucket(BaseModel): - """The number of ratings and the percentage of ratings for a given rating level - 1 (lowest) to 5 (highest).""" - count: int - percentage: float +class SchoolRating(BaseModel): + """A single school rating (review). + + ``overall`` is computed as the average of all category scores. + ``category_ratings`` maps category name to score. + """ + + date: date + comment: str + overall: Optional[float] = None + category_ratings: Optional[Dict[str, float]] = None + thumbs_up: Optional[int] = None + thumbs_down: Optional[int] = None + class ProfessorRatingsPage(BaseModel): - """A page of ratings for a professor.""" + """One page of professor ratings with cursor pagination.""" professor: Professor ratings: List[Rating] @@ -73,55 +106,36 @@ class ProfessorRatingsPage(BaseModel): class ProfessorSearchResult(BaseModel): - """A page of search results for professors.""" + """Paginated result from professor search or listing by school.""" professors: List[Professor] total: Optional[int] = None - page: int page_size: int has_next_page: bool - next_cursor: Optional[str] = None # from relay pageInfo.endCursor for next page + next_cursor: Optional[str] = None class SchoolSearchResult(BaseModel): - """A page of search results for schools.""" + """Paginated result from school search.""" schools: List[School] total: Optional[int] = None - page: int page_size: int has_next_page: bool - next_cursor: Optional[str] = None # from relay pageInfo.endCursor for next page + next_cursor: Optional[str] = None class CompareSchoolsResult(BaseModel): - """Result of comparing two schools (from /compare/schools/id1/id2).""" + """Result of comparing two schools.""" school_1: School school_2: School -class SchoolRating(BaseModel): - """A single rating/review on a school page. - - Each rating has an overall score (out of 5), optional category bars (out of 5), - and optional thumbs/helpful counts. - """ - - date: date - comment: str - overall: Optional[float] = None - # Category bars (out of 5): reputation, location, opportunities, facilities, - # internet, food, clubs, social, happiness, safety. Keys lowercase. - category_ratings: Optional[Dict[str, float]] = None - helpful: Optional[int] = None - thumbs_up: Optional[int] = None - thumbs_down: Optional[int] = None - - class SchoolRatingsPage(BaseModel): + """One page of school ratings with cursor pagination.""" + school: School ratings: List[SchoolRating] has_next_page: bool next_cursor: Optional[str] = None - diff --git a/src/rmp_client/queries.py b/src/rmp_client/queries.py new file mode 100644 index 0000000..e8a8579 --- /dev/null +++ b/src/rmp_client/queries.py @@ -0,0 +1,219 @@ +"""GraphQL query strings sent to the RateMyProfessors API. + +Each constant matches an operation the RMP frontend uses. Variable names +and fragment names are kept identical so the server accepts them. +""" + +RATINGS_LIST_QUERY = """ +query RatingsListQuery($count: Int!, $id: ID!, $courseFilter: String, $cursor: String) { + node(id: $id) { + __typename + ... on Teacher { + ...RatingsList_teacher_4pguUW + id + } + } +} +fragment RatingsList_teacher_4pguUW on Teacher { + id + legacyId + firstName + lastName + department + avgRating + avgDifficulty + numRatings + wouldTakeAgainPercent + school { + id + legacyId + name + city + state + avgRating + numRatings + } + ratings(first: $count, after: $cursor, courseFilter: $courseFilter) { + edges { + cursor + node { + id + __typename + comment + helpfulRating + clarityRating + difficultyRating + ratingTags + date + class + grade + attendanceMandatory + textbookUse + isForCredit + thumbsUpTotal + thumbsDownTotal + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +""" + +SCHOOL_RATINGS_LIST_QUERY = """ +query SchoolRatingsListQuery($count: Int!, $id: ID!, $cursor: String) { + node(id: $id) { + ... on School { + id + name + city + state + country + ratings(first: $count, after: $cursor) { + edges { + cursor + node { + id + comment + date + reputationRating + locationRating + safetyRating + socialRating + opportunitiesRating + happinessRating + facilitiesRating + internetRating + foodRating + clubsRating + thumbsUpTotal + thumbsDownTotal + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +""" + +SCHOOL_SEARCH_RESULTS_QUERY = """ +query SchoolSearchResultsPageQuery($query: SchoolSearchQuery!, $count: Int!, $cursor: String) { + search: newSearch { + schools(query: $query, first: $count, after: $cursor) { + edges { + cursor + node { + id + legacyId + name + city + state + numRatings + avgRating + avgRatingRounded + } + } + pageInfo { + hasNextPage + endCursor + } + resultCount + } + } +} +""" + +TEACHER_SEARCH_RESULTS_QUERY = """ +query TeacherSearchResultsPageQuery($query: TeacherSearchQuery!, $count: Int!, $cursor: String) { + search: newSearch { + teachers(query: $query, first: $count, after: $cursor) { + edges { + cursor + node { + id + legacyId + firstName + lastName + avgRating + numRatings + wouldTakeAgainPercent + avgDifficulty + department + school { + id + legacyId + name + city + state + } + } + } + pageInfo { + hasNextPage + endCursor + } + resultCount + } + } +} +""" + +GET_TEACHER_QUERY = """ +query GetTeacherQuery($id: ID!) { + node(id: $id) { + ... on Teacher { + id + legacyId + firstName + lastName + department + avgRating + avgDifficulty + numRatings + wouldTakeAgainPercent + school { + id + legacyId + name + city + state + } + } + } +} +""" + +GET_SCHOOL_QUERY = """ +query GetSchoolQuery($id: ID!) { + node(id: $id) { + ... on School { + id + legacyId + name + city + state + country + numRatings + avgRatingRounded + summary { + campusCondition + campusLocation + careerOpportunities + clubAndEventActivities + foodQuality + internetSpeed + schoolReputation + schoolSafety + schoolSatisfaction + socialActivities + } + } + } +} +""" diff --git a/src/rmp_client/relay_store.py b/src/rmp_client/relay_store.py deleted file mode 100644 index d40f349..0000000 --- a/src/rmp_client/relay_store.py +++ /dev/null @@ -1,516 +0,0 @@ -"""Extract and parse window.__RELAY_STORE__ from RMP professor page HTML.""" - -from __future__ import annotations - -import base64 -import json -from typing import Any, Dict, List, Mapping, Optional - - -def extract_relay_store(html: str) -> Dict[str, Any]: - """Extract window.__RELAY_STORE__ from professor page HTML and parse as JSON. - - Raises: - ValueError: If __RELAY_STORE__ is not found or JSON is invalid. - """ - marker = "window.__RELAY_STORE__" - if marker not in html: - raise ValueError("__RELAY_STORE__ not found in HTML") - - start = html.index(marker) + len(marker) - # Skip " = " - start = html.index("=", start) + 1 - # Find the start of the JSON object - start = html.index("{", start) - depth = 0 - end = start - in_string = False - escape = False - quote = None - i = start - while i < len(html): - c = html[i] - if escape: - escape = False - i += 1 - continue - if in_string: - if c == "\\": - escape = True - elif c == quote: - in_string = False - i += 1 - continue - if c in ('"', "'"): - in_string = True - quote = c - i += 1 - continue - if c == "{": - depth += 1 - elif c == "}": - depth -= 1 - if depth == 0: - end = i + 1 - break - i += 1 - - if depth != 0: - raise ValueError("__RELAY_STORE__: unclosed JSON object") - - raw = html[start:end] - return json.loads(raw) - - -def _is_record_ref(value: Any) -> bool: - return isinstance(value, dict) and "__ref" in value and len(value) == 1 - - -def _resolve_ref(store: Mapping[str, Any], ref: Dict[str, str]) -> Optional[Dict[str, Any]]: - record_id = ref.get("__ref") - if not record_id: - return None - return store.get(record_id) if isinstance(store.get(record_id), dict) else None - - -def _record_id(record: Mapping[str, Any]) -> Optional[str]: - return record.get("__id") or record.get("id") - - -def _resolve_refs(store: Mapping[str, Any], ref_ids: List[str]) -> List[Dict[str, Any]]: - """Resolve a list of record IDs to a list of records. Skips missing/invalid.""" - out: List[Dict[str, Any]] = [] - for ref_id in ref_ids or []: - if not isinstance(ref_id, str): - continue - rec = store.get(ref_id) - if isinstance(rec, dict): - out.append(rec) - return out - - -def get_professor_node(store: Dict[str, Any], professor_id: str) -> Optional[Dict[str, Any]]: - """Find the Professor/Teacher record in a Relay store by legacy ID (URL slug).""" - professor_id_str = str(professor_id) - for record in store.values(): - if not isinstance(record, dict): - continue - # RMP uses __typename "Teacher" on the professor page - if record.get("__typename") not in ("Professor", "Teacher"): - continue - # Match by legacyId (URL slug in /professor/{legacyId}) or id/__id - legacy = record.get("legacyId") - if legacy is not None and str(legacy) == professor_id_str: - return record - rid = record.get("id") or record.get("__id") - if rid is not None and str(rid) == professor_id_str: - return record - return None - - -def _get_ratings_connection_ref(professor_record: Dict[str, Any]) -> Optional[Dict[str, str]]: - """Get the ratings connection __ref from a professor/teacher record. - - RMP uses keys like "ratings(first:5)" or "ratings"; value is {"__ref": "..."}. - """ - # Prefer exact key then any key that starts with "ratings" - for key in ("ratings(first:5)", "ratings"): - val = professor_record.get(key) - if _is_record_ref(val): - return val - for key, val in professor_record.items(): - if key.startswith("ratings") and _is_record_ref(val): - return val - return None - - -def _edges_to_rating_records( - store: Dict[str, Any], edges_value: Any -) -> List[Dict[str, Any]]: - """Turn connection edges (list or __refs) into list of Rating record dicts.""" - ratings: List[Dict[str, Any]] = [] - edge_refs: List[str] = [] - if isinstance(edges_value, list): - for edge in edges_value: - if not isinstance(edge, dict): - continue - node = edge.get("node") - if _is_record_ref(node): - rec = _resolve_ref(store, node) - if rec and rec.get("__typename") in ("Rating", "ProfessorRating", "Review"): - ratings.append(rec) - elif isinstance(node, dict): - ratings.append(node) - return ratings - # RMP uses edges: {"__refs": ["...edges:0", "...edges:1", ...]} - if isinstance(edges_value, dict) and "__refs" in edges_value: - edge_refs = edges_value.get("__refs") or [] - if not edge_refs: - return ratings - for ref_id in edge_refs: - edge_record = store.get(ref_id) if isinstance(ref_id, str) else None - if not isinstance(edge_record, dict): - continue - node = edge_record.get("node") - if _is_record_ref(node): - rating_record = _resolve_ref(store, node) - if rating_record and rating_record.get("__typename") in ("Rating", "ProfessorRating", "Review"): - ratings.append(rating_record) - elif isinstance(node, dict): - ratings.append(node) - return ratings - - -def _get_professor_ratings_connection( - store: Dict[str, Any], professor_record: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - """Resolve the ratings connection dict for a professor/teacher record.""" - ratings_ref = _get_ratings_connection_ref(professor_record) - if ratings_ref is not None: - return _resolve_ref(store, ratings_ref) - ratings_field = professor_record.get("ratings") - if isinstance(ratings_field, dict) and "edges" in ratings_field: - return ratings_field - return None - - -def get_professor_ratings_connection_page_info( - store: Dict[str, Any], professor_record: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - """Return pageInfo (hasNextPage, endCursor) for the professor's ratings connection.""" - conn = _get_professor_ratings_connection(store, professor_record) - if not isinstance(conn, dict): - return None - page_info_ref = conn.get("pageInfo") - if not _is_record_ref(page_info_ref): - return None - info = _resolve_ref(store, page_info_ref) - return info if isinstance(info, dict) else None - - -def get_ratings_from_store( - store: Dict[str, Any], - professor_record: Dict[str, Any], -) -> List[Dict[str, Any]]: - """Extract rating records from the store for this professor. - - Handles Relay connection pattern: professor.ratings -> connection (or __ref) -> edges (list or __refs) -> node (__ref). - """ - conn = _get_professor_ratings_connection(store, professor_record) - if not conn: - return [] - edges_value = conn.get("edges") - return _edges_to_rating_records(store, edges_value) - - -def get_all_rating_records(store: Dict[str, Any]) -> List[Dict[str, Any]]: - """Fallback: collect all records that look like ratings from the store.""" - out: List[Dict[str, Any]] = [] - for record in store.values(): - if not isinstance(record, dict): - continue - if record.get("__typename") in ("Rating", "ProfessorRating", "Review"): - out.append(record) - return out - - -def get_school_node(store: Dict[str, Any], school_id: str) -> Optional[Dict[str, Any]]: - """Find the School/University record in a Relay store by legacy ID (URL slug) or id.""" - sid = str(school_id) - for key, record in store.items(): - if not isinstance(record, dict): - continue - if record.get("__typename") not in ("School", "University"): - continue - # Match by legacyId first (URL slug in /school/{legacyId}) - legacy = record.get("legacyId") - if legacy is not None and str(legacy) == sid: - return record - rid = record.get("id") or record.get("__id") - if rid is not None and str(rid) == sid: - return record - try: - decoded = base64.b64decode(key).decode("utf-8", errors="replace") - if sid in decoded: - return record - except Exception: - pass - if sid in str(key): - return record - schools = [ - r - for r in store.values() - if isinstance(r, dict) and r.get("__typename") in ("School", "University") - ] - if len(schools) == 1: - return schools[0] - return None - - -def _get_school_ratings_connection_ref(school_record: Dict[str, Any]) -> Optional[Dict[str, str]]: - """Get the ratings connection __ref from a school record. - - RMP uses keys like "ratings(first:5)" or "ratings"; value is {"__ref": "..."}. - """ - for key in ("ratings(first:5)", "ratings"): - val = school_record.get(key) - if _is_record_ref(val): - return val - for key, val in school_record.items(): - if key.startswith("ratings") and _is_record_ref(val): - return val - return None - - -def _edges_to_school_rating_records( - store: Dict[str, Any], edges_value: Any -) -> List[Dict[str, Any]]: - """Turn connection edges (list or __refs) into list of SchoolRating record dicts.""" - ratings: List[Dict[str, Any]] = [] - school_typenames = ("SchoolRating", "Rating", "SchoolReview", "Review") - edge_refs: List[str] = [] - if isinstance(edges_value, list): - for edge in edges_value: - if not isinstance(edge, dict): - continue - node = edge.get("node") - if _is_record_ref(node): - rec = _resolve_ref(store, node) - if rec and rec.get("__typename") in school_typenames: - ratings.append(rec) - elif isinstance(node, dict): - ratings.append(node) - return ratings - if isinstance(edges_value, dict) and "__refs" in edges_value: - edge_refs = edges_value.get("__refs") or [] - if not edge_refs: - return ratings - for ref_id in edge_refs: - edge_record = store.get(ref_id) if isinstance(ref_id, str) else None - if not isinstance(edge_record, dict): - continue - node = edge_record.get("node") - if _is_record_ref(node): - rating_record = _resolve_ref(store, node) - if rating_record and rating_record.get("__typename") in school_typenames: - ratings.append(rating_record) - elif isinstance(node, dict): - ratings.append(node) - return ratings - - -def _get_school_ratings_connection( - store: Dict[str, Any], school_record: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - """Resolve the ratings connection dict for a school record.""" - ratings_ref = _get_school_ratings_connection_ref(school_record) - if ratings_ref is not None: - return _resolve_ref(store, ratings_ref) - ratings_field = school_record.get("ratings") - if isinstance(ratings_field, dict) and "edges" in ratings_field: - return ratings_field - if _is_record_ref(ratings_field): - return _resolve_ref(store, ratings_field) - return None - - -def get_school_ratings_connection_page_info( - store: Dict[str, Any], school_record: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - """Return pageInfo (hasNextPage, endCursor) for the school's ratings connection.""" - conn = _get_school_ratings_connection(store, school_record) - if not isinstance(conn, dict): - return None - page_info_ref = conn.get("pageInfo") - if not _is_record_ref(page_info_ref): - return None - info = _resolve_ref(store, page_info_ref) - return info if isinstance(info, dict) else None - - -def get_school_ratings_from_store( - store: Dict[str, Any], - school_record: Dict[str, Any], -) -> List[Dict[str, Any]]: - """Extract school rating records from the store. - - Handles Relay pattern: school.ratings(first:5) -> connection -> edges (list or __refs) -> node (__ref). - """ - conn = _get_school_ratings_connection(store, school_record) - if not conn: - return [] - edges_value = conn.get("edges") - return _edges_to_school_rating_records(store, edges_value) - - -def get_all_school_rating_records(store: Dict[str, Any]) -> List[Dict[str, Any]]: - """Fallback: collect all records that look like school ratings from the store.""" - out: List[Dict[str, Any]] = [] - for record in store.values(): - if not isinstance(record, dict): - continue - if record.get("__typename") in ("Rating", "SchoolRating", "Review", "SchoolReview"): - out.append(record) - return out - - -# ---- Professor search page (search/professors/?q=...) ------------------------------------------ - - -def get_teacher_search_connection(store: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Get the TeacherSearchConnectionConnection from a search page relay store. - - Root has newSearch __ref -> newSearch record has teachers(...) __ref -> connection. - """ - root = store.get("client:root") - if not isinstance(root, dict): - return None - new_search_ref = root.get("newSearch") - if not _is_record_ref(new_search_ref): - return None - new_search = _resolve_ref(store, new_search_ref) - if not isinstance(new_search, dict): - return None - for key, val in new_search.items(): - if key.startswith("teachers(") and _is_record_ref(val): - conn = _resolve_ref(store, val) - if isinstance(conn, dict) and conn.get("edges") is not None: - return conn - return None - - -def _edges_to_teacher_records( - store: Dict[str, Any], edges_value: Any -) -> List[Dict[str, Any]]: - """Turn connection edges (list or __refs) into list of Teacher/Professor record dicts.""" - teachers: List[Dict[str, Any]] = [] - teacher_typenames = ("Teacher", "Professor") - edge_refs: List[str] = [] - if isinstance(edges_value, list): - for edge in edges_value: - if not isinstance(edge, dict): - continue - node = edge.get("node") - if _is_record_ref(node): - rec = _resolve_ref(store, node) - if rec and rec.get("__typename") in teacher_typenames: - teachers.append(rec) - elif isinstance(node, dict) and node.get("__typename") in teacher_typenames: - teachers.append(node) - return teachers - if isinstance(edges_value, dict) and "__refs" in edges_value: - edge_refs = edges_value.get("__refs") or [] - if not edge_refs: - return teachers - for ref_id in edge_refs: - edge_record = store.get(ref_id) if isinstance(ref_id, str) else None - if not isinstance(edge_record, dict): - continue - node = edge_record.get("node") - if _is_record_ref(node): - rec = _resolve_ref(store, node) - if rec and rec.get("__typename") in teacher_typenames: - teachers.append(rec) - elif isinstance(node, dict) and node.get("__typename") in teacher_typenames: - teachers.append(node) - return teachers - - -def get_teacher_search_result_count(connection: Dict[str, Any]) -> Optional[int]: - """Get resultCount from TeacherSearchConnectionConnection (total matches).""" - val = connection.get("resultCount") - return int(val) if val is not None else None - - -def get_teacher_search_page_info( - store: Dict[str, Any], connection: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - """Resolve pageInfo __ref from connection; return dict with hasNextPage, endCursor.""" - page_info_ref = connection.get("pageInfo") - if not _is_record_ref(page_info_ref): - return None - info = _resolve_ref(store, page_info_ref) - if not isinstance(info, dict): - return None - return info - - -# ---- School search page (search/schools?q=...) ---------------------------------------------- - - -def get_school_search_connection(store: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Get the SchoolSearchConnectionConnection from a search page relay store. - - Root has newSearch __ref -> newSearch record has schools(...) __ref -> connection. - """ - root = store.get("client:root") - if not isinstance(root, dict): - return None - new_search_ref = root.get("newSearch") - if not _is_record_ref(new_search_ref): - return None - new_search = _resolve_ref(store, new_search_ref) - if not isinstance(new_search, dict): - return None - for key, val in new_search.items(): - if key.startswith("schools(") and _is_record_ref(val): - conn = _resolve_ref(store, val) - if isinstance(conn, dict) and conn.get("edges") is not None: - return conn - return None - - -def _edges_to_school_records( - store: Dict[str, Any], edges_value: Any -) -> List[Dict[str, Any]]: - """Turn connection edges (list or __refs) into list of School record dicts.""" - schools: List[Dict[str, Any]] = [] - school_typenames = ("School", "University") - edge_refs: List[str] = [] - if isinstance(edges_value, list): - for edge in edges_value: - if not isinstance(edge, dict): - continue - node = edge.get("node") - if _is_record_ref(node): - rec = _resolve_ref(store, node) - if rec and rec.get("__typename") in school_typenames: - schools.append(rec) - elif isinstance(node, dict) and node.get("__typename") in school_typenames: - schools.append(node) - return schools - if isinstance(edges_value, dict) and "__refs" in edges_value: - edge_refs = edges_value.get("__refs") or [] - if not edge_refs: - return schools - for ref_id in edge_refs: - edge_record = store.get(ref_id) if isinstance(ref_id, str) else None - if not isinstance(edge_record, dict): - continue - node = edge_record.get("node") - if _is_record_ref(node): - rec = _resolve_ref(store, node) - if rec and rec.get("__typename") in school_typenames: - schools.append(rec) - elif isinstance(node, dict) and node.get("__typename") in school_typenames: - schools.append(node) - return schools - - -def get_school_search_result_count(connection: Dict[str, Any]) -> Optional[int]: - """Get resultCount from SchoolSearchConnectionConnection (total matches).""" - val = connection.get("resultCount") - return int(val) if val is not None else None - - -def get_school_search_page_info( - store: Dict[str, Any], connection: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - """Resolve pageInfo __ref from connection; return dict with hasNextPage, endCursor.""" - page_info_ref = connection.get("pageInfo") - if not _is_record_ref(page_info_ref): - return None - info = _resolve_ref(store, page_info_ref) - if not isinstance(info, dict): - return None - return info diff --git a/tests/test_client.py b/tests/test_client.py index 68b8bca..09a5c2a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,9 +1,11 @@ -"""Tests for RMPClient (get_professor, ratings, parsing) with mocked HTTP.""" +"""Tests for RMPClient with mocked GraphQL responses via pytest-httpx.""" from __future__ import annotations +import base64 import json from datetime import date + import pytest import pytest_httpx @@ -12,404 +14,604 @@ from rmp_client.errors import ParsingError -def _html_with_store(store: dict) -> str: - return f'' - - -def _make_professor_store(professor_id: str, name: str = "Test Professor", **kwargs: object) -> dict: - """Minimal Relay store with one Professor and optional school/ratings.""" - prof_node = { - "__typename": "Professor", - "id": professor_id, - "legacyId": professor_id, - "name": name, - "overallRating": 4.5, - "numRatings": 10, - **kwargs, - } - store: dict = {f"node:{professor_id}": prof_node} - return store - - -def _add_school_to_store(store: dict, prof_key: str, school_id: str = "s1") -> None: - store["node:s1"] = { - "__typename": "School", - "id": school_id, - "name": "Test University", - "location": "City, ST, USA", - } - store[prof_key]["school"] = {"__ref": "node:s1"} - - -def _add_ratings_to_store( - store: dict, prof_key: str, rating_comments: list[str] -) -> None: - edges = [] - for i, comment in enumerate(rating_comments): - rid = f"node:r{i}" - store[rid] = { - "__typename": "Rating", - "id": rid, - "comment": comment, - "date": "2024-01-15", - "quality": 5.0, - "difficulty": 2.0, - "course": "MATH 101", - } - edges.append({"node": {"__ref": rid}}) - conn_id = "conn:ratings" - store[conn_id] = {"edges": edges} - store[prof_key]["ratings"] = {"__ref": conn_id} - - -class TestRMPClientGetProfessor: - """get_professor fetches page and parses __RELAY_STORE__.""" - - def test_returns_professor_from_relay_store( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - config = RMPClientConfig( - professors_page_url="https://www.ratemyprofessors.com/professor/", - rate_limit_per_minute=1000, - ) - store = _make_professor_store("abc123", name="Jane Doe", department="Math") - html = _html_with_store(store) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/professor/abc123", - text=html, - ) - with RMPClient(config=config) as client: - prof = client.get_professor("abc123") - assert prof.id == "abc123" - assert prof.name == "Jane Doe" - assert prof.department == "Math" - assert prof.overall_rating == 4.5 - assert prof.num_ratings == 10 - - def test_resolves_school_ref( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - config = RMPClientConfig( - professors_page_url="https://www.ratemyprofessors.com/professor/", - rate_limit_per_minute=1000, - ) - store = _make_professor_store("p1") - _add_school_to_store(store, "node:p1") - html = _html_with_store(store) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/professor/p1", - text=html, - ) - with RMPClient(config=config) as client: - prof = client.get_professor("p1") - assert prof.school is not None - assert prof.school.name == "Test University" - assert prof.school.location == "City, ST, USA" - - def test_raises_parsing_error_when_professor_not_in_store( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - config = RMPClientConfig( - professors_page_url="https://www.ratemyprofessors.com/professor/", - rate_limit_per_minute=1000, - ) - store = {"client:root": {"__id": "client:root"}} # no Professor - html = _html_with_store(store) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/professor/missing", - text=html, - ) - with RMPClient(config=config) as client: - with pytest.raises(ParsingError, match="not found"): - client.get_professor("missing") +def _cfg() -> RMPClientConfig: + return RMPClientConfig(rate_limit_per_minute=10000) - def test_raises_parsing_error_when_store_missing_in_html( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - config = RMPClientConfig( - professors_page_url="https://www.ratemyprofessors.com/professor/", - rate_limit_per_minute=1000, - ) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/professor/x", - text="No store here", - ) - with RMPClient(config=config) as client: - with pytest.raises(ParsingError, match="__RELAY_STORE__"): - client.get_professor("x") - - -class TestRMPClientGetProfessorRatingsPage: - """get_professor_ratings_page returns professor and ratings from store.""" - - def test_returns_ratings_from_store( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - config = RMPClientConfig( - professors_page_url="https://www.ratemyprofessors.com/professor/", - rate_limit_per_minute=1000, - ) - store = _make_professor_store("p1", name="Dr. Smith") - _add_ratings_to_store(store, "node:p1", ["Great!", "Okay.", "Loved it"]) - html = _html_with_store(store) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/professor/p1", - text=html, - ) - with RMPClient(config=config) as client: - page = client.get_professor_ratings_page("p1", page_size=10) - assert page.professor.name == "Dr. Smith" - assert len(page.ratings) == 3 - assert page.ratings[0].comment == "Great!" - assert page.ratings[1].comment == "Okay." - assert page.ratings[2].comment == "Loved it" - assert page.ratings[0].date == date(2024, 1, 15) - assert page.ratings[0].course_raw == "MATH 101" - - def test_pagination_in_memory( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - config = RMPClientConfig( - professors_page_url="https://www.ratemyprofessors.com/professor/", - rate_limit_per_minute=1000, - ) - store = _make_professor_store("p1") - _add_ratings_to_store(store, "node:p1", ["A", "B", "C", "D", "E"]) - html = _html_with_store(store) - # Same page is fetched for each call to get_professor_ratings_page - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/professor/p1", - text=html, - is_reusable=True, - ) - with RMPClient(config=config) as client: - page1 = client.get_professor_ratings_page("p1", page_size=2) - page2 = client.get_professor_ratings_page( - "p1", cursor=page1.next_cursor, page_size=2 - ) - assert len(page1.ratings) == 2 - assert page1.ratings[0].comment == "A" - assert page1.ratings[1].comment == "B" - assert page1.has_next_page is True - assert len(page2.ratings) == 2 - assert page2.ratings[0].comment == "C" - assert page2.ratings[1].comment == "D" - - -class TestRMPClientProfessorPageUrl: - """_professor_page_url uses config.professors_page_url.""" - - def test_builds_correct_url(self) -> None: - config = RMPClientConfig(professors_page_url="https://site.com/professor/") - client = RMPClient(config=config) - url = client._professor_page_url("legacy-123") - assert url == "https://site.com/professor/legacy-123" - - -class TestRMPClientSearchSchools: - """search_schools fetches search page and parses __RELAY_STORE__.""" - - def test_returns_schools_from_search_page( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - store = { - "client:root": {"__id": "client:root", "newSearch": {"__ref": "client:root:newSearch"}}, - "client:root:newSearch": { - "__id": "client:root:newSearch", - "schools(after:\"\",first:5,query:{\"text\":\"queens\"})": {"__ref": "conn:schools"}, - }, - "conn:schools": { - "__typename": "SchoolSearchConnectionConnection", - "resultCount": 8, - "edges": {"__refs": ["edge:0", "edge:1"]}, - "pageInfo": {"__ref": "conn:pageInfo"}, - }, - "conn:pageInfo": {"hasNextPage": True, "endCursor": "YXJyYXljb25uZWN0aW9uOjQ="}, - "edge:0": {"node": {"__ref": "S1"}}, - "edge:1": {"node": {"__ref": "S2"}}, - "S1": { - "__typename": "School", - "legacyId": 231, - "name": "CUNY Queens College", - "city": "Queens", - "state": "NY", - "numRatings": 551, - "avgRatingRounded": 3.3, - "id": "S1", - }, - "S2": { - "__typename": "School", - "legacyId": 842, - "name": "St. John's University - Jamaica/Queens", - "city": "Queens", - "state": "NY", - "numRatings": 425, - "avgRatingRounded": 3.5, - "id": "S2", - }, - } - html = _html_with_store(store) - config = RMPClientConfig( - search_schools_page_url="https://www.ratemyprofessors.com/search/schools/", - rate_limit_per_minute=1000, - ) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/search/schools?q=queens", - text=html, - ) - with RMPClient(config=config) as client: - result = client.search_schools("queens") + +def _gql(data: dict) -> str: + return json.dumps({"data": data}) + + +# --------------------------------------------------------------------------- +# searchSchools +# --------------------------------------------------------------------------- + + +class TestSearchSchools: + def test_returns_schools(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"schools": { + "edges": [ + {"cursor": "c0", "node": {"id": "U2Nob29sLTIzMQ==", "legacyId": 231, "name": "CUNY Queens College", "city": "Queens", "state": "NY", "numRatings": 552, "avgRating": 0, "avgRatingRounded": 3.3}}, + {"cursor": "c1", "node": {"id": "U2Nob29sLTE0NjY=", "legacyId": 1466, "name": "Queen's University at Kingston", "city": "Kingston", "state": "ON", "numRatings": 460, "avgRating": 0, "avgRatingRounded": 4}}, + ], + "pageInfo": {"hasNextPage": True, "endCursor": "c1"}, + "resultCount": 19, + }}}}) + with RMPClient(config=_cfg()) as client: + result = client.search_schools("queen") assert len(result.schools) == 2 + assert result.schools[0].id == "231" assert result.schools[0].name == "CUNY Queens College" assert result.schools[0].location == "Queens, NY" - assert result.schools[0].num_ratings == 551 + assert result.schools[0].num_ratings == 552 assert result.schools[0].overall_quality == 3.3 - assert result.schools[1].name == "St. John's University - Jamaica/Queens" - assert result.total == 8 + assert result.schools[1].id == "1466" + assert result.total == 19 assert result.has_next_page is True - assert result.next_cursor == "YXJyYXljb25uZWN0aW9uOjQ=" - - -class TestRMPClientSearchProfessors: - """search_professors fetches search page and parses __RELAY_STORE__.""" - - def test_returns_professors_from_search_page( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - store = { - "client:root": {"__id": "client:root", "newSearch": {"__ref": "client:root:newSearch"}}, - "client:root:newSearch": { - "__id": "client:root:newSearch", - "teachers(after:\"\",first:5,query:{\"text\":\"test\"})": {"__ref": "conn:teachers"}, - }, - "conn:teachers": { - "__typename": "TeacherSearchConnectionConnection", - "resultCount": 196, - "edges": {"__refs": ["edge:0", "edge:1"]}, - "pageInfo": {"__ref": "conn:pageInfo"}, - }, - "conn:pageInfo": {"hasNextPage": True, "endCursor": "YXJyYXljb25uZWN0aW9uOjQ="}, - "edge:0": {"node": {"__ref": "T1"}}, - "edge:1": {"node": {"__ref": "T2"}}, - "T1": { - "__typename": "Teacher", - "legacyId": 2707318, - "firstName": "Susan", - "lastName": "Testani", - "department": "Mathematics", - "avgRating": 3.1, - "numRatings": 9, - "wouldTakeAgainPercent": 44.44, - "avgDifficulty": 3, - "school": {"__ref": "S1"}, - }, - "S1": {"__typename": "School", "id": "S1", "name": "Montgomery County Community College (all)"}, - "T2": { - "__typename": "Teacher", - "legacyId": 3079576, - "firstName": "Kimberly", - "lastName": "Testa Fortier", - "department": "Education", - "avgRating": 5, - "numRatings": 1, - "wouldTakeAgainPercent": 100, - "avgDifficulty": 1, - "school": {"__ref": "S2"}, - }, - "S2": {"__typename": "School", "id": "S2", "name": "Purdue University Global"}, - } - html = _html_with_store(store) - config = RMPClientConfig( - search_professors_page_url="https://www.ratemyprofessors.com/search/professors/", - rate_limit_per_minute=1000, - ) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/search/professors?q=test", - text=html, - ) - with RMPClient(config=config) as client: - result = client.search_professors("test") + assert result.next_cursor == "c1" + + def test_empty_result(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {}}) + with RMPClient(config=_cfg()) as client: + result = client.search_schools("nonexistent") + assert len(result.schools) == 0 + assert result.has_next_page is False + + def test_sends_correct_variables(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {}}) + with RMPClient(config=_cfg()) as client: + client.search_schools("test", page_size=10, cursor="abc") + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["operationName"] == "SchoolSearchResultsPageQuery" + assert body["variables"]["query"] == {"text": "test"} + assert body["variables"]["count"] == 10 + assert body["variables"]["cursor"] == "abc" + + def test_multi_page_cursor_pagination(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"schools": { + "edges": [{"cursor": "c0", "node": {"legacyId": 1, "name": "School A", "city": "A", "state": "AA", "numRatings": 10, "avgRatingRounded": 3.5}}], + "pageInfo": {"hasNextPage": True, "endCursor": "c0"}, + "resultCount": 2, + }}}}) + httpx_mock.add_response(json={"data": {"search": {"schools": { + "edges": [{"cursor": "c1", "node": {"legacyId": 2, "name": "School B", "city": "B", "state": "BB", "numRatings": 20, "avgRatingRounded": 4.0}}], + "pageInfo": {"hasNextPage": False, "endCursor": "c1"}, + "resultCount": 2, + }}}}) + with RMPClient(config=_cfg()) as client: + p1 = client.search_schools("test", page_size=1) + assert p1.schools[0].name == "School A" + assert p1.has_next_page is True + p2 = client.search_schools("test", page_size=1, cursor=p1.next_cursor) + assert p2.schools[0].name == "School B" + assert p2.has_next_page is False + assert len(httpx_mock.get_requests()) == 2 + + +# --------------------------------------------------------------------------- +# searchProfessors +# --------------------------------------------------------------------------- + + +class TestSearchProfessors: + def test_returns_professors(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [ + {"cursor": "c0", "node": {"legacyId": 1927792, "firstName": "Selim", "lastName": "Tuncel", "avgRating": 2.9, "numRatings": 35, "wouldTakeAgainPercent": 41.9355, "avgDifficulty": 4.3, "department": "Mathematics", "school": {"legacyId": 1530, "name": "University of Washington", "city": "Seattle", "state": "WA"}}}, + {"cursor": "c1", "node": {"legacyId": 336794, "firstName": "Selim", "lastName": "Kuru", "avgRating": 3.6, "numRatings": 25, "wouldTakeAgainPercent": 60, "avgDifficulty": 2.5, "department": "Languages", "school": {"legacyId": 1530, "name": "University of Washington", "city": "Seattle", "state": "WA"}}}, + ], + "pageInfo": {"hasNextPage": True, "endCursor": "c1"}, + "resultCount": 89, + }}}}) + with RMPClient(config=_cfg()) as client: + result = client.search_professors("selim") assert len(result.professors) == 2 - assert result.professors[0].name == "Susan Testani" + assert result.professors[0].id == "1927792" + assert result.professors[0].name == "Selim Tuncel" assert result.professors[0].department == "Mathematics" - assert result.professors[0].overall_rating == 3.1 - assert result.professors[0].num_ratings == 9 + assert result.professors[0].overall_rating == 2.9 assert result.professors[0].school is not None - assert result.professors[0].school.name == "Montgomery County Community College (all)" - assert result.professors[1].name == "Kimberly Testa Fortier" - assert result.total == 196 + assert result.professors[0].school.name == "University of Washington" + assert result.professors[0].school.location == "Seattle, WA" + assert result.total == 89 assert result.has_next_page is True - assert result.next_cursor == "YXJyYXljb25uZWN0aW9uOjQ=" - - -class TestRMPClientGetCompareSchools: - """get_compare_schools fetches compare page and returns both schools.""" - - def test_returns_both_schools_from_compare_page( - self, httpx_mock: pytest_httpx.HTTPXMock - ) -> None: - store = { - "S1466": { - "__typename": "School", - "legacyId": 1466, - "name": "Queen's University at Kingston", - "location": "Kingston, ON", - "numRatings": 460, - "avgRatingRounded": 4, - "summary": {"__ref": "sum1466"}, - }, - "sum1466": { - "__typename": "SchoolSummary", - "schoolReputation": 4.42, - "schoolSafety": 4.2, - "schoolSatisfaction": 4.19, - "campusCondition": 4.17, - "socialActivities": 4.14, - "campusLocation": 4.03, - "clubAndEventActivities": 4.01, - "careerOpportunities": 4.0, - "internetSpeed": 3.72, - "foodQuality": 3.27, - }, - "S1491": { - "__typename": "School", - "legacyId": 1491, - "name": "Western University", - "location": "London, ON", - "numRatings": 889, - "avgRatingRounded": 3.9, - "summary": {"__ref": "sum1491"}, - }, - "sum1491": { - "__typename": "SchoolSummary", - "schoolReputation": 4.05, - "schoolSafety": 4.11, - "schoolSatisfaction": 4.07, - "campusCondition": 4.14, - "socialActivities": 4.14, - "campusLocation": 3.79, - "clubAndEventActivities": 4.05, - "careerOpportunities": 3.75, - "internetSpeed": 3.48, - "foodQuality": 3.44, + + def test_passes_school_id(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {}}) + with RMPClient(config=_cfg()) as client: + client.search_professors("test", school_id="1530") + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["variables"]["query"]["schoolID"] == "1530" + + def test_empty_result(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {}}) + with RMPClient(config=_cfg()) as client: + result = client.search_professors("zzzzz") + assert len(result.professors) == 0 + assert result.has_next_page is False + + def test_multi_page_cursor_pagination(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [{"cursor": "c0", "node": {"legacyId": 100, "firstName": "Alice", "lastName": "A", "avgRating": 4.0, "numRatings": 10, "department": "CS", "school": {"legacyId": 1, "name": "Uni", "city": "C", "state": "S"}}}], + "pageInfo": {"hasNextPage": True, "endCursor": "c0"}, + "resultCount": 2, + }}}}) + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [{"cursor": "c1", "node": {"legacyId": 200, "firstName": "Bob", "lastName": "B", "avgRating": 3.5, "numRatings": 5, "department": "Math", "school": {"legacyId": 1, "name": "Uni", "city": "C", "state": "S"}}}], + "pageInfo": {"hasNextPage": False, "endCursor": "c1"}, + "resultCount": 2, + }}}}) + with RMPClient(config=_cfg()) as client: + p1 = client.search_professors("test", page_size=1) + assert p1.professors[0].name == "Alice A" + p2 = client.search_professors("test", page_size=1, cursor=p1.next_cursor) + assert p2.professors[0].name == "Bob B" + assert p2.has_next_page is False + assert len(httpx_mock.get_requests()) == 2 + + +# --------------------------------------------------------------------------- +# getProfessor +# --------------------------------------------------------------------------- + + +class TestGetProfessor: + def test_returns_professor(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "legacyId": 2823076, "firstName": "Jane", "lastName": "Doe", + "department": "Computer Science", "avgRating": 4.5, "avgDifficulty": 2.1, + "numRatings": 42, "wouldTakeAgainPercent": 95.5, + "school": {"legacyId": 123, "name": "MIT", "city": "Cambridge", "state": "MA"}, + }}}) + with RMPClient(config=_cfg()) as client: + prof = client.get_professor("2823076") + assert prof.id == "2823076" + assert prof.name == "Jane Doe" + assert prof.department == "Computer Science" + assert prof.overall_rating == 4.5 + assert prof.level_of_difficulty == 2.1 + assert prof.num_ratings == 42 + assert prof.percent_take_again == 95.5 + assert prof.school is not None + assert prof.school.name == "MIT" + assert prof.school.location == "Cambridge, MA" + + def test_sends_base64_node_id(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": {"legacyId": 123, "lastName": "X"}}}) + with RMPClient(config=_cfg()) as client: + client.get_professor("123") + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["variables"]["id"] == base64.b64encode(b"Teacher-123").decode() + + def test_raises_parsing_error_when_null(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": None}}) + with RMPClient(config=_cfg()) as client: + with pytest.raises(ParsingError): + client.get_professor("missing") + + +# --------------------------------------------------------------------------- +# getSchool +# --------------------------------------------------------------------------- + + +class TestGetSchool: + def test_returns_school_with_summary(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "legacyId": 1466, "name": "Queen's University at Kingston", + "city": "Kingston", "state": "ON", "country": "Canada", + "numRatings": 460, "avgRatingRounded": 4, + "summary": { + "campusCondition": 4.17, "campusLocation": 4.03, + "careerOpportunities": 4.0, "clubAndEventActivities": 4.01, + "foodQuality": 3.27, "internetSpeed": 3.72, + "schoolReputation": 4.42, "schoolSafety": 4.2, + "schoolSatisfaction": 4.19, "socialActivities": 4.14, }, - } - html = _html_with_store(store) - config = RMPClientConfig( - compare_schools_page_url="https://www.ratemyprofessors.com/compare/schools/", - rate_limit_per_minute=1000, - ) - httpx_mock.add_response( - url="https://www.ratemyprofessors.com/compare/schools/1466/1491", - text=html, - ) - with RMPClient(config=config) as client: + }}}) + with RMPClient(config=_cfg()) as client: + school = client.get_school("1466") + assert school.id == "1466" + assert school.name == "Queen's University at Kingston" + assert school.location == "Kingston, ON, Canada" + assert school.overall_quality == 4 + assert school.num_ratings == 460 + assert school.reputation == 4.42 + assert school.safety == 4.2 + assert school.happiness == 4.19 + assert school.facilities == 4.17 + assert school.social == 4.14 + assert school.location_rating == 4.03 + assert school.clubs == 4.01 + assert school.opportunities == 4.0 + assert school.internet == 3.72 + assert school.food == 3.27 + + def test_raises_parsing_error_when_null(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": None}}) + with RMPClient(config=_cfg()) as client: + with pytest.raises(ParsingError): + client.get_school("999") + + def test_sends_base64_node_id(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": {"legacyId": 1466, "name": "Q"}}}) + with RMPClient(config=_cfg()) as client: + client.get_school("1466") + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["variables"]["id"] == base64.b64encode(b"School-1466").decode() + + +# --------------------------------------------------------------------------- +# getCompareSchools +# --------------------------------------------------------------------------- + + +class TestGetCompareSchools: + def test_returns_both_schools(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": {"legacyId": 1466, "name": "Queen's University", "city": "Kingston", "state": "ON", "numRatings": 460, "avgRatingRounded": 4}}}) + httpx_mock.add_response(json={"data": {"node": {"legacyId": 1491, "name": "Western University", "city": "London", "state": "ON", "numRatings": 889, "avgRatingRounded": 3.9}}}) + with RMPClient(config=_cfg()) as client: result = client.get_compare_schools("1466", "1491") - assert result.school_1.name == "Queen's University at Kingston" + assert result.school_1.name == "Queen's University" assert result.school_1.num_ratings == 460 - assert result.school_1.overall_quality == 4.0 - assert result.school_1.reputation == 4.42 assert result.school_2.name == "Western University" assert result.school_2.num_ratings == 889 - assert result.school_2.overall_quality == 3.9 - assert result.school_2.reputation == 4.05 + assert len(httpx_mock.get_requests()) == 2 + + +# --------------------------------------------------------------------------- +# getProfessorRatingsPage +# --------------------------------------------------------------------------- + + +def _ratings_page_response(comments: list[str], has_next: bool, end_cursor: str | None) -> dict: + return {"data": {"node": { + "__typename": "Teacher", "legacyId": 123, "lastName": "Smith", + "numRatings": 100, + "school": {"legacyId": 1, "name": "Uni", "city": "City", "state": "ST"}, + "ratings": { + "edges": [{"cursor": f"cursor_{i}", "node": { + "id": f"r{i}", "__typename": "Rating", + "comment": c, "helpfulRating": 4, "clarityRating": 5, + "difficultyRating": 3, "ratingTags": "Tough grader--Get ready to read", + "date": "2025-01-15 00:00:00 +0000 UTC", "class": "CS 101", + }} for i, c in enumerate(comments)], + "pageInfo": {"hasNextPage": has_next, "endCursor": end_cursor}, + }, + }}} + + +class TestGetProfessorRatingsPage: + def test_fetches_and_caches(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_ratings_page_response(["A", "B", "C"], False, None)) + with RMPClient(config=_cfg()) as client: + page = client.get_professor_ratings_page("123", page_size=2) + assert page.professor.id == "123" + assert page.professor.name == "Smith" + assert len(page.ratings) == 2 + assert page.ratings[0].comment == "A" + assert page.ratings[1].comment == "B" + assert page.has_next_page is True + assert page.next_cursor == "2" + assert len(httpx_mock.get_requests()) == 1 + + def test_serves_from_cache(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_ratings_page_response(["A", "B", "C", "D", "E"], False, None)) + with RMPClient(config=_cfg()) as client: + p1 = client.get_professor_ratings_page("123", page_size=2) + p2 = client.get_professor_ratings_page("123", cursor=p1.next_cursor, page_size=2) + p3 = client.get_professor_ratings_page("123", cursor=p2.next_cursor, page_size=2) + assert [r.comment for r in p1.ratings] == ["A", "B"] + assert [r.comment for r in p2.ratings] == ["C", "D"] + assert [r.comment for r in p3.ratings] == ["E"] + assert p3.has_next_page is False + assert len(httpx_mock.get_requests()) == 1 + + def test_pre_fetches_multiple_pages(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_ratings_page_response(["A", "B"], True, "cursor1")) + httpx_mock.add_response(json=_ratings_page_response(["C", "D"], False, None)) + with RMPClient(config=_cfg()) as client: + page = client.get_professor_ratings_page("123", page_size=10) + assert len(page.ratings) == 4 + assert [r.comment for r in page.ratings] == ["A", "B", "C", "D"] + assert len(httpx_mock.get_requests()) == 2 + + def test_parses_rating_tags(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_ratings_page_response(["A"], False, None)) + with RMPClient(config=_cfg()) as client: + page = client.get_professor_ratings_page("123", page_size=10) + assert page.ratings[0].tags == ["Tough grader", "Get ready to read"] + + def test_parses_quality_from_clarity(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_ratings_page_response(["A"], False, None)) + with RMPClient(config=_cfg()) as client: + page = client.get_professor_ratings_page("123", page_size=10) + assert page.ratings[0].quality == 5 + assert page.ratings[0].difficulty == 3 + assert page.ratings[0].course_raw == "CS 101" + + def test_repeated_first_page_from_cache(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_ratings_page_response(["A", "B"], False, None)) + with RMPClient(config=_cfg()) as client: + client.get_professor_ratings_page("123") + client.get_professor_ratings_page("123") + assert len(httpx_mock.get_requests()) == 1 + + +# --------------------------------------------------------------------------- +# getSchoolRatingsPage +# --------------------------------------------------------------------------- + + +def _school_ratings_response(count: int, has_next: bool, end_cursor: str | None) -> dict: + edges = [{"cursor": f"c{i}", "node": { + "id": f"sr{i}", "comment": f"Review {i}", + "date": "2025-12-15 22:29:19 +0000 UTC", + "reputationRating": 5, "locationRating": 4, "safetyRating": 5, + "socialRating": 4, "opportunitiesRating": 5, "happinessRating": 5, + "facilitiesRating": 5, "internetRating": 4, "foodRating": 3, "clubsRating": 5, + "thumbsUpTotal": 2, "thumbsDownTotal": 1, + }} for i in range(count)] + return {"data": {"node": { + "id": "U2Nob29sLTE0NjY=", "name": "Queen's University", + "city": "Kingston", "state": "ON", "country": "Canada", + "ratings": {"edges": edges, "pageInfo": {"hasNextPage": has_next, "endCursor": end_cursor}}, + }}} + + +class TestGetSchoolRatingsPage: + def test_fetches_and_caches(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_school_ratings_response(3, False, None)) + with RMPClient(config=_cfg()) as client: + page = client.get_school_ratings_page("1466", page_size=2) + assert page.school.name == "Queen's University" + assert len(page.ratings) == 2 + assert page.has_next_page is True + assert page.next_cursor == "2" + + def test_parses_category_ratings(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_school_ratings_response(1, False, None)) + with RMPClient(config=_cfg()) as client: + page = client.get_school_ratings_page("1466", page_size=10) + r = page.ratings[0] + assert r.category_ratings is not None + assert r.category_ratings["reputation"] == 5 + assert r.category_ratings["location"] == 4 + assert r.category_ratings["food"] == 3 + assert r.thumbs_up == 2 + assert r.thumbs_down == 1 + assert r.overall is not None and r.overall > 0 + + def test_serves_from_cache(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_school_ratings_response(5, False, None)) + with RMPClient(config=_cfg()) as client: + p1 = client.get_school_ratings_page("1466", page_size=2) + p2 = client.get_school_ratings_page("1466", cursor=p1.next_cursor, page_size=2) + assert len(p1.ratings) == 2 + assert len(p2.ratings) == 2 + assert len(httpx_mock.get_requests()) == 1 + + def test_pre_fetches_multiple_pages(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json=_school_ratings_response(2, True, "c1")) + httpx_mock.add_response(json=_school_ratings_response(2, False, None)) + with RMPClient(config=_cfg()) as client: + page = client.get_school_ratings_page("1466", page_size=10) + assert len(page.ratings) == 4 + assert len(httpx_mock.get_requests()) == 2 + + +# --------------------------------------------------------------------------- +# iterProfessorRatings +# --------------------------------------------------------------------------- + + +class TestIterProfessorRatings: + def test_yields_all(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "legacyId": 1, "lastName": "X", "numRatings": 3, + "ratings": {"edges": [ + {"cursor": "c0", "node": {"comment": "A", "date": "2025-03-01", "clarityRating": 5, "difficultyRating": 2, "class": "CS"}}, + {"cursor": "c1", "node": {"comment": "B", "date": "2025-02-01", "clarityRating": 4, "difficultyRating": 3, "class": "CS"}}, + {"cursor": "c2", "node": {"comment": "C", "date": "2025-01-01", "clarityRating": 3, "difficultyRating": 4, "class": "CS"}}, + ], "pageInfo": {"hasNextPage": False, "endCursor": None}}, + }}}) + with RMPClient(config=_cfg()) as client: + comments = [r.comment for r in client.iter_professor_ratings("1", page_size=10)] + assert comments == ["A", "B", "C"] + + def test_stops_at_since_date(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "legacyId": 1, "lastName": "X", "numRatings": 2, + "ratings": {"edges": [ + {"cursor": "c0", "node": {"comment": "New", "date": "2025-06-01", "clarityRating": 5, "difficultyRating": 2, "class": "CS"}}, + {"cursor": "c1", "node": {"comment": "Old", "date": "2024-01-01", "clarityRating": 4, "difficultyRating": 3, "class": "CS"}}, + ], "pageInfo": {"hasNextPage": False, "endCursor": None}}, + }}}) + with RMPClient(config=_cfg()) as client: + since = date(2025, 1, 1) + comments = [r.comment for r in client.iter_professor_ratings("1", since=since)] + assert comments == ["New"] + + def test_small_page_size_across_cache(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "legacyId": 1, "lastName": "X", "numRatings": 5, + "ratings": {"edges": [ + {"cursor": f"c{i}", "node": {"comment": f"R{i+1}", "date": f"2025-0{5-i}-01", "clarityRating": 5-i, "difficultyRating": i+1, "class": "CS"}} + for i in range(5) + ], "pageInfo": {"hasNextPage": False, "endCursor": None}}, + }}}) + with RMPClient(config=_cfg()) as client: + comments = [r.comment for r in client.iter_professor_ratings("1", page_size=2)] + assert comments == ["R1", "R2", "R3", "R4", "R5"] + assert len(httpx_mock.get_requests()) == 1 + + def test_multi_graphql_page_prefetch(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "legacyId": 1, "lastName": "X", "numRatings": 4, + "ratings": {"edges": [ + {"cursor": "c0", "node": {"comment": "P1A", "date": "2025-04-01", "clarityRating": 5, "difficultyRating": 2, "class": "CS"}}, + {"cursor": "c1", "node": {"comment": "P1B", "date": "2025-03-01", "clarityRating": 4, "difficultyRating": 3, "class": "CS"}}, + ], "pageInfo": {"hasNextPage": True, "endCursor": "c1"}}, + }}}) + httpx_mock.add_response(json={"data": {"node": { + "legacyId": 1, "lastName": "X", "numRatings": 4, + "ratings": {"edges": [ + {"cursor": "c2", "node": {"comment": "P2A", "date": "2025-02-01", "clarityRating": 3, "difficultyRating": 4, "class": "CS"}}, + {"cursor": "c3", "node": {"comment": "P2B", "date": "2025-01-01", "clarityRating": 2, "difficultyRating": 5, "class": "CS"}}, + ], "pageInfo": {"hasNextPage": False, "endCursor": None}}, + }}}) + with RMPClient(config=_cfg()) as client: + comments = [r.comment for r in client.iter_professor_ratings("1", page_size=2)] + assert comments == ["P1A", "P1B", "P2A", "P2B"] + assert len(httpx_mock.get_requests()) == 2 + + +# --------------------------------------------------------------------------- +# iterSchoolRatings +# --------------------------------------------------------------------------- + + +class TestIterSchoolRatings: + def test_yields_all(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "name": "Uni", "city": "C", "state": "S", + "ratings": {"edges": [ + {"cursor": "c0", "node": {"comment": "Good", "date": "2025-12-01", "reputationRating": 5, "thumbsUpTotal": 1, "thumbsDownTotal": 0}}, + {"cursor": "c1", "node": {"comment": "Fine", "date": "2025-11-01", "reputationRating": 4, "thumbsUpTotal": 0, "thumbsDownTotal": 0}}, + ], "pageInfo": {"hasNextPage": False, "endCursor": None}}, + }}}) + with RMPClient(config=_cfg()) as client: + comments = [r.comment for r in client.iter_school_ratings("1466")] + assert comments == ["Good", "Fine"] + + def test_stops_at_since_date(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"node": { + "name": "Uni", "city": "C", "state": "S", + "ratings": {"edges": [ + {"cursor": "c0", "node": {"comment": "Recent", "date": "2025-06-01", "reputationRating": 5, "thumbsUpTotal": 0, "thumbsDownTotal": 0}}, + {"cursor": "c1", "node": {"comment": "Old", "date": "2024-01-01", "reputationRating": 3, "thumbsUpTotal": 0, "thumbsDownTotal": 0}}, + ], "pageInfo": {"hasNextPage": False, "endCursor": None}}, + }}}) + with RMPClient(config=_cfg()) as client: + since = date(2025, 1, 1) + comments = [r.comment for r in client.iter_school_ratings("1466", since=since)] + assert comments == ["Recent"] + + +# --------------------------------------------------------------------------- +# listProfessorsForSchool +# --------------------------------------------------------------------------- + + +class TestListProfessorsForSchool: + def test_passes_school_id(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {}}) + with RMPClient(config=_cfg()) as client: + client.list_professors_for_school(1530) + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["variables"]["query"]["schoolID"] == "1530" + assert body["variables"]["query"]["text"] == "" + + def test_returns_professors(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [ + {"cursor": "c0", "node": {"legacyId": 10, "firstName": "John", "lastName": "Doe", "avgRating": 4.2, "numRatings": 30, "department": "CS", "school": {"legacyId": 1530, "name": "UW", "city": "Seattle", "state": "WA"}}}, + {"cursor": "c1", "node": {"legacyId": 20, "firstName": "Jane", "lastName": "Smith", "avgRating": 3.8, "numRatings": 15, "department": "Math", "school": {"legacyId": 1530, "name": "UW", "city": "Seattle", "state": "WA"}}}, + ], + "pageInfo": {"hasNextPage": False, "endCursor": "c1"}, + "resultCount": 2, + }}}}) + with RMPClient(config=_cfg()) as client: + result = client.list_professors_for_school(1530, page_size=10) + assert len(result.professors) == 2 + assert result.professors[0].name == "John Doe" + assert result.professors[1].name == "Jane Smith" + + def test_paginates_with_cursor(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [{"cursor": "c0", "node": {"legacyId": 10, "firstName": "A", "lastName": "Prof", "avgRating": 4.0, "numRatings": 5, "department": "CS", "school": {"legacyId": 1530, "name": "UW", "city": "Seattle", "state": "WA"}}}], + "pageInfo": {"hasNextPage": True, "endCursor": "c0"}, + "resultCount": 2, + }}}}) + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [{"cursor": "c1", "node": {"legacyId": 20, "firstName": "B", "lastName": "Prof", "avgRating": 3.5, "numRatings": 3, "department": "Math", "school": {"legacyId": 1530, "name": "UW", "city": "Seattle", "state": "WA"}}}], + "pageInfo": {"hasNextPage": False, "endCursor": "c1"}, + "resultCount": 2, + }}}}) + with RMPClient(config=_cfg()) as client: + p1 = client.list_professors_for_school(1530, page_size=1) + assert p1.professors[0].name == "A Prof" + p2 = client.list_professors_for_school(1530, page_size=1, cursor=p1.next_cursor) + assert p2.professors[0].name == "B Prof" + assert p2.has_next_page is False + + +# --------------------------------------------------------------------------- +# iterProfessorsForSchool +# --------------------------------------------------------------------------- + + +class TestIterProfessorsForSchool: + def test_single_page(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [ + {"cursor": "c0", "node": {"legacyId": 1, "firstName": "A", "lastName": "One", "avgRating": 4.0, "numRatings": 10, "department": "CS", "school": {"legacyId": 99, "name": "U", "city": "C", "state": "S"}}}, + {"cursor": "c1", "node": {"legacyId": 2, "firstName": "B", "lastName": "Two", "avgRating": 3.5, "numRatings": 5, "department": "Math", "school": {"legacyId": 99, "name": "U", "city": "C", "state": "S"}}}, + ], + "pageInfo": {"hasNextPage": False, "endCursor": "c1"}, + "resultCount": 2, + }}}}) + with RMPClient(config=_cfg()) as client: + names = [p.name for p in client.iter_professors_for_school(99)] + assert names == ["A One", "B Two"] + assert len(httpx_mock.get_requests()) == 1 + + def test_multi_page(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [{"cursor": "c0", "node": {"legacyId": 1, "firstName": "Page1", "lastName": "Prof", "avgRating": 4.0, "numRatings": 10, "department": "CS", "school": {"legacyId": 99, "name": "U", "city": "C", "state": "S"}}}], + "pageInfo": {"hasNextPage": True, "endCursor": "c0"}, + "resultCount": 3, + }}}}) + httpx_mock.add_response(json={"data": {"search": {"teachers": { + "edges": [ + {"cursor": "c1", "node": {"legacyId": 2, "firstName": "Page2A", "lastName": "Prof", "avgRating": 3.5, "numRatings": 5, "department": "Math", "school": {"legacyId": 99, "name": "U", "city": "C", "state": "S"}}}, + {"cursor": "c2", "node": {"legacyId": 3, "firstName": "Page2B", "lastName": "Prof", "avgRating": 4.5, "numRatings": 20, "department": "Bio", "school": {"legacyId": 99, "name": "U", "city": "C", "state": "S"}}}, + ], + "pageInfo": {"hasNextPage": False, "endCursor": "c2"}, + "resultCount": 3, + }}}}) + with RMPClient(config=_cfg()) as client: + names = [p.name for p in client.iter_professors_for_school(99, page_size=1)] + assert names == ["Page1 Prof", "Page2A Prof", "Page2B Prof"] + assert len(httpx_mock.get_requests()) == 2 + + def test_empty(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {}}) + with RMPClient(config=_cfg()) as client: + names = [p.name for p in client.iter_professors_for_school(99)] + assert names == [] + + +# --------------------------------------------------------------------------- +# rawQuery +# --------------------------------------------------------------------------- + + +class TestRawQuery: + def test_forwards_payload(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + httpx_mock.add_response(json={"data": {"custom": "result"}}) + with RMPClient(config=_cfg()) as client: + result = client.raw_query({"query": "{ viewer { id } }"}) + assert result["data"]["custom"] == "result" + + +# --------------------------------------------------------------------------- +# close +# --------------------------------------------------------------------------- + + +class TestClose: + def test_safe_to_call_multiple_times(self) -> None: + client = RMPClient(config=_cfg()) + client.close() + client.close() diff --git a/tests/test_config.py b/tests/test_config.py index a9af50a..3cbd822 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,12 +4,8 @@ from rmp_client.config import ( DEFAULT_BASE_URL, - DEFAULT_COMPARE_SCHOOLS_PAGE_URL, - DEFAULT_GET_HEADERS, - DEFAULT_PROFESSORS_PAGE_URL, - DEFAULT_SCHOOLS_PAGE_URL, - DEFAULT_SEARCH_PROFESSORS_PAGE_URL, - DEFAULT_SEARCH_SCHOOLS_PAGE_URL, + DEFAULT_HEADERS, + DEFAULT_USER_AGENT, RMPClientConfig, ) @@ -17,33 +13,14 @@ class TestRMPClientConfigDefaults: """Default values and structure.""" - def test_default_professors_page_url(self) -> None: - config = RMPClientConfig() - assert config.professors_page_url == DEFAULT_PROFESSORS_PAGE_URL - - def test_default_schools_page_url(self) -> None: - config = RMPClientConfig() - assert config.schools_page_url == DEFAULT_SCHOOLS_PAGE_URL - - def test_default_compare_schools_page_url(self) -> None: - config = RMPClientConfig() - assert config.compare_schools_page_url == DEFAULT_COMPARE_SCHOOLS_PAGE_URL - - def test_default_search_professors_page_url(self) -> None: - config = RMPClientConfig() - assert config.search_professors_page_url == DEFAULT_SEARCH_PROFESSORS_PAGE_URL - - def test_default_search_schools_page_url(self) -> None: - config = RMPClientConfig() - assert config.search_schools_page_url == DEFAULT_SEARCH_SCHOOLS_PAGE_URL - def test_default_base_url(self) -> None: config = RMPClientConfig() assert config.base_url == DEFAULT_BASE_URL + assert "graphql" in config.base_url - def test_default_headers_match_get_headers(self) -> None: + def test_default_headers(self) -> None: config = RMPClientConfig() - assert dict(config.default_headers) == dict(DEFAULT_GET_HEADERS) + assert dict(config.default_headers) == dict(DEFAULT_HEADERS) assert "User-Agent" in config.default_headers assert "Accept-Language" in config.default_headers @@ -55,4 +32,16 @@ def test_default_timeout_and_retries(self) -> None: def test_user_agent_default(self) -> None: config = RMPClientConfig() - assert config.user_agent == DEFAULT_GET_HEADERS["User-Agent"] + assert config.user_agent == DEFAULT_USER_AGENT + + def test_override_rate_limit(self) -> None: + config = RMPClientConfig(rate_limit_per_minute=30) + assert config.rate_limit_per_minute == 30 + + def test_override_base_url(self) -> None: + config = RMPClientConfig(base_url="https://custom.example.com/graphql") + assert config.base_url == "https://custom.example.com/graphql" + + def test_override_max_retries(self) -> None: + config = RMPClientConfig(max_retries=5) + assert config.max_retries == 5 diff --git a/tests/test_http.py b/tests/test_http.py index 7747f9c..2b43b05 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,4 +1,4 @@ -"""Tests for HttpClient (get_html, post_json) with mocked HTTP.""" +"""Tests for HttpClient (post_json) with mocked HTTP.""" from __future__ import annotations @@ -12,50 +12,6 @@ from rmp_client.http import HttpClient -class TestHttpClientGetHtml: - """get_html with pytest-httpx.""" - - def test_returns_text_on_200(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: - config = RMPClientConfig(rate_limit_per_minute=1000) - httpx_mock.add_response(url="https://example.com/page", text="Hello") - client = HttpClient(config) - try: - result = client.get_html("https://example.com/page") - assert result == "Hello" - finally: - client.close() - - def test_raises_http_error_on_404(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: - config = RMPClientConfig(rate_limit_per_minute=1000) - httpx_mock.add_response( - url="https://example.com/missing", - status_code=404, - text="Not Found", - ) - client = HttpClient(config) - try: - with pytest.raises(HttpError) as exc_info: - client.get_html("https://example.com/missing") - assert exc_info.value.status_code == 404 - assert "Not Found" in (exc_info.value.body or "") - finally: - client.close() - - def test_sends_default_headers(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: - config = RMPClientConfig(rate_limit_per_minute=1000) - httpx_mock.add_response(url="https://example.com/", text="ok") - client = HttpClient(config) - try: - client.get_html("https://example.com/") - requests = httpx_mock.get_requests() - assert len(requests) >= 1 - request = requests[0] - assert "User-Agent" in request.headers - assert "Accept-Language" in request.headers - finally: - client.close() - - class TestHttpClientPostJson: """post_json with pytest-httpx.""" @@ -122,3 +78,17 @@ def test_succeeds_after_5xx_retry(self, httpx_mock: pytest_httpx.HTTPXMock) -> N assert result == {"data": "ok"} finally: client.close() + + def test_sends_default_headers(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: + config = RMPClientConfig(rate_limit_per_minute=1000) + httpx_mock.add_response(url=config.base_url, json={"data": {}}) + client = HttpClient(config) + try: + client.post_json("", {"query": "..."}) + requests = httpx_mock.get_requests() + assert len(requests) >= 1 + request = requests[0] + assert "User-Agent" in request.headers + assert "Accept-Language" in request.headers + finally: + client.close() diff --git a/tests/test_relay_store.py b/tests/test_relay_store.py deleted file mode 100644 index f814cf9..0000000 --- a/tests/test_relay_store.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Tests for relay_store (__RELAY_STORE__ extraction and parsing).""" - -from __future__ import annotations - -import json - -import pytest - -from rmp_client.relay_store import ( - extract_relay_store, - get_all_rating_records, - get_professor_node, - get_professor_ratings_connection_page_info, - get_ratings_from_store, - get_school_node, - get_school_ratings_connection_page_info, - get_school_ratings_from_store, -) - - -def _html_with_store(store: dict) -> str: - """Wrap a dict as window.__RELAY_STORE__ in script tag.""" - return f'' - - -class TestExtractRelayStore: - """Extract __RELAY_STORE__ from HTML.""" - - def test_extracts_valid_store(self) -> None: - store = {"client:root": {"__id": "client:root"}, "node:1": {"__typename": "Professor", "id": "1"}} - html = _html_with_store(store) - result = extract_relay_store(html) - assert result == store - - def test_extracts_nested_json(self) -> None: - store = {"a": {"b": {"c": 1}}, "d": []} - html = _html_with_store(store) - result = extract_relay_store(html) - assert result["a"]["b"]["c"] == 1 - assert result["d"] == [] - - def test_raises_when_marker_missing(self) -> None: - html = "" - with pytest.raises(ValueError, match="__RELAY_STORE__ not found"): - extract_relay_store(html) - - def test_raises_on_invalid_json(self) -> None: - html = "" - with pytest.raises(json.JSONDecodeError): - extract_relay_store(html) - - def test_handles_strings_with_braces_inside(self) -> None: - store = {"key": "value with { and }"} - html = _html_with_store(store) - result = extract_relay_store(html) - assert result["key"] == "value with { and }" - - -class TestGetProfessorNode: - """Find Professor record in store by id or legacyId.""" - - def test_finds_by_id(self) -> None: - store = { - "node:abc": {"__typename": "Professor", "id": "abc", "name": "Jane"}, - } - node = get_professor_node(store, "abc") - assert node is not None - assert node["name"] == "Jane" - - def test_finds_by_legacy_id(self) -> None: - store = { - "node:slug123": {"__typename": "Professor", "legacyId": "slug123", "name": "Bob"}, - } - node = get_professor_node(store, "slug123") - assert node is not None - assert node["name"] == "Bob" - - def test_finds_teacher_by_legacy_id(self) -> None: - """RMP professor page uses __typename Teacher and legacyId (int) for URL id.""" - store = { - "VGVhY2hlci0yODIzMDc2": { - "__typename": "Teacher", - "legacyId": 2823076, - "firstName": "Erin", - "lastName": "Meger", - }, - } - node = get_professor_node(store, "2823076") - assert node is not None - assert node["firstName"] == "Erin" - assert node["lastName"] == "Meger" - - def test_returns_none_when_not_found(self) -> None: - store = { - "node:1": {"__typename": "School", "id": "1"}, - } - assert get_professor_node(store, "999") is None - - def test_ignores_non_professor_records(self) -> None: - store = { - "node:1": {"__typename": "Rating", "id": "1"}, - } - assert get_professor_node(store, "1") is None - - def test_match_with_str_id(self) -> None: - store = { - "node:42": {"__typename": "Professor", "id": 42, "name": "Num"}, - } - node = get_professor_node(store, "42") - assert node is not None - assert node["name"] == "Num" - - -class TestGetRatingsFromStore: - """Extract ratings from professor's ratings connection.""" - - def test_ratings_via_ref_connection(self) -> None: - store = { - "node:prof": { - "__typename": "Professor", - "id": "prof", - "ratings": {"__ref": "conn:prof"}, - }, - "conn:prof": { - "edges": [ - {"node": {"__ref": "node:r1"}}, - {"node": {"__ref": "node:r2"}}, - ], - }, - "node:r1": {"__typename": "Rating", "id": "r1", "comment": "Good"}, - "node:r2": {"__typename": "Rating", "id": "r2", "comment": "OK"}, - } - prof = store["node:prof"] - ratings = get_ratings_from_store(store, prof) - assert len(ratings) == 2 - assert ratings[0]["comment"] == "Good" - assert ratings[1]["comment"] == "OK" - - def test_ratings_inline_edges(self) -> None: - store = { - "node:prof": { - "__typename": "Professor", - "ratings": { - "edges": [ - {"node": {"__typename": "Rating", "comment": "A"}}, - {"node": {"__typename": "ProfessorRating", "comment": "B"}}, - ], - }, - }, - } - ratings = get_ratings_from_store(store, store["node:prof"]) - assert len(ratings) == 2 - assert ratings[0]["comment"] == "A" - assert ratings[1]["comment"] == "B" - - def test_empty_when_no_ratings_field(self) -> None: - prof = {"__typename": "Professor", "id": "1"} - assert get_ratings_from_store({}, prof) == [] - - def test_empty_when_edges_not_list(self) -> None: - prof = {"__typename": "Professor", "ratings": {"edges": "not-a-list"}} - assert get_ratings_from_store({}, prof) == [] - - def test_ratings_via_edges_refs_rmp_style(self) -> None: - """Real RMP store: ratings(first:5) is __ref, connection has edges.__refs.""" - store = { - "Teacher-2823076": { - "__typename": "Teacher", - "legacyId": 2823076, - "ratings(first:5)": {"__ref": "conn:2823076:ratings"}, - }, - "conn:2823076:ratings": { - "__typename": "RatingConnection", - "edges": {"__refs": ["edge:0", "edge:1"]}, - }, - "edge:0": {"node": {"__ref": "Rating-1"}}, - "edge:1": {"node": {"__ref": "Rating-2"}}, - "Rating-1": {"__typename": "Rating", "comment": "First", "clarityRating": 1}, - "Rating-2": {"__typename": "Rating", "comment": "Second", "clarityRating": 2}, - } - prof = store["Teacher-2823076"] - ratings = get_ratings_from_store(store, prof) - assert len(ratings) == 2 - assert ratings[0]["comment"] == "First" - assert ratings[1]["comment"] == "Second" - - def test_professor_ratings_page_info_when_present(self) -> None: - """When connection has pageInfo __ref, return hasNextPage and endCursor.""" - store = { - "Teacher-1": { - "__typename": "Teacher", - "legacyId": 1, - "ratings(first:5)": {"__ref": "conn:1:ratings"}, - }, - "conn:1:ratings": { - "edges": {"__refs": ["e1"]}, - "pageInfo": {"__ref": "conn:1:pageInfo"}, - }, - "conn:1:pageInfo": {"hasNextPage": True, "endCursor": "YXJyYXljb25uZWN0aW9uOjQ="}, - "e1": {"node": {"__ref": "Rating-1"}}, - "Rating-1": {"__typename": "Rating", "comment": "One"}, - } - prof = store["Teacher-1"] - info = get_professor_ratings_connection_page_info(store, prof) - assert info is not None - assert info.get("hasNextPage") is True - assert info.get("endCursor") == "YXJyYXljb25uZWN0aW9uOjQ=" - - def test_professor_ratings_page_info_when_absent(self) -> None: - """When connection has no pageInfo, return None.""" - store = { - "Teacher-2823076": { - "__typename": "Teacher", - "legacyId": 2823076, - "ratings(first:5)": {"__ref": "conn:2823076:ratings"}, - }, - "conn:2823076:ratings": { - "__typename": "RatingConnection", - "edges": {"__refs": ["edge:0", "edge:1"]}, - }, - } - prof = store["Teacher-2823076"] - info = get_professor_ratings_connection_page_info(store, prof) - assert info is None - - -class TestGetSchoolNode: - """Find School record in store by legacyId or id.""" - - def test_finds_school_by_legacy_id(self) -> None: - """RMP school page uses legacyId (e.g. 1466) for URL /school/1466.""" - store = { - "U2Nob29sLTE0NjY=": { - "__typename": "School", - "legacyId": 1466, - "name": "Queen's University at Kingston", - "location": "Kingston, ON", - }, - } - node = get_school_node(store, "1466") - assert node is not None - assert node["name"] == "Queen's University at Kingston" - assert node["legacyId"] == 1466 - - -class TestGetSchoolRatingsFromStore: - """Extract school ratings from store (CampusRatingConnection, edges.__refs).""" - - def test_school_ratings_via_edges_refs_rmp_style(self) -> None: - """Real RMP store: ratings(first:5) is __ref, connection has edges.__refs, SchoolRating nodes.""" - store = { - "School-1466": { - "__typename": "School", - "legacyId": 1466, - "ratings(first:5)": {"__ref": "conn:1466:ratings"}, - }, - "conn:1466:ratings": { - "__typename": "CampusRatingConnection", - "edges": {"__refs": ["edge:s0", "edge:s1"]}, - }, - "edge:s0": {"node": {"__ref": "SchoolRating-1"}}, - "edge:s1": {"node": {"__ref": "SchoolRating-2"}}, - "SchoolRating-1": {"__typename": "SchoolRating", "comment": "Changed my life.", "reputationRating": 5}, - "SchoolRating-2": {"__typename": "SchoolRating", "comment": "Love it here.", "reputationRating": 5}, - } - school = store["School-1466"] - ratings = get_school_ratings_from_store(store, school) - assert len(ratings) == 2 - assert ratings[0]["comment"] == "Changed my life." - assert ratings[1]["comment"] == "Love it here." - - def test_school_ratings_page_info_when_present(self) -> None: - store = { - "School-1466": { - "__typename": "School", - "legacyId": 1466, - "ratings(first:5)": {"__ref": "conn:1466:ratings"}, - }, - "conn:1466:ratings": { - "edges": {"__refs": ["edge:s0"]}, - "pageInfo": {"__ref": "conn:1466:pageInfo"}, - }, - "conn:1466:pageInfo": {"hasNextPage": True, "endCursor": "c2Nob29sOjE="}, - "edge:s0": {"node": {"__ref": "SchoolRating-1"}}, - "SchoolRating-1": {"__typename": "SchoolRating", "comment": "Great"}, - } - school = store["School-1466"] - info = get_school_ratings_connection_page_info(store, school) - assert info is not None - assert info.get("hasNextPage") is True - assert info.get("endCursor") == "c2Nob29sOjE=" - - -class TestGetAllRatingRecords: - """Fallback: collect all rating-like records from store.""" - - def test_collects_rating_typenames(self) -> None: - store = { - "node:r1": {"__typename": "Rating", "id": "r1"}, - "node:r2": {"__typename": "ProfessorRating", "id": "r2"}, - "node:r3": {"__typename": "Review", "id": "r3"}, - "node:p": {"__typename": "Professor", "id": "p"}, - } - records = get_all_rating_records(store) - assert len(records) == 3 - typenames = {r["__typename"] for r in records} - assert typenames == {"Rating", "ProfessorRating", "Review"} - - def test_ignores_non_dict_values(self) -> None: - store = {"node:r1": "string", "node:r2": {"__typename": "Rating"}} - records = get_all_rating_records(store) - assert len(records) == 1