From a5e8dd26be78fb53414193c5e671130db3dd9a29 Mon Sep 17 00:00:00 2001 From: Amaan Javed Date: Tue, 17 Mar 2026 00:01:35 -0400 Subject: [PATCH 1/4] fix versioning --- README.md | 2 +- pyproject.toml | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a35e39f..3ca94e8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pip install ratemyprofessors-client ## Available Functions -Create a client and call any of these methods. See the [full docs](docs/) for parameters, return types, and examples. +Create a client and call any of these methods. See the [full docs](https://amaanjaved1.github.io/Rate-My-Professors-API-Client-Python/) for parameters, return types, and examples. ```python from rmp_client import RMPClient diff --git a/pyproject.toml b/pyproject.toml index b4e7551..0bc5526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,17 +4,17 @@ build-backend = "hatchling.build" [project] name = "ratemyprofessors-client" -version = "0.1.0" -description = "A Python API Client for RateMyProfessors." +version = "2.0.0" +description = "Typed, retrying, rate-limited unofficial Python client for the RateMyProfessors GraphQL API." readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } authors = [ { name = "Amaan", email = "amaanjaved2004@gmail.com" }, ] -keywords = ["ratemyprofessors", "api-client", "scraping", "ratings"] +keywords = ["ratemyprofessors", "api-client", "graphql", "ratings"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -42,7 +42,8 @@ dev = [ ] [project.urls] -Repository = "https://github.com/amaanjaved1/Rate-My-Professors-API-Client-Python" +Repository = "https://github.com/amaanjaved1/Rate-My-Professors-API-Client" +Documentation = "https://amaanjaved1.github.io/Rate-My-Professors-API-Client/" [tool.hatch.build.targets.wheel] packages = ["src/rmp_client"] From 4c9db0d7919c84282aa5657917432e1d2aaa4830 Mon Sep 17 00:00:00 2001 From: Amaan Javed Date: Tue, 17 Mar 2026 00:02:26 -0400 Subject: [PATCH 2/4] get rid of tests for the extras --- tests/test_extras.py | 104 ------------------------------------------- 1 file changed, 104 deletions(-) delete mode 100644 tests/test_extras.py diff --git a/tests/test_extras.py b/tests/test_extras.py deleted file mode 100644 index 51a5710..0000000 --- a/tests/test_extras.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Tests for rmp_client.extras (dedupe, course_codes, sentiment).""" - -from __future__ import annotations - -import pytest - -from rmp_client.extras.course_codes import build_course_mapping, clean_course_label -from rmp_client.extras.dedupe import is_valid_comment, normalize_comment - - -class TestNormalizeComment: - """normalize_comment lowercases and collapses whitespace.""" - - def test_lowercase_and_collapse(self) -> None: - raw = " Hello World! " - assert normalize_comment(raw) == "hello world!" - - def test_empty_after_strip(self) -> None: - assert normalize_comment(" ") == "" - - def test_single_word(self) -> None: - assert normalize_comment("GREAT") == "great" - - def test_newlines_collapsed(self) -> None: - assert normalize_comment("a\nb\nc") == "a b c" - - def test_unicode_preserved(self) -> None: - assert normalize_comment(" Café ") == "café" - - -class TestIsValidComment: - """is_valid_comment filters by length.""" - - def test_valid_min_len_default(self) -> None: - assert is_valid_comment("this is ten!!") is True - assert is_valid_comment("short") is False - - def test_empty_false(self) -> None: - assert is_valid_comment("") is False - assert is_valid_comment(" ") is False - - def test_custom_min_len(self) -> None: - assert is_valid_comment("five!", min_len=5) is True - assert is_valid_comment("four", min_len=5) is False - - def test_exactly_min_len(self) -> None: - assert is_valid_comment("12345", min_len=5) is True - - -class TestCleanCourseLabel: - """clean_course_label removes (n) and collapses whitespace.""" - - def test_removes_count_parens(self) -> None: - assert clean_course_label("MATH 101 (12)") == "MATH 101" - assert clean_course_label("CS 50 (3)") == "CS 50" - - def test_collapses_whitespace(self) -> None: - assert clean_course_label(" ANAT 215 ") == "ANAT 215" - - def test_no_parens_unchanged_except_trim(self) -> None: - assert clean_course_label("MATH 101") == "MATH 101" - - -class TestBuildCourseMapping: - """build_course_mapping maps scraped labels to valid course codes.""" - - def test_exact_match_nospace(self) -> None: - valid = ["MATH 101", "ANAT 215"] - scraped = ["MATH 101", "math 101", "ANAT 215"] - mapping = build_course_mapping(scraped, valid) - assert mapping["MATH 101"] == {"MATH 101"} - assert mapping["math 101"] == {"MATH 101"} - assert mapping["ANAT 215"] == {"ANAT 215"} - - def test_prefix_number_match(self) -> None: - valid = ["ANAT 215"] - scraped = ["ANAT215", "anat 215"] - mapping = build_course_mapping(scraped, valid) - assert mapping.get("ANAT215") == {"ANAT 215"} - assert mapping.get("anat 215") == {"ANAT 215"} - - def test_unknown_returns_none(self) -> None: - valid = ["MATH 101"] - scraped = ["UNKNOWN 999"] - mapping = build_course_mapping(scraped, valid) - assert mapping["UNKNOWN 999"] is None - - def test_empty_valid(self) -> None: - mapping = build_course_mapping(["MATH 101"], []) - assert mapping["MATH 101"] is None - - -class TestSentimentExtras: - """analyze_sentiment requires textblob; test error when missing.""" - - def test_analyze_sentiment_raises_without_textblob(self) -> None: - from rmp_client.extras import sentiment as sentiment_mod - - if sentiment_mod.TextBlob is not None: - pytest.skip("textblob is installed; cannot test missing dependency") - from rmp_client.extras.sentiment import analyze_sentiment - - with pytest.raises(RuntimeError, match="textblob"): - analyze_sentiment("Great professor!") From 0278618613de632b794c984918d3c975df239b52 Mon Sep 17 00:00:00 2001 From: Amaan Javed Date: Tue, 17 Mar 2026 00:07:40 -0400 Subject: [PATCH 3/4] Update tests to use real API --- src/rmp_client/client.py | 4 +- src/rmp_client/queries.py | 1 + tests/test_client.py | 807 ++++++++++++++------------------------ 3 files changed, 305 insertions(+), 507 deletions(-) diff --git a/src/rmp_client/client.py b/src/rmp_client/client.py index ad7c1f0..90824b1 100644 --- a/src/rmp_client/client.py +++ b/src/rmp_client/client.py @@ -197,7 +197,7 @@ def search_professors( """Search professors by name (TeacherSearchResultsPageQuery).""" query_var: Dict[str, Any] = {"text": query} if school_id is not None: - query_var["schoolID"] = school_id + query_var["schoolID"] = _school_node_id(school_id) data = self.raw_query({ "operationName": "TeacherSearchResultsPageQuery", @@ -250,7 +250,7 @@ def list_professors_for_school( ) -> ProfessorSearchResult: """List professors at a school. Wrapper around :meth:`search_professors`.""" return self.search_professors( - query=query or "", + query=query if query else " ", school_id=str(school_id), page_size=page_size, cursor=cursor, diff --git a/src/rmp_client/queries.py b/src/rmp_client/queries.py index e8a8579..171c929 100644 --- a/src/rmp_client/queries.py +++ b/src/rmp_client/queries.py @@ -67,6 +67,7 @@ node(id: $id) { ... on School { id + legacyId name city state diff --git a/tests/test_client.py b/tests/test_client.py index 09a5c2a..ae4c52a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,608 +1,405 @@ -"""Tests for RMPClient with mocked GraphQL responses via pytest-httpx.""" +"""Integration tests for RMPClient against the live RateMyProfessors GraphQL API. + +These tests make real HTTP requests. Run with a reasonable rate limit to +avoid hammering the API. Data assertions are kept flexible since live +data (num_ratings, etc.) changes over time. +""" from __future__ import annotations -import base64 -import json from datetime import date import pytest -import pytest_httpx from rmp_client import RMPClient from rmp_client.config import RMPClientConfig -from rmp_client.errors import ParsingError - +from rmp_client.errors import ParsingError, RMPAPIError -def _cfg() -> RMPClientConfig: - return RMPClientConfig(rate_limit_per_minute=10000) +SCHOOL_QUEENS = "1466" +SCHOOL_WESTERN = "1491" +SCHOOL_UW = "1530" +PROFESSOR_ID = "2823076" -def _gql(data: dict) -> str: - return json.dumps({"data": data}) +@pytest.fixture(scope="module") +def client() -> RMPClient: + cfg = RMPClientConfig(rate_limit_per_minute=30) + c = RMPClient(config=cfg) + yield c + c.close() # --------------------------------------------------------------------------- -# searchSchools +# search_schools # --------------------------------------------------------------------------- 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 == 552 - assert result.schools[0].overall_quality == 3.3 - assert result.schools[1].id == "1466" - assert result.total == 19 - assert result.has_next_page is True - 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") + def test_returns_results(self, client: RMPClient) -> None: + result = client.search_schools("queens") + assert len(result.schools) > 0 + school = result.schools[0] + assert school.id + assert school.name + assert school.location + + def test_pagination_fields(self, client: RMPClient) -> None: + result = client.search_schools("university", page_size=2) + assert result.page_size <= 2 + assert isinstance(result.has_next_page, bool) + if result.has_next_page: + assert result.next_cursor is not None + + def test_multi_page_cursor_pagination(self, client: RMPClient) -> None: + p1 = client.search_schools("university", page_size=2) + assert len(p1.schools) > 0 + assert p1.has_next_page is True + assert p1.next_cursor is not None + + p2 = client.search_schools("university", page_size=2, cursor=p1.next_cursor) + assert len(p2.schools) > 0 + p1_ids = {s.id for s in p1.schools} + p2_ids = {s.id for s in p2.schools} + assert p1_ids.isdisjoint(p2_ids), "Page 2 should not repeat page 1 schools" + + def test_empty_search(self, client: RMPClient) -> None: + result = client.search_schools("zzzxxx999qqq") 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 +# search_professors # --------------------------------------------------------------------------- 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].id == "1927792" - assert result.professors[0].name == "Selim Tuncel" - assert result.professors[0].department == "Mathematics" - assert result.professors[0].overall_rating == 2.9 - assert result.professors[0].school is not None - 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 - - 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") + def test_returns_results(self, client: RMPClient) -> None: + result = client.search_professors("smith") + assert len(result.professors) > 0 + prof = result.professors[0] + assert prof.id + assert prof.name + + def test_school_id_filter(self, client: RMPClient) -> None: + result = client.search_professors("smith", school_id=SCHOOL_UW) + assert len(result.professors) > 0 + for prof in result.professors: + if prof.school: + assert prof.school.id == SCHOOL_UW + + def test_multi_page_cursor_pagination(self, client: RMPClient) -> None: + p1 = client.search_professors("smith", page_size=2) + assert len(p1.professors) > 0 + assert p1.has_next_page is True + assert p1.next_cursor is not None + + p2 = client.search_professors("smith", page_size=2, cursor=p1.next_cursor) + assert len(p2.professors) > 0 + p1_ids = {p.id for p in p1.professors} + p2_ids = {p.id for p in p2.professors} + assert p1_ids.isdisjoint(p2_ids), "Page 2 should not repeat page 1 professors" + + def test_empty_search(self, client: RMPClient) -> None: + result = client.search_professors("zzzxxx999qqq") 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 +# get_professor # --------------------------------------------------------------------------- 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 + def test_returns_professor(self, client: RMPClient) -> None: + prof = client.get_professor(PROFESSOR_ID) + assert prof.id == PROFESSOR_ID + assert prof.name + assert len(prof.name) > 0 + assert prof.department is not None + assert prof.overall_rating is not None + assert prof.num_ratings is not None and prof.num_ratings > 0 assert prof.school is not None - assert prof.school.name == "MIT" - assert prof.school.location == "Cambridge, MA" + assert prof.school.name - 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_professor_has_numeric_fields(self, client: RMPClient) -> None: + prof = client.get_professor(PROFESSOR_ID) + assert isinstance(prof.overall_rating, float) + assert isinstance(prof.level_of_difficulty, float) + assert isinstance(prof.num_ratings, int) - 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") + def test_raises_error_for_invalid_id(self, client: RMPClient) -> None: + with pytest.raises((ParsingError, RMPAPIError)): + client.get_professor("999999999") # --------------------------------------------------------------------------- -# getSchool +# get_school # --------------------------------------------------------------------------- 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, - }, - }}}) - 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() + def test_returns_school_with_summary(self, client: RMPClient) -> None: + school = client.get_school(SCHOOL_QUEENS) + assert school.id == SCHOOL_QUEENS + assert "Queen" in school.name + assert school.location is not None + assert school.overall_quality is not None + assert school.num_ratings is not None and school.num_ratings > 0 + + def test_has_category_ratings(self, client: RMPClient) -> None: + school = client.get_school(SCHOOL_QUEENS) + assert school.reputation is not None + assert school.safety is not None + assert school.happiness is not None + assert school.facilities is not None + assert school.social is not None + assert school.food is not None + assert school.internet is not None + assert school.clubs is not None + assert school.opportunities is not None + assert school.location_rating is not None + + def test_raises_error_for_invalid_id(self, client: RMPClient) -> None: + with pytest.raises((ParsingError, RMPAPIError)): + client.get_school("999999999") # --------------------------------------------------------------------------- -# getCompareSchools +# get_compare_schools # --------------------------------------------------------------------------- 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" - assert result.school_1.num_ratings == 460 - assert result.school_2.name == "Western University" - assert result.school_2.num_ratings == 889 - assert len(httpx_mock.get_requests()) == 2 + def test_returns_both_schools(self, client: RMPClient) -> None: + result = client.get_compare_schools(SCHOOL_QUEENS, SCHOOL_WESTERN) + assert result.school_1.id == SCHOOL_QUEENS + assert result.school_2.id == SCHOOL_WESTERN + assert result.school_1.name != result.school_2.name + assert result.school_1.num_ratings is not None + assert result.school_2.num_ratings is not None # --------------------------------------------------------------------------- -# getProfessorRatingsPage +# get_professor_ratings_page (cached pagination) # --------------------------------------------------------------------------- -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 + def test_first_page(self, client: RMPClient) -> None: + page = client.get_professor_ratings_page(PROFESSOR_ID, page_size=5) + assert page.professor.id == PROFESSOR_ID + assert page.professor.name + assert len(page.ratings) > 0 + assert len(page.ratings) <= 5 + for r in page.ratings: + assert r.date is not None + assert isinstance(r.comment, str) + + def test_load_more_from_cache(self, client: RMPClient) -> None: + p1 = client.get_professor_ratings_page(PROFESSOR_ID, page_size=3) + assert p1.has_next_page is True + assert p1.next_cursor is not None + + p2 = client.get_professor_ratings_page( + PROFESSOR_ID, cursor=p1.next_cursor, page_size=3 + ) + assert len(p2.ratings) > 0 + p1_comments = {r.comment for r in p1.ratings} + p2_comments = {r.comment for r in p2.ratings} + assert p1_comments.isdisjoint(p2_comments), "Page 2 should not repeat page 1 ratings" + + def test_rating_fields_populated(self, client: RMPClient) -> None: + page = client.get_professor_ratings_page(PROFESSOR_ID, page_size=5) + for r in page.ratings: + assert isinstance(r.date, date) + assert r.quality is None or isinstance(r.quality, float) + assert r.difficulty is None or isinstance(r.difficulty, float) + assert isinstance(r.tags, list) + + def test_multiple_show_mores(self, client: RMPClient) -> None: + all_comments: list[str] = [] + cursor = None + pages_fetched = 0 + while pages_fetched < 4: + page = client.get_professor_ratings_page( + PROFESSOR_ID, cursor=cursor, page_size=5 + ) + all_comments.extend(r.comment for r in page.ratings) + pages_fetched += 1 + if not page.has_next_page: + break + cursor = page.next_cursor + + assert len(all_comments) > 5, "Should have fetched more than one page worth" + assert len(all_comments) == len(set(all_comments)), "No duplicate comments" # --------------------------------------------------------------------------- -# getSchoolRatingsPage +# get_school_ratings_page (cached pagination) # --------------------------------------------------------------------------- -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 + def test_first_page(self, client: RMPClient) -> None: + page = client.get_school_ratings_page(SCHOOL_QUEENS, page_size=5) + assert page.school.name + assert len(page.ratings) > 0 + assert len(page.ratings) <= 5 + + def test_has_category_ratings(self, client: RMPClient) -> None: + page = client.get_school_ratings_page(SCHOOL_QUEENS, page_size=5) + for r in page.ratings: + assert isinstance(r.date, date) + assert isinstance(r.comment, str) + if r.category_ratings: + assert isinstance(r.category_ratings, dict) + assert len(r.category_ratings) > 0 + + def test_load_more_from_cache(self, client: RMPClient) -> None: + p1 = client.get_school_ratings_page(SCHOOL_QUEENS, page_size=3) + if not p1.has_next_page: + pytest.skip("School does not have enough ratings for multi-page test") + + p2 = client.get_school_ratings_page( + SCHOOL_QUEENS, cursor=p1.next_cursor, page_size=3 + ) + assert len(p2.ratings) > 0 + + def test_overall_score_computed(self, client: RMPClient) -> None: + page = client.get_school_ratings_page(SCHOOL_QUEENS, page_size=5) + for r in page.ratings: + if r.category_ratings and len(r.category_ratings) > 0: + assert r.overall is not None + assert r.overall > 0 # --------------------------------------------------------------------------- -# iterProfessorRatings +# iter_professor_ratings # --------------------------------------------------------------------------- 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 + def test_yields_ratings(self, client: RMPClient) -> None: + ratings = list(client.iter_professor_ratings(PROFESSOR_ID, page_size=5)) + assert len(ratings) > 0 + for r in ratings: + assert isinstance(r.date, date) + assert isinstance(r.comment, str) + + def test_since_date_stops_early(self, client: RMPClient) -> None: + cutoff = date(2025, 1, 1) + ratings = list( + client.iter_professor_ratings(PROFESSOR_ID, page_size=10, since=cutoff) + ) + for r in ratings: + assert r.date > cutoff + + def test_collects_all_ratings(self, client: RMPClient) -> None: + all_ratings = list(client.iter_professor_ratings(PROFESSOR_ID, page_size=20)) + assert len(all_ratings) > 0 + dates = [r.date for r in all_ratings] + assert dates == sorted(dates, reverse=True) or len(dates) <= 1, \ + "Ratings should be in reverse chronological order" # --------------------------------------------------------------------------- -# iterSchoolRatings +# iter_school_ratings # --------------------------------------------------------------------------- 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"] + def test_yields_ratings(self, client: RMPClient) -> None: + ratings = list(client.iter_school_ratings(SCHOOL_QUEENS, page_size=5)) + assert len(ratings) > 0 + for r in ratings: + assert isinstance(r.date, date) + assert isinstance(r.comment, str) + + def test_since_date_stops_early(self, client: RMPClient) -> None: + cutoff = date(2025, 1, 1) + ratings = list( + client.iter_school_ratings(SCHOOL_QUEENS, page_size=10, since=cutoff) + ) + for r in ratings: + assert r.date > cutoff # --------------------------------------------------------------------------- -# listProfessorsForSchool +# list_professors_for_school # --------------------------------------------------------------------------- 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 + def test_returns_professors(self, client: RMPClient) -> None: + result = client.list_professors_for_school(int(SCHOOL_UW), page_size=5) + assert len(result.professors) > 0 + for prof in result.professors: + assert prof.id + assert prof.name + + def test_cursor_pagination(self, client: RMPClient) -> None: + p1 = client.list_professors_for_school(int(SCHOOL_UW), page_size=2) + assert len(p1.professors) > 0 + assert p1.has_next_page is True + + p2 = client.list_professors_for_school( + int(SCHOOL_UW), page_size=2, cursor=p1.next_cursor + ) + assert len(p2.professors) > 0 + p1_ids = {p.id for p in p1.professors} + p2_ids = {p.id for p in p2.professors} + assert p1_ids.isdisjoint(p2_ids) # --------------------------------------------------------------------------- -# iterProfessorsForSchool +# iter_professors_for_school # --------------------------------------------------------------------------- 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 == [] + def test_yields_professors(self, client: RMPClient) -> None: + profs = [] + for prof in client.iter_professors_for_school(int(SCHOOL_UW), page_size=5): + profs.append(prof) + if len(profs) >= 10: + break + assert len(profs) >= 5 + for prof in profs: + assert prof.id + assert prof.name + + def test_multi_page(self, client: RMPClient) -> None: + profs = [] + for prof in client.iter_professors_for_school(int(SCHOOL_UW), page_size=2): + profs.append(prof) + if len(profs) >= 5: + break + assert len(profs) >= 3, "Should iterate across multiple pages" + ids = [p.id for p in profs] + assert len(ids) == len(set(ids)), "No duplicate professors" # --------------------------------------------------------------------------- -# rawQuery +# raw_query # --------------------------------------------------------------------------- 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" + def test_sends_query_and_gets_response(self, client: RMPClient) -> None: + from rmp_client.queries import GET_SCHOOL_QUERY + import base64 + + node_id = base64.b64encode(f"School-{SCHOOL_QUEENS}".encode()).decode() + result = client.raw_query({ + "operationName": "GetSchoolQuery", + "query": GET_SCHOOL_QUERY, + "variables": {"id": node_id}, + }) + assert "data" in result + assert result["data"]["node"] is not None + assert "Queen" in result["data"]["node"]["name"] # --------------------------------------------------------------------------- @@ -612,6 +409,6 @@ def test_forwards_payload(self, httpx_mock: pytest_httpx.HTTPXMock) -> None: class TestClose: def test_safe_to_call_multiple_times(self) -> None: - client = RMPClient(config=_cfg()) - client.close() - client.close() + c = RMPClient() + c.close() + c.close() From fc8cf2bd72a58f2e32618801927fb4cdfcf4293f Mon Sep 17 00:00:00 2001 From: Amaan Javed Date: Tue, 17 Mar 2026 00:12:10 -0400 Subject: [PATCH 4/4] Update tests to use live RMP API --- src/rmp_client/client.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/rmp_client/client.py b/src/rmp_client/client.py index 90824b1..7ec955c 100644 --- a/src/rmp_client/client.py +++ b/src/rmp_client/client.py @@ -13,7 +13,7 @@ from typing import Any, Dict, Iterator, List, Mapping, Optional, Tuple from .config import RMPClientConfig -from .errors import ParsingError +from .errors import HttpError, ParsingError, RetryError, RMPAPIError from .http import HttpClient, HttpClientContext from .models import ( CompareSchoolsResult, @@ -351,9 +351,12 @@ def get_professor_ratings_page( 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 - ) + try: + nxt = self._fetch_professor_ratings_page( + professor_id, after=after, first=100, course_filter=course_filter + ) + except (RMPAPIError, HttpError, RetryError): + break all_ratings.extend(nxt.ratings) after = nxt.next_cursor if nxt.has_next_page else None @@ -459,7 +462,10 @@ def get_school_ratings_page( 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) + try: + nxt = self._fetch_school_ratings_page(school_id, after=after, first=100) + except (RMPAPIError, HttpError, RetryError): + break all_ratings.extend(nxt.ratings) after = nxt.next_cursor if nxt.has_next_page else None