- Professor Ratings (Paginated, Cached) #
+ Professor Ratings (Paginated) #
with RMPClient() as client:
page = client.get_professor_ratings_page("2823076", page_size=10)
print(f"Professor: {page.professor.name}")
@@ -103,7 +103,7 @@ Iterate All Professor Ratings
- School Ratings (Paginated, Cached) #
+ School Ratings (Paginated) #
with RMPClient() as client:
page = client.get_school_ratings_page("1466", page_size=10)
for rating in page.ratings:
diff --git a/pyproject.toml b/pyproject.toml
index 9d49568..308ef44 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratemyprofessors-client"
-version = "2.1.2"
+version = "3.0.0"
description = "Typed, retrying, rate-limited unofficial Python client for the RateMyProfessors GraphQL API."
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/rmp_client/__init__.py b/src/rmp_client/__init__.py
index d1e638d..de117a2 100644
--- a/src/rmp_client/__init__.py
+++ b/src/rmp_client/__init__.py
@@ -6,7 +6,6 @@
ConfigurationError,
HttpError,
ParsingError,
- RateLimitError,
RetryError,
RMPAPIError,
RMPError,
@@ -30,7 +29,6 @@
"ConfigurationError",
"HttpError",
"ParsingError",
- "RateLimitError",
"RetryError",
"RMPAPIError",
"TokenBucket",
diff --git a/src/rmp_client/client.py b/src/rmp_client/client.py
index 24fd561..37d2907 100644
--- a/src/rmp_client/client.py
+++ b/src/rmp_client/client.py
@@ -3,17 +3,18 @@
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.
+Call :meth:`RMPClient.close` when done to release resources.
"""
from __future__ import annotations
import base64
+import warnings
from datetime import date
-from typing import Any, Dict, Iterator, List, Mapping, Optional, Tuple
+from typing import Any, Dict, Iterator, List, Mapping, Optional
from .config import RMPClientConfig
-from .errors import HttpError, ParsingError, RetryError, RMPAPIError
+from .errors import ParsingError
from .http import HttpClient, HttpClientContext
from .models import (
CompareSchoolsResult,
@@ -70,10 +71,15 @@ def _safe_int(value: Any) -> Optional[int]:
return None
+def _coalesce(*values: Any) -> Any:
+ """Return the first non-None value, or None if all are None."""
+ return next((v for v in values if v is not None), None)
+
+
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.
+ Uses only the date part; invalid input warns and yields today's date.
"""
if isinstance(date_str, str):
part = date_str.split(" ")[0] if " " in date_str else date_str
@@ -81,6 +87,7 @@ def _parse_date(date_str: Any) -> date:
return date.fromisoformat(part)
except ValueError:
pass
+ warnings.warn(f"Could not parse date {date_str!r}, using today", stacklevel=3)
return date.today()
@@ -94,12 +101,6 @@ 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__()
@@ -108,8 +109,6 @@ 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:
@@ -118,12 +117,10 @@ def _client(self) -> HttpClient:
return self._http
def close(self) -> None:
- """Close the HTTP client and clear all rating caches. Safe to call multiple times."""
+ """Close the HTTP client. 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 ---------------------------------------------------------------
@@ -283,11 +280,7 @@ def iter_professors_for_school(
# ---- Professor details + ratings ---------------------------------------------
def get_professor(self, professor_id: str) -> Professor:
- """Fetch a single professor by legacy numeric ID.
-
- Uses the ratings list query with a minimal page size to retrieve
- full teacher details in a single request.
- """
+ """Fetch a single professor by legacy numeric ID."""
page = self._fetch_professor_ratings_page(professor_id, first=1)
return page.professor
@@ -299,67 +292,12 @@ def get_professor_ratings_page(
page_size: int = 20,
course_filter: Optional[str] = None,
) -> ProfessorRatingsPage:
- """Fetch one page of ratings for a professor.
-
- 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.
- """
- # 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 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:
- 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
-
- 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=str(page_size) if has_next else None,
+ """Fetch one page of ratings for a professor."""
+ return self._fetch_professor_ratings_page(
+ professor_id,
+ after=cursor,
+ first=page_size,
+ course_filter=course_filter,
)
def iter_professor_ratings(
@@ -370,7 +308,11 @@ def iter_professor_ratings(
since: Optional[date] = None,
course_filter: Optional[str] = None,
) -> Iterator[Rating]:
- """Iterate all ratings for a professor. Optional ``since`` stops early."""
+ """Iterate all ratings for a professor.
+
+ ``since`` stops iteration early; assumes the API returns ratings
+ newest-first, which is the observed behaviour.
+ """
cursor: Optional[str] = None
while True:
page = self.get_professor_ratings_page(
@@ -390,11 +332,7 @@ def iter_professor_ratings(
# ---- School details + ratings ------------------------------------------------
def get_school(self, school_id: str) -> School:
- """Fetch a single school by legacy numeric ID.
-
- Uses the school ratings list query with a minimal page size to retrieve
- full school details (including category summaries) in a single request.
- """
+ """Fetch a single school by legacy numeric ID."""
page = self._fetch_school_ratings_page(school_id, first=1)
return page.school
@@ -413,56 +351,8 @@ def get_school_ratings_page(
cursor: Optional[str] = None,
page_size: int = 20,
) -> SchoolRatingsPage:
- """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,
- )
-
- 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:
- 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
-
- 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=str(page_size) if has_next else None,
- )
+ """Fetch one page of school ratings."""
+ return self._fetch_school_ratings_page(school_id, after=cursor, first=page_size)
def iter_school_ratings(
self,
@@ -471,7 +361,11 @@ def iter_school_ratings(
page_size: int = 20,
since: Optional[date] = None,
) -> Iterator[SchoolRating]:
- """Iterate all ratings for a school. Optional ``since`` stops early."""
+ """Iterate all ratings for a school.
+
+ ``since`` stops iteration early; assumes the API returns ratings
+ newest-first, which is the observed behaviour.
+ """
cursor: Optional[str] = None
while True:
page = self.get_school_ratings_page(
@@ -530,10 +424,10 @@ def _fetch_professor_ratings_page(
name=name or "Unknown",
department=node.get("department"),
school=school,
- overall_rating=_safe_float(node.get("avgRating")),
+ overall_rating=_safe_float(_coalesce(node.get("avgRating"), node.get("overallRating"))),
num_ratings=_safe_int(node.get("numRatings")),
- percent_take_again=_safe_float(node.get("wouldTakeAgainPercent")),
- level_of_difficulty=_safe_float(node.get("avgDifficulty")),
+ percent_take_again=_safe_float(_coalesce(node.get("wouldTakeAgainPercent"), node.get("percentTakeAgain"))),
+ level_of_difficulty=_safe_float(_coalesce(node.get("avgDifficulty"), node.get("levelOfDifficulty"))),
)
ratings_conn = node.get("ratings") or {}
@@ -623,16 +517,15 @@ def _parse_professor_node(self, node: Mapping[str, Any]) -> Professor:
name=name,
department=node.get("department"),
school=school,
- url=node.get("url"),
overall_rating=_safe_float(
- node.get("avgRating") or node.get("overallRating")
+ _coalesce(node.get("avgRating"), node.get("overallRating"))
),
num_ratings=_safe_int(node.get("numRatings")),
percent_take_again=_safe_float(
- node.get("wouldTakeAgainPercent") or node.get("percentTakeAgain")
+ _coalesce(node.get("wouldTakeAgainPercent"), node.get("percentTakeAgain"))
),
level_of_difficulty=_safe_float(
- node.get("avgDifficulty") or node.get("levelOfDifficulty")
+ _coalesce(node.get("avgDifficulty"), node.get("levelOfDifficulty"))
),
tags=[],
rating_distribution=None,
@@ -661,7 +554,7 @@ def _parse_rating_node(self, record: Mapping[str, Any]) -> Rating:
date=_parse_date(record.get("date")),
comment=str(record.get("comment") or ""),
quality=_safe_float(
- record.get("clarityRating") or record.get("helpfulRating")
+ _coalesce(record.get("clarityRating"), record.get("helpfulRating"))
),
difficulty=_safe_float(record.get("difficultyRating")),
tags=tags,
@@ -674,44 +567,21 @@ def _parse_rating_node(self, record: Mapping[str, Any]) -> Rating:
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 ""),
+ id=str(_coalesce(node.get("legacyId"), 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")
- ),
+ overall_quality=_safe_float(_coalesce(node.get("avgRatingRounded"), 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")
- ),
+ reputation=_safe_float(_coalesce((summary or {}).get("schoolReputation"), node.get("reputation"))),
+ safety=_safe_float(_coalesce((summary or {}).get("schoolSafety"), node.get("safety"))),
+ happiness=_safe_float(_coalesce((summary or {}).get("schoolSatisfaction"), node.get("happiness"))),
+ facilities=_safe_float(_coalesce((summary or {}).get("campusCondition"), node.get("facilities"))),
+ social=_safe_float(_coalesce((summary or {}).get("socialActivities"), node.get("social"))),
+ location_rating=_safe_float(_coalesce((summary or {}).get("campusLocation"), node.get("location_rating"))),
+ clubs=_safe_float(_coalesce((summary or {}).get("clubAndEventActivities"), node.get("clubs"))),
+ opportunities=_safe_float(_coalesce((summary or {}).get("careerOpportunities"), node.get("opportunities"))),
+ internet=_safe_float(_coalesce((summary or {}).get("internetSpeed"), node.get("internet"))),
+ food=_safe_float(_coalesce((summary or {}).get("foodQuality"), node.get("food"))),
)
def _parse_school_rating_node(self, record: Mapping[str, Any]) -> SchoolRating:
diff --git a/src/rmp_client/config.py b/src/rmp_client/config.py
index 0eb1bec..16a6626 100644
--- a/src/rmp_client/config.py
+++ b/src/rmp_client/config.py
@@ -32,7 +32,6 @@ class RMPClientConfig:
base_url: str = DEFAULT_BASE_URL
timeout_seconds: float = 10.0
max_retries: int = 3
- rate_limit_per_minute: int = 60
user_agent: str = DEFAULT_USER_AGENT
default_headers: Mapping[str, str] = field(
default_factory=lambda: dict(DEFAULT_HEADERS)
diff --git a/src/rmp_client/errors.py b/src/rmp_client/errors.py
index db49b4e..7e01615 100644
--- a/src/rmp_client/errors.py
+++ b/src/rmp_client/errors.py
@@ -21,10 +21,6 @@ def __init__(self, status_code: int, url: str, body: Optional[str] = None) -> No
super().__init__(f"HTTP {status_code} for {url}")
-class RateLimitError(RMPError):
- """Raised when a local rate limit is exceeded."""
-
-
class RetryError(RMPError):
"""Raised when a request ultimately fails after exhausting retries."""
diff --git a/src/rmp_client/extras/__pycache__/__init__.cpython-313.pyc b/src/rmp_client/extras/__pycache__/__init__.cpython-313.pyc
index 7ba88e8..9c2b0d5 100644
Binary files a/src/rmp_client/extras/__pycache__/__init__.cpython-313.pyc and b/src/rmp_client/extras/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/rmp_client/extras/__pycache__/course_codes.cpython-313.pyc b/src/rmp_client/extras/__pycache__/course_codes.cpython-313.pyc
index 87c9355..dcfd235 100644
Binary files a/src/rmp_client/extras/__pycache__/course_codes.cpython-313.pyc and b/src/rmp_client/extras/__pycache__/course_codes.cpython-313.pyc differ
diff --git a/src/rmp_client/extras/course_codes.py b/src/rmp_client/extras/course_codes.py
index 420c239..2632db5 100644
--- a/src/rmp_client/extras/course_codes.py
+++ b/src/rmp_client/extras/course_codes.py
@@ -39,7 +39,7 @@ def build_course_mapping(
# try simple prefix+3-digit patterns: ANAT215 -> ANAT 215
prefix_match = re.match(r"^[A-Z]+", key)
- num_match = re.search(r"(\d{3})", key)
+ num_match = re.search(r"(\d{3,4})", key)
candidates: Set[str] = set()
if prefix_match and num_match:
diff --git a/src/rmp_client/extras/helpers.py b/src/rmp_client/extras/helpers.py
index b27be42..9614c1a 100644
--- a/src/rmp_client/extras/helpers.py
+++ b/src/rmp_client/extras/helpers.py
@@ -2,14 +2,15 @@
from __future__ import annotations
+import html
import re
from dataclasses import dataclass, field
from typing import Literal
def _strip_html(text: str) -> str:
- """Strip HTML tags from text (RMP comments occasionally contain markup)."""
- return re.sub(r"<[^>]*>", "", text)
+ """Strip HTML tags and decode HTML entities from text."""
+ return html.unescape(re.sub(r"<[^>]*>", "", text))
def normalize_comment(
diff --git a/src/rmp_client/http.py b/src/rmp_client/http.py
index 51db2ef..95d5859 100644
--- a/src/rmp_client/http.py
+++ b/src/rmp_client/http.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
+import time
from typing import Any, Dict, Mapping, Optional
import httpx
@@ -18,10 +19,7 @@ class HttpClient:
def __init__(self, config: RMPClientConfig) -> None:
self._config = config
self._client = httpx.Client(timeout=config.timeout_seconds)
- self._bucket = TokenBucket(
- capacity=config.rate_limit_per_minute,
- refill_per_second=config.rate_limit_per_minute / 60.0,
- )
+ self._bucket = TokenBucket(capacity=60, refill_per_second=1.0)
def close(self) -> None:
self._client.close()
@@ -81,6 +79,9 @@ def post_json(
response.status_code, str(response.url), body=response.text
)
last_exc = err
+ if response.status_code == 429 and attempt <= self._config.max_retries:
+ time.sleep(2 ** attempt)
+ continue
if (
500 <= response.status_code < 600
and attempt <= self._config.max_retries
diff --git a/src/rmp_client/models.py b/src/rmp_client/models.py
index 58ab316..139be73 100644
--- a/src/rmp_client/models.py
+++ b/src/rmp_client/models.py
@@ -47,7 +47,6 @@ class Professor(BaseModel):
name: str
department: Optional[str] = None
school: Optional[School] = None
- url: Optional[str] = None
overall_rating: Optional[float] = None
num_ratings: Optional[int] = None
percent_take_again: Optional[float] = None
diff --git a/src/rmp_client/queries.py b/src/rmp_client/queries.py
index b7668fb..7ecd9f8 100644
--- a/src/rmp_client/queries.py
+++ b/src/rmp_client/queries.py
@@ -30,6 +30,7 @@
name
city
state
+ country
avgRating
numRatings
}
diff --git a/src/rmp_client/rate_limit.py b/src/rmp_client/rate_limit.py
index b8827c9..ff57e70 100644
--- a/src/rmp_client/rate_limit.py
+++ b/src/rmp_client/rate_limit.py
@@ -4,8 +4,6 @@
import time
from dataclasses import dataclass
-from .errors import RateLimitError
-
@dataclass
class TokenBucket:
@@ -23,20 +21,14 @@ def _refill(self) -> None:
self._last_refill = now
self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_per_second)
- def consume(self, amount: float = 1.0, *, block: bool = True) -> None:
- """Consume tokens from the bucket.
-
- If block=False and there are insufficient tokens, raises RateLimitError.
- """
+ def consume(self, amount: float = 1.0) -> None:
+ """Consume tokens from the bucket, blocking until available."""
with self._lock:
while True:
self._refill()
if self._tokens >= amount:
self._tokens -= amount
return
- if not block:
- raise RateLimitError("Local rate limit exceeded")
- # Sleep just enough to gain one token
needed = amount - self._tokens
sleep_for = max(needed / self.refill_per_second, 0.01)
time.sleep(sleep_for)
diff --git a/tests/test_client.py b/tests/test_client.py
index fad8d57..45a5ec8 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -23,8 +23,7 @@
@pytest.fixture(scope="module")
def client() -> RMPClient:
- cfg = RMPClientConfig(rate_limit_per_minute=30)
- c = RMPClient(config=cfg)
+ c = RMPClient()
yield c
c.close()
@@ -182,7 +181,7 @@ def test_returns_both_schools(self, client: RMPClient) -> None:
# ---------------------------------------------------------------------------
-# get_professor_ratings_page (cached pagination)
+# get_professor_ratings_page
# ---------------------------------------------------------------------------
@@ -197,7 +196,7 @@ def test_first_page(self, client: RMPClient) -> None:
assert r.date is not None
assert isinstance(r.comment, str)
- def test_load_more_from_cache(self, client: RMPClient) -> None:
+ def test_load_more(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
@@ -237,7 +236,7 @@ def test_multiple_show_mores(self, client: RMPClient) -> None:
# ---------------------------------------------------------------------------
-# get_school_ratings_page (cached pagination)
+# get_school_ratings_page
# ---------------------------------------------------------------------------
@@ -257,7 +256,7 @@ def test_has_category_ratings(self, client: RMPClient) -> None:
assert isinstance(r.category_ratings, dict)
assert len(r.category_ratings) > 0
- def test_load_more_from_cache(self, client: RMPClient) -> None:
+ def test_load_more(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")
diff --git a/tests/test_config.py b/tests/test_config.py
index 3cbd822..444ae8f 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -28,16 +28,11 @@ def test_default_timeout_and_retries(self) -> None:
config = RMPClientConfig()
assert config.timeout_seconds == 10.0
assert config.max_retries == 3
- assert config.rate_limit_per_minute == 60
def test_user_agent_default(self) -> None:
config = RMPClientConfig()
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"
diff --git a/tests/test_errors.py b/tests/test_errors.py
index d886fd0..4fadbc7 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -10,7 +10,6 @@
ParsingError,
RMPAPIError,
RMPError,
- RateLimitError,
RetryError,
)
@@ -38,11 +37,6 @@ def test_rmp_api_error_is_rmp_error(self) -> None:
exc = RMPAPIError("api err", details=[])
assert isinstance(exc, RMPError)
- def test_rate_limit_error_is_rmp_error(self) -> None:
- assert issubclass(RateLimitError, RMPError)
- exc = RateLimitError("limit exceeded")
- assert isinstance(exc, RMPError)
-
def test_retry_error_is_rmp_error(self) -> None:
assert issubclass(RetryError, RMPError)
exc = RetryError(ValueError("inner"))
@@ -97,11 +91,3 @@ class TestParsingError:
def test_message(self) -> None:
err = ParsingError("Unexpected payload shape")
assert "Unexpected" in str(err)
-
-
-class TestRateLimitError:
- """RateLimitError for local rate limit."""
-
- def test_message(self) -> None:
- err = RateLimitError("Local rate limit exceeded")
- assert "rate limit" in str(err).lower()
diff --git a/tests/test_extras.py b/tests/test_extras.py
index 24c5086..cabe067 100644
--- a/tests/test_extras.py
+++ b/tests/test_extras.py
@@ -31,6 +31,10 @@ def test_unicode_preserved(self) -> None:
def test_strips_html_by_default(self) -> None:
assert normalize_comment("Loved this class") == "loved this class"
+ def test_decodes_html_entities(self) -> None:
+ assert normalize_comment("great & easy") == "great & easy"
+ assert normalize_comment("bold & great") == "bold & great"
+
def test_strip_html_option(self) -> None:
assert normalize_comment("Bold", strip_html=False) == "bold"
@@ -115,3 +119,10 @@ def test_unknown_returns_none(self) -> None:
def test_empty_valid(self) -> None:
mapping = build_course_mapping(["MATH 101"], [])
assert mapping["MATH 101"] is None
+
+ def test_four_digit_course_number_match(self) -> None:
+ valid = ["MATH 1001", "CS 1102"]
+ scraped = ["MATH1001", "CS1102"]
+ mapping = build_course_mapping(scraped, valid)
+ assert mapping["MATH1001"] == {"MATH 1001"}
+ assert mapping["CS1102"] == {"CS 1102"}
diff --git a/tests/test_http.py b/tests/test_http.py
index 2b43b05..6f9c50d 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -16,7 +16,7 @@ class TestHttpClientPostJson:
"""post_json with pytest-httpx."""
def test_returns_json_on_200(self, httpx_mock: pytest_httpx.HTTPXMock) -> None:
- config = RMPClientConfig(rate_limit_per_minute=1000)
+ config = RMPClientConfig()
payload = {"data": {"x": 1}}
httpx_mock.add_response(
url=config.base_url,
@@ -32,7 +32,7 @@ def test_returns_json_on_200(self, httpx_mock: pytest_httpx.HTTPXMock) -> None:
def test_raises_rmp_api_error_when_errors_in_body(
self, httpx_mock: pytest_httpx.HTTPXMock
) -> None:
- config = RMPClientConfig(rate_limit_per_minute=1000)
+ config = RMPClientConfig()
body = json.dumps({"errors": [{"message": "Unauthorized"}]})
httpx_mock.add_response(url=config.base_url, content=body.encode(), status_code=200)
client = HttpClient(config)
@@ -44,7 +44,7 @@ def test_raises_rmp_api_error_when_errors_in_body(
client.close()
def test_raises_http_error_on_4xx(self, httpx_mock: pytest_httpx.HTTPXMock) -> None:
- config = RMPClientConfig(rate_limit_per_minute=1000)
+ config = RMPClientConfig()
httpx_mock.add_response(url=config.base_url, status_code=403, text="Forbidden")
client = HttpClient(config)
try:
@@ -55,7 +55,7 @@ def test_raises_http_error_on_4xx(self, httpx_mock: pytest_httpx.HTTPXMock) -> N
client.close()
def test_retries_on_5xx(self, httpx_mock: pytest_httpx.HTTPXMock) -> None:
- config = RMPClientConfig(max_retries=2, rate_limit_per_minute=1000)
+ config = RMPClientConfig(max_retries=2)
httpx_mock.add_response(url=config.base_url, status_code=502)
httpx_mock.add_response(url=config.base_url, status_code=502)
httpx_mock.add_response(url=config.base_url, status_code=502)
@@ -69,7 +69,7 @@ def test_retries_on_5xx(self, httpx_mock: pytest_httpx.HTTPXMock) -> None:
client.close()
def test_succeeds_after_5xx_retry(self, httpx_mock: pytest_httpx.HTTPXMock) -> None:
- config = RMPClientConfig(max_retries=3, rate_limit_per_minute=1000)
+ config = RMPClientConfig(max_retries=3)
httpx_mock.add_response(url=config.base_url, status_code=503)
httpx_mock.add_response(url=config.base_url, json={"data": "ok"})
client = HttpClient(config)
@@ -79,8 +79,23 @@ def test_succeeds_after_5xx_retry(self, httpx_mock: pytest_httpx.HTTPXMock) -> N
finally:
client.close()
+ def test_retries_on_429(
+ self, httpx_mock: pytest_httpx.HTTPXMock, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr("rmp_client.http.time.sleep", lambda _: None)
+ config = RMPClientConfig(max_retries=1)
+ httpx_mock.add_response(url=config.base_url, status_code=429)
+ httpx_mock.add_response(url=config.base_url, json={"data": "ok"})
+ client = HttpClient(config)
+ try:
+ result = client.post_json("", {})
+ assert result == {"data": "ok"}
+ assert len(httpx_mock.get_requests()) == 2
+ finally:
+ client.close()
+
def test_sends_default_headers(self, httpx_mock: pytest_httpx.HTTPXMock) -> None:
- config = RMPClientConfig(rate_limit_per_minute=1000)
+ config = RMPClientConfig()
httpx_mock.add_response(url=config.base_url, json={"data": {}})
client = HttpClient(config)
try:
diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py
index bbbdec1..0461a2a 100644
--- a/tests/test_rate_limit.py
+++ b/tests/test_rate_limit.py
@@ -4,50 +4,28 @@
import time
-import pytest
-
-from rmp_client.errors import RateLimitError
from rmp_client.rate_limit import TokenBucket
class TestTokenBucketConsume:
- """consume() with block=True (default)."""
-
def test_consumes_without_error(self) -> None:
bucket = TokenBucket(capacity=10, refill_per_second=10)
for _ in range(5):
bucket.consume()
- def test_exhausts_capacity_then_block_false_raises(self) -> None:
- bucket = TokenBucket(capacity=3, refill_per_second=1.0)
- for _ in range(3):
- bucket.consume()
- with pytest.raises(RateLimitError, match="rate limit"):
- bucket.consume(amount=1.0, block=False)
-
- def test_block_false_raises_when_insufficient_tokens(self) -> None:
- bucket = TokenBucket(capacity=1, refill_per_second=0.01)
- bucket.consume() # exhaust
- with pytest.raises(RateLimitError):
- bucket.consume(block=False)
-
- def test_block_false_succeeds_when_tokens_available(self) -> None:
- bucket = TokenBucket(capacity=2, refill_per_second=10)
- bucket.consume(block=False)
- bucket.consume(block=False)
-
def test_refill_over_time(self) -> None:
- bucket = TokenBucket(capacity=2, refill_per_second=10.0) # refill 10 per second
+ bucket = TokenBucket(capacity=2, refill_per_second=10.0)
bucket.consume()
bucket.consume()
- # After 0.2s we have 2 tokens again (0 + 10*0.2 = 2)
time.sleep(0.25)
- bucket.consume(block=False)
- bucket.consume(block=False)
+ start = time.monotonic()
+ bucket.consume()
+ bucket.consume()
+ assert time.monotonic() - start < 0.1
def test_consume_amount(self) -> None:
- bucket = TokenBucket(capacity=10, refill_per_second=1.0)
+ bucket = TokenBucket(capacity=10, refill_per_second=100.0)
bucket.consume(amount=5.0)
bucket.consume(amount=5.0)
- with pytest.raises(RateLimitError):
- bucket.consume(amount=1.0, block=False)
+ time.sleep(0.15)
+ bucket.consume(amount=10.0)