diff --git a/docs/api-reference/NEWS-API.md b/docs/api-reference/NEWS-API.md index a8d3b6c..21e8e2a 100644 --- a/docs/api-reference/NEWS-API.md +++ b/docs/api-reference/NEWS-API.md @@ -1,15 +1,46 @@ > [Home](README.md) > News API + --- # News API -### **get_news(feed, max_news_items)** +`NewsClient` searches Pittwire and discovers its available filters from the +current Pittwire page. Topic IDs, category paths, and publication years are +not maintained as hardcoded lists in PittAPI. + +## Discovering filters + +```python +from pittapi import NewsClient + +with NewsClient() as news: + topics = news.get_topics() + categories = news.get_categories() + years = news.get_years() +``` + +`get_topics()` returns `NewsTopic` models containing Pittwire's topic ID and +display name. `get_categories()` returns `NewsCategory` models containing the +category path and display name. `get_years()` returns integers. + +## Fetching articles + +```python +from pittapi import NewsClient -#### **Parameters** - - `feed`: News feed - can be one of ("main_news", "cssd", "news_chronicle", "news_alerts"). Default is "main_news" - - `max_news_items`: Maximum number of news items. Default is 10. +with NewsClient() as news: + topics = news.get_topics() + technology = next( + topic for topic in topics if topic.name == "Technology & Science" + ) + articles = news.get_articles_by_topic( + technology, + query="robotics", + year=2026, + max_num_results=5, + ) +``` -#### **Returns**: -Returns a list of dictionaries with parameters 'title' and 'url' of each news article from each news feed category. -News fetched from `feed`. -Maximum length specified by `max_news_items`. +`get_articles_by_topic()` returns an immutable tuple of `Article` models in +Pittwire's page order. Pass a discovered `NewsCategory` with `category=` to +search a category other than Features & Articles. diff --git a/docs/api-reference/PEOPLE-API.md b/docs/api-reference/PEOPLE-API.md index 227cc52..55e7bd1 100644 --- a/docs/api-reference/PEOPLE-API.md +++ b/docs/api-reference/PEOPLE-API.md @@ -1,38 +1,16 @@ > [Home](README.md) > People API ---- # People API -### **get_person(query, max_people)** +`PeopleClient.get_person(query)` returns matching directory entries as immutable `Person` models: -#### **Parameters**: - - `query`: Query to find people | Example: `smith` or `abc123` - - `max_people`: Max number of people to return | Example: `10` or `100` - - Default value is `10` - -#### **Returns**: -Returns a dictionary with data of user profiles. - -#### **Example**: - -###### **Code**: ```python -people.get_person('Jane') -``` +from pittapi import PeopleClient -###### **Sample Output**: -```python -[ - { - "name": "Jane Doe", - "email": "jdoe@pitt.edu", - "phone": "(999)999-999" - }, - { - "name": "Janedo Smith", - "school": "School of Dental Medicine" - ] - }, - ... -] +with PeopleClient() as people: + matches = people.get_person("Jane Doe") ``` + +Each person's `fields` tuple contains `PersonField` models. Field names are the non-empty labels published by Pitt; +repeated labels are grouped into one field whose `values` are a tuple. This preserves new directory fields without a +PittAPI release. diff --git a/pittapi/library.py b/pittapi/library.py index 237a679..02984e9 100644 --- a/pittapi/library.py +++ b/pittapi/library.py @@ -15,131 +15,162 @@ You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -""" - -from __future__ import annotations -import requests -from typing import Any, NamedTuple +Library catalog search and Hillman study-room reservations. +""" -LIBRARY_URL = ( - "https://pitt.primo.exlibrisgroup.com/primaws/rest/pub/pnxs" - "?acTriggered=false&blendFacetsSeparately=false&citationTrailFilterByAvailability=true&disableCache=false&getMore=0" - "&inst=01PITT_INST&isCDSearch=false&lang=en&limit=10&newspapersActive=false&newspapersSearch=false&offset=0" - "&otbRanking=false&pcAvailability=false&qExclude=&qInclude=&rapido=false&refEntryActive=false&rtaLinks=true" - "&scope=MyInst_and_CI&searchInFulltextUserSelection=false&skipDelivery=Y&sort=rank&tab=Everything" - "&vid=01PITT_INST:01PITT_INST" -) -STUDY_ROOMS_URL = ( - "https://pitt.libcal.com/spaces/bookings/search" - "?lid=917&gid=1558&eid=0&seat=0&d=1&customDate=&q=&daily=0&draw=1&order%5B0%5D%5Bcolumn%5D=1&order%5B0%5D%5Bdir%5D=asc" - "&start=0&length=25&search%5Bvalue%5D=&_=1717907260661" +from dataclasses import dataclass +from typing import Any + +from pittapi.base_client import BaseClient + +__all__ = ["Document", "LibraryClient", "QueryResult", "Reservation"] + +LIBRARY_URL = "https://pitt.primo.exlibrisgroup.com/primaws/rest/pub/pnxs" +LIBRARY_PARAMS = { + "acTriggered": "false", + "blendFacetsSeparately": "false", + "citationTrailFilterByAvailability": "true", + "disableCache": "false", + "getMore": "0", + "inst": "01PITT_INST", + "isCDSearch": "false", + "lang": "en", + "limit": "10", + "newspapersActive": "false", + "newspapersSearch": "false", + "offset": "0", + "otbRanking": "false", + "pcAvailability": "false", + "qExclude": "", + "qInclude": "", + "rapido": "false", + "refEntryActive": "false", + "rtaLinks": "true", + "scope": "MyInst_and_CI", + "searchInFulltextUserSelection": "false", + "skipDelivery": "Y", + "sort": "rank", + "tab": "Everything", + "vid": "01PITT_INST:01PITT_INST", +} +STUDY_ROOMS_URL = "https://pitt.libcal.com/spaces/bookings/search" +STUDY_ROOM_PARAMS = { + "lid": "917", + "gid": "1558", + "eid": "0", + "seat": "0", + "d": "1", + "customDate": "", + "q": "", + "daily": "0", + "draw": "1", + "order[0][column]": "1", + "order[0][dir]": "asc", + "start": "0", + "length": "25", + "search[value]": "", +} +DOCUMENT_FIELDS = ( + "title", + "language", + "subject", + "format", + "type", + "isbns", + "description", + "publisher", + "edition", + "genre", + "place", + "creator", + "version", + "creationdate", ) -QUERY_START = "&q=any,contains," - -sess = requests.session() - - -class Document(NamedTuple): - # Field names must exactly match key names in JSON data - title: list[str] | None = None - language: list[str] | None = None - subject: list[str] | None = None - format: list[str] | None = None - type: list[str] | None = None - isbns: list[str] | None = None - description: list[str] | None = None - publisher: list[str] | None = None - edition: list[str] | None = None - genre: list[str] | None = None - place: list[str] | None = None - creator: list[str] | None = None - version: list[str] | None = None - creationdate: list[str] | None = None - -class QueryResult(NamedTuple): +@dataclass(frozen=True, slots=True) +class Document: + title: tuple[str, ...] = () + language: tuple[str, ...] = () + subject: tuple[str, ...] = () + format: tuple[str, ...] = () + type: tuple[str, ...] = () + isbns: tuple[str, ...] = () + description: tuple[str, ...] = () + publisher: tuple[str, ...] = () + edition: tuple[str, ...] = () + genre: tuple[str, ...] = () + place: tuple[str, ...] = () + creator: tuple[str, ...] = () + version: tuple[str, ...] = () + creationdate: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class QueryResult: num_results: int num_pages: int - docs: list[Document] + documents: tuple[Document, ...] -class Reservation(NamedTuple): +@dataclass(frozen=True, slots=True) +class Reservation: room: str reserved_from: str reserved_until: str -def get_documents(query: str) -> QueryResult: - """Return ten resource results from the specified page""" - parsed_query = query.replace(" ", "+") - full_query = LIBRARY_URL + QUERY_START + parsed_query - resp = sess.get(full_query) - resp_json = resp.json() - - results = QueryResult( - num_results=resp_json["info"]["total"], - num_pages=resp_json["info"]["last"], - docs=_filter_documents(resp_json["docs"]), - ) - return results - - -def get_document_by_bookmark(bookmark: str) -> QueryResult: - """Return resource referenced by bookmark""" - payload = {"bookMark": bookmark} - resp = sess.get(LIBRARY_URL, params=payload) - resp_json = resp.json() - - if resp_json.get("errors"): - for error in resp_json.get("errors"): - if error["code"] == "invalid.bookmark.format": - raise ValueError("Invalid bookmark") - results = QueryResult( - num_results=resp_json["info"]["total"], - num_pages=resp_json["info"]["last"], - docs=_filter_documents(resp_json["docs"]), - ) - return results - - -def _filter_documents(documents: list[dict[str, Any]]) -> list[Document]: - new_docs: list[Document] = [] - - for doc in documents: - filtered_doc = {key: vals for key, vals in doc["pnx"]["display"].items() if key in Document._fields} - new_docs.append(Document(**filtered_doc)) - - return new_docs - - -def hillman_total_reserved() -> int: - """Returns a simple count dictionary of the total amount of reserved rooms appointments""" - resp = requests.get(STUDY_ROOMS_URL) - resp_json = resp.json() - total_records: int = resp_json["recordsTotal"] # Total records is kept track of by default in the JSON - - # Note: this must align with the amount of entries in reserved times function; renamed for further clarification - return total_records - - -def reserved_hillman_times() -> list[Reservation]: - """Returns a list of dictionaries of reserved rooms in Hillman with their respective times""" - resp = requests.get(STUDY_ROOMS_URL) - resp_json = resp.json() - data = resp_json["data"] - - if data is None: - return [] - - # Note: there can be multiple reservations in the same room, so we must use a list of maps and not a singular map - bookings = [ - Reservation( - room=reservation["itemName"], - reserved_from=reservation["from"], - reserved_until=reservation["to"], - ) - for reservation in data - ] - return bookings +class LibraryClient(BaseClient): + """Search Pitt's library catalog and study-room reservations.""" + + def get_documents(self, query: str) -> QueryResult: + params = {**LIBRARY_PARAMS, "q": f"any,contains,{query}"} + return parse_query_result(self.request("GET", LIBRARY_URL, params=params).json()) + + def get_document_by_bookmark(self, bookmark: str) -> QueryResult: + params = {**LIBRARY_PARAMS, "bookMark": bookmark} + data = self.request("GET", LIBRARY_URL, params=params).json() + for error in data.get("errors", ()): + if error.get("code") == "invalid.bookmark.format": + raise ValueError("invalid bookmark") + return parse_query_result(data) + + def hillman_total_reserved(self) -> int: + data = self.request("GET", STUDY_ROOMS_URL, params=STUDY_ROOM_PARAMS).json() + try: + return data["recordsTotal"] + except (KeyError, TypeError) as error: + raise ValueError("reservation response is missing its total") from error + + def reserved_hillman_times(self) -> tuple[Reservation, ...]: + data = self.request("GET", STUDY_ROOMS_URL, params=STUDY_ROOM_PARAMS).json() + try: + reservations = data["data"] or () + return tuple( + Reservation( + room=item["itemName"], + reserved_from=item["from"], + reserved_until=item["to"], + ) + for item in reservations + ) + except (KeyError, TypeError) as error: + raise ValueError("reservation response is missing required data") from error + + +def parse_query_result(data: dict[str, Any]) -> QueryResult: + try: + info = data["info"] + documents = tuple(parse_document(item) for item in data["docs"]) + return QueryResult(num_results=info["total"], num_pages=info["last"], documents=documents) + except (KeyError, TypeError) as error: + raise ValueError("library response is missing required data") from error + + +def parse_document(data: dict[str, Any]) -> Document: + display = data["pnx"]["display"] + fields = {} + for name in DOCUMENT_FIELDS: + fields[name] = tuple(display.get(name, ())) + return Document(**fields) diff --git a/pittapi/news.py b/pittapi/news.py index 422c87e..2114a26 100644 --- a/pittapi/news.py +++ b/pittapi/news.py @@ -15,119 +15,164 @@ You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -""" -from __future__ import annotations +Articles published by Pittwire. +""" +from dataclasses import dataclass import math -import requests +from urllib.parse import urljoin + from bs4 import BeautifulSoup, Tag -from typing import Literal, NamedTuple - -NUM_ARTICLES_PER_PAGE = 20 - -NEWS_BY_CATEGORY_URL = ( - "https://www.pitt.edu/pittwire/news/{category}?field_topics_target_id={topic_id}&field_article_date_value={year}" - "&title={query}&field_category_target_id=All&page={page_num}" -) -PITT_BASE_URL = "https://www.pitt.edu" - -Category = Literal["features-articles", "accolades-honors", "ones-to-watch", "announcements-and-updates"] -Topic = Literal[ - "university-news", - "health-and-wellness", - "technology-and-science", - "arts-and-humanities", - "community-impact", - "innovation-and-research", - "global", - "diversity-equity-and-inclusion", - "our-city-our-campus", - "teaching-and-learning", - "space", - "ukraine", - "sustainability", -] - -TOPIC_ID_MAP: dict[Topic, int] = { - "university-news": 432, - "health-and-wellness": 2, - "technology-and-science": 391, - "arts-and-humanities": 4, - "community-impact": 6, - "innovation-and-research": 1, - "global": 9, - "diversity-equity-and-inclusion": 8, - "our-city-our-campus": 12, - "teaching-and-learning": 7, - "space": 440, - "ukraine": 441, - "sustainability": 470, -} - -sess = requests.Session() - - -class Article(NamedTuple): + +from pittapi.base_client import BaseClient + +__all__ = ["Article", "NewsCategory", "NewsClient", "NewsTopic"] + +ARTICLES_PER_PAGE = 20 +PITTWIRE_BASE_URL = "https://www.pittwire.pitt.edu" +DEFAULT_NEWS_CATEGORY = "features-articles" +NEWS_BY_CATEGORY_URL = PITTWIRE_BASE_URL + "/pittwire/news/{category}" + + +@dataclass(frozen=True, slots=True) +class NewsTopic: + id: int + name: str + + +@dataclass(frozen=True, slots=True) +class NewsCategory: + slug: str + name: str + + +@dataclass(frozen=True, slots=True) +class Article: title: str description: str url: str - tags: list[str] - - @classmethod - def from_html(cls, article_html: Tag) -> Article: - article_heading = article_html.select_one("h2.news-card-title a") - article_subheading = article_html.find("p") - if not isinstance(article_heading, Tag) or not isinstance(article_subheading, Tag): - raise ValueError("News card is missing its heading or description") - - article_title = article_heading.get_text(strip=True) - article_href = article_heading.get("href") - if not isinstance(article_href, str): - raise ValueError("News card heading is missing its URL") - article_url = PITT_BASE_URL + article_href - article_description = article_subheading.get_text(strip=True) - article_tags = [tag.get_text(strip=True) for tag in article_html.select("ul.news-card-tags li")] - - return cls(title=article_title, description=article_description, url=article_url, tags=article_tags) - - -def _get_page_articles( - topic: Topic, - category: Category, - query: str, - year: int | None, - page_num: int, -) -> list[Article]: - year_str = str(year) if year else "" - page_num_str = str(page_num) if page_num else "" - response = sess.get( - NEWS_BY_CATEGORY_URL.format( - category=category, topic_id=TOPIC_ID_MAP[topic], year=year_str, query=query, page_num=page_num_str - ) + tags: tuple[str, ...] + + +class NewsClient(BaseClient): + """Search Pittwire articles.""" + + def get_topics(self) -> tuple[NewsTopic, ...]: + """Return the topics currently offered by Pittwire's search form.""" + soup = self.get_filter_page() + topic_select = soup.select_one("select[name=field_topics_target_id]") + if not isinstance(topic_select, Tag): + raise ValueError("news page is missing its topic filter") + + topics = [] + for option in topic_select.find_all("option"): + topic_id = option.get("value") + if topic_id == "All": + continue + if not isinstance(topic_id, str) or not topic_id.isdigit(): + raise ValueError("news page contains an invalid topic ID") + topics.append(NewsTopic(id=int(topic_id), name=option.get_text(strip=True))) + return tuple(topics) + + def get_categories(self) -> tuple[NewsCategory, ...]: + """Return the article categories currently linked by Pittwire.""" + soup = self.get_filter_page() + category_links = soup.select(".view-category-menu .view-content a") + if not category_links: + raise ValueError("news page is missing its category links") + + categories = [] + path_prefix = "/pittwire/news/" + for link in category_links: + href = link.get("href") + if not isinstance(href, str) or not href.startswith(path_prefix): + raise ValueError("news page contains an invalid category link") + categories.append( + NewsCategory( + slug=href.removeprefix(path_prefix), + name=link.get_text(strip=True), + ) + ) + return tuple(categories) + + def get_years(self) -> tuple[int, ...]: + """Return the publication years currently offered by Pittwire.""" + soup = self.get_filter_page() + year_select = soup.select_one("select[name=field_article_date_value]") + if not isinstance(year_select, Tag): + raise ValueError("news page is missing its year filter") + + years = [] + for option in year_select.find_all("option"): + year = option.get("value") + if year == "": + continue + if not isinstance(year, str) or not year.isdigit(): + raise ValueError("news page contains an invalid publication year") + years.append(int(year)) + return tuple(years) + + def get_filter_page(self) -> BeautifulSoup: + """Fetch the default news page used to discover available filters.""" + url = NEWS_BY_CATEGORY_URL.format(category=DEFAULT_NEWS_CATEGORY) + return BeautifulSoup(self.request("GET", url).text, "html.parser") + + def get_articles_by_topic( + self, + topic: NewsTopic, + category: NewsCategory | None = None, + query: str = "", + year: int | None = None, + max_num_results: int = ARTICLES_PER_PAGE, + ) -> tuple[Article, ...]: + if max_num_results < 0: + raise ValueError("max_num_results cannot be negative") + + page_count = math.ceil(max_num_results / ARTICLES_PER_PAGE) + articles = [] + for page in range(page_count): + page_articles = self.get_page_articles(topic, category, query, year, page) + remaining = max_num_results - len(articles) + articles.extend(page_articles[:remaining]) + return tuple(articles) + + def get_page_articles( + self, + topic: NewsTopic, + category: NewsCategory | None, + query: str, + year: int | None, + page: int, + ) -> tuple[Article, ...]: + category_slug = category.slug if category else DEFAULT_NEWS_CATEGORY + url = NEWS_BY_CATEGORY_URL.format(category=category_slug) + parameters = { + "field_topics_target_id": topic.id, + "field_article_date_value": year or "", + "title": query, + "field_category_target_id": "All", + "page": page, + } + soup = BeautifulSoup(self.request("GET", url, params=parameters).text, "html.parser") + main_content = soup.select_one("html > body > div > main > div > section") + if not isinstance(main_content, Tag): + raise ValueError("news page is missing its main content") + return tuple(parse_article(card) for card in main_content.select("div.news-card")) + + +def parse_article(article_html: Tag) -> Article: + heading = article_html.select_one("h2.news-card-title a") + description = article_html.find("p") + if not isinstance(heading, Tag) or not isinstance(description, Tag): + raise ValueError("news card is missing its heading or description") + href = heading.get("href") + if not isinstance(href, str): + raise ValueError("news card heading is missing its URL") + tags = tuple(tag.get_text(strip=True) for tag in article_html.select("ul.news-card-tags li")) + return Article( + title=heading.get_text(strip=True), + description=description.get_text(strip=True), + url=urljoin(PITTWIRE_BASE_URL, href), + tags=tags, ) - soup = BeautifulSoup(response.text, "html.parser") - main_content = soup.select_one("html > body > div > main > div > section") - if not isinstance(main_content, Tag): - raise ValueError("News page is missing its main content") - news_cards = main_content.select("div.news-card") - page_articles = [Article.from_html(news_card) for news_card in news_cards] - return page_articles - - -def get_articles_by_topic( - topic: Topic, - category: Category = "features-articles", - query: str = "", - year: int | None = None, - max_num_results: int = NUM_ARTICLES_PER_PAGE, -) -> list[Article]: - num_pages = math.ceil(max_num_results / NUM_ARTICLES_PER_PAGE) - - # Fetch pages sequentially so articles remain in page order. - results: list[Article] = [] - for page_num in range(num_pages): # Page numbers in url are 0-indexed - page_articles = _get_page_articles(topic, category, query, year, page_num) - num_articles_to_add = min(len(page_articles), max_num_results - len(results)) - results.extend(page_articles[:num_articles_to_add]) - return results diff --git a/pittapi/people.py b/pittapi/people.py index 087b98c..8d3905a 100644 --- a/pittapi/people.py +++ b/pittapi/people.py @@ -15,68 +15,58 @@ You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Search Pitt's public people directory. """ -import requests +from dataclasses import dataclass + from bs4 import BeautifulSoup, Tag -from typing import Any -# Please note that find.pitt.edu will not accept more than 10 requests within a few minutes -# It will time out if that happens +from pittapi.base_client import BaseClient + +__all__ = ["Person", "PersonField", "PeopleClient"] PEOPLE_SEARCH_URL = "https://find.pitt.edu/Search" +REQUEST_HEADERS = {"User-Agent": "PittAPI (+https://github.com/Pitt-CSC/PittAPI)"} + + +@dataclass(frozen=True, slots=True) +class PersonField: + name: str + values: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class Person: + name: str + fields: tuple[PersonField, ...] -LABEL_CONVERSION = { - "Email": "email", - "Nickname": "nickname", - "Student Campus": "campus", - "Student Plan(s)": "academic_plan", - "Web Page": "website", - "Employee Information": "employment_info", - "Office Phone": "office_phone", - "Office Mailing Address": "office_mailing_address", - "Office Location Address": "office_location_address", - "Mobile Phone": "mobile_phone", - "UPMC Department": "upmc_department", - "UPMC Position": "upmc_position", - "UPMC Email": "upmc_email", -} - - -def _parse_segments(person: dict[str, Any], segments: list[Tag]) -> None: - label = None + +class PeopleClient(BaseClient): + """Search public Pitt directory records.""" + + def get_person(self, query: str) -> tuple[Person, ...]: + response = self.request("POST", PEOPLE_SEARCH_URL, data={"search": query}, headers=REQUEST_HEADERS) + if "Too many people matched your criteria." in response.text: + raise ValueError("too many people matched the search") + + soup = BeautifulSoup(response.text, "html.parser") + people = [] + for entry in soup.select("#searchResults > section"): + name, *segments = entry.find_all("span") + people.append(Person(name=name.get_text(strip=True), fields=parse_segments(segments))) + return tuple(people) + + +def parse_segments(segments: list[Tag]) -> tuple[PersonField, ...]: + values_by_name: dict[str, list[str]] = {} + current_name = None for segment in segments: - segment_text = segment.get_text(strip=True) - if "row-label" in segment.get("class", []): - if segment_text in LABEL_CONVERSION: - label = LABEL_CONVERSION[segment_text] - elif segment_text == "": - continue - else: - label = None - elif label: - if label in person: - if not isinstance(person[label], list): - person[label] = [person[label]] - person[label].append(segment_text) - else: - person[label] = segment_text - - -def get_person(query: str) -> list[dict[str, Any]]: - payload = {"search": query} - session = requests.Session() - resp = session.post(PEOPLE_SEARCH_URL, data=payload) - if "Too many people matched your criteria." in resp.text: - return [{"ERROR": "Too many people matched your criteria."}] - soup = BeautifulSoup(resp.text, "html.parser") - elements = soup.select("#searchResults > section") - result = [] - for entry in elements: - name, *segments = entry.find_all("span") - person = {"name": name.get_text(strip=True)} - _parse_segments(person, segments) - result.append(person) - if not result: - return [{"ERROR": "No one found."}] - return result + text = segment.get_text(strip=True) + if "row-label" in segment.get("class", ()): + current_name = text or None + elif current_name is not None and text: + values_by_name.setdefault(current_name, []).append(text) + + return tuple(PersonField(name=name, values=tuple(values)) for name, values in values_by_name.items()) diff --git a/tests/library_test.py b/tests/library_test.py index 1d21bf8..f1c3a91 100644 --- a/tests/library_test.py +++ b/tests/library_test.py @@ -1,147 +1,79 @@ -""" -The Pitt API, to access workable data of the University of Pittsburgh -Copyright (C) 2015 Ritwik Gupta - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License along -with this program; if not, write to the Free Software Foundation, Inc., -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -""" - import json -import unittest -from copy import deepcopy from pathlib import Path +from urllib.parse import parse_qs, urlparse +import pytest import responses -from pittapi import library - -SAMPLE_PATH = Path(__file__).parent / "samples" - - -class LibraryTest(unittest.TestCase): - def __init__(self, *args, **kwargs): - unittest.TestCase.__init__(self, *args, **kwargs) - with (SAMPLE_PATH / "library_mock_response_water.json").open() as f: - self.library_query = json.load(f) - - @responses.activate - def test_get_documents(self): - responses.add( - responses.GET, - library.LIBRARY_URL + library.QUERY_START + "water", - json=self.library_query, - status=200, - ) - query_result = library.get_documents("water") - self.assertEqual(query_result.num_pages, 10) - self.assertEqual(len(query_result.docs), 10) - - @responses.activate - def test_get_document_by_bookmark(self): - responses.add( - responses.GET, - library.LIBRARY_URL + "&bookMark=valid", - json=self.library_query, - status=200, - ) - - query_result = library.get_document_by_bookmark("valid") - - self.assertEqual(query_result.num_pages, 10) - self.assertEqual(len(query_result.docs), 10) - - @responses.activate - def test_get_document_by_bookmark_rejects_invalid_bookmark(self): - responses.add( - responses.GET, - library.LIBRARY_URL + "&bookMark=invalid", - json={"errors": [{"code": "invalid.bookmark.format"}]}, - status=200, - ) - - with self.assertRaisesRegex(ValueError, "Invalid bookmark"): - library.get_document_by_bookmark("invalid") - - @responses.activate - def test_get_document_by_bookmark_ignores_unrelated_errors(self): - response_data = deepcopy(self.library_query) - response_data["errors"] = [{"code": "unrelated.error"}] - responses.add( - responses.GET, - library.LIBRARY_URL + "&bookMark=other", - json=response_data, - status=200, - ) - - self.assertEqual(library.get_document_by_bookmark("other").num_pages, 10) - - -class StudyRoomTest(unittest.TestCase): - def __init__(self, *args, **kwargs): - unittest.TestCase.__init__(self, *args, **kwargs) - with (SAMPLE_PATH / "hillman_study_room_mock_response.json").open() as f: - self.hillman_query = json.load(f) - - @responses.activate - def test_hillman_total_reserved(self): - responses.add( - responses.GET, - library.STUDY_ROOMS_URL, - json=self.hillman_query, - status=200, - ) - self.assertEqual(library.hillman_total_reserved(), 4) - - @responses.activate - def test_reserved_hillman_times(self): - responses.add( - responses.GET, - library.STUDY_ROOMS_URL, - json=self.hillman_query, - status=200, - ) - mock_answer = [ - library.Reservation( - room="408 HL (Max. 5 persons) (Enclosed Room)", - reserved_from="2024-06-12 17:30:00", - reserved_until="2024-06-12 20:30:00", - ), - library.Reservation( - room="409 HL (Max. 5 persons) (Enclosed Room)", - reserved_from="2024-06-12 18:00:00", - reserved_until="2024-06-12 21:00:00", - ), - library.Reservation( - room="303 HL (Max. 5 persons) (Enclosed Room)", - reserved_from="2024-06-12 18:30:00", - reserved_until="2024-06-12 21:30:00", - ), - library.Reservation( - room="217 HL (Max. 10 persons) (Enclosed Room)", - reserved_from="2024-06-12 19:00:00", - reserved_until="2024-06-12 22:30:00", - ), - ] - self.assertEqual(mock_answer, library.reserved_hillman_times()) - - @responses.activate - def test_reserved_hillman_times_with_no_data(self): - responses.add( - responses.GET, - library.STUDY_ROOMS_URL, - json={"data": None}, - status=200, - ) - - self.assertEqual(library.reserved_hillman_times(), []) +from pittapi.library import ( + LIBRARY_PARAMS, + LIBRARY_URL, + STUDY_ROOM_PARAMS, + STUDY_ROOMS_URL, + Document, + LibraryClient, +) + +SAMPLES = Path("tests/samples") +QUERY_DATA = json.loads((SAMPLES / "library_mock_response_water.json").read_text()) +ROOM_DATA = json.loads((SAMPLES / "hillman_study_room_mock_response.json").read_text()) + + +@responses.activate +def test_get_documents(): + responses.add(responses.GET, LIBRARY_URL, json=QUERY_DATA) + result = LibraryClient().get_documents("water cycle") + assert result.num_pages == 10 + assert len(result.documents) == 10 + assert isinstance(result.documents[0], Document) + assert isinstance(result.documents[0].title, tuple) + params = parse_qs(urlparse(responses.calls[0].request.url).query) + assert params["q"] == ["any,contains,water cycle"] + assert params["inst"] == [LIBRARY_PARAMS["inst"]] + + +@responses.activate +def test_bookmark_success_unrelated_error_and_invalid(): + responses.add(responses.GET, LIBRARY_URL, json=QUERY_DATA) + assert LibraryClient().get_document_by_bookmark("valid").num_pages == 10 + + unrelated = QUERY_DATA | {"errors": [{"code": "other"}]} + responses.add(responses.GET, LIBRARY_URL, json=unrelated) + assert LibraryClient().get_document_by_bookmark("other").num_pages == 10 + + responses.add( + responses.GET, + LIBRARY_URL, + json={"errors": [{"code": "invalid.bookmark.format"}]}, + ) + with pytest.raises(ValueError, match="invalid bookmark"): + LibraryClient().get_document_by_bookmark("bad") + + +@responses.activate +def test_room_reservations_and_empty_data(): + responses.add(responses.GET, STUDY_ROOMS_URL, json=ROOM_DATA) + responses.add(responses.GET, STUDY_ROOMS_URL, json=ROOM_DATA) + responses.add(responses.GET, STUDY_ROOMS_URL, json={"data": None}) + client = LibraryClient() + assert client.hillman_total_reserved() == 4 + assert len(client.reserved_hillman_times()) == 4 + assert client.reserved_hillman_times() == () + params = parse_qs(urlparse(responses.calls[0].request.url).query) + assert params["lid"] == [STUDY_ROOM_PARAMS["lid"]] + assert "_" not in params + + +@responses.activate +def test_malformed_library_responses(): + responses.add(responses.GET, LIBRARY_URL, json={}) + with pytest.raises(ValueError, match="library response"): + LibraryClient().get_documents("bad") + + responses.add(responses.GET, STUDY_ROOMS_URL, json={}) + with pytest.raises(ValueError, match="missing its total"): + LibraryClient().hillman_total_reserved() + + responses.add(responses.GET, STUDY_ROOMS_URL, json={}) + with pytest.raises(ValueError, match="reservation response"): + LibraryClient().reserved_hillman_times() diff --git a/tests/news_test.py b/tests/news_test.py index 598c06b..0ef0f6e 100644 --- a/tests/news_test.py +++ b/tests/news_test.py @@ -1,272 +1,166 @@ -""" -The Pitt API, to access workable data of the University of Pittsburgh -Copyright (C) 2015 Ritwik Gupta - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License along -with this program; if not, write to the Free Software Foundation, Inc., -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -""" - -import responses -import unittest - from pathlib import Path -from pittapi import news - -SAMPLE_PATH = Path() / "tests" / "samples" - - -class NewsTest(unittest.TestCase): - def __init__(self, *args, **kwargs): - unittest.TestCase.__init__(self, *args, **kwargs) - with (SAMPLE_PATH / "news_university_news_features_articles_page_0.html").open() as f: - self.university_news_features_articles_page_0 = f.read() - with (SAMPLE_PATH / "news_university_news_features_articles_page_1.html").open() as f: - self.university_news_features_articles_page_1 = f.read() - with (SAMPLE_PATH / "news_university_news_features_articles_fulbright.html").open() as f: - self.university_news_features_articles_fulbright = f.read() - with (SAMPLE_PATH / "news_university_news_features_articles_2020.html").open() as f: - self.university_news_features_articles_2020 = f.read() - - @responses.activate - def test_get_articles_by_topic(self): - responses.add( - responses.GET, - "https://www.pitt.edu/pittwire/news/features-articles?field_topics_target_id=432&field_article_date_value=&title=" - "&field_category_target_id=All", - body=self.university_news_features_articles_page_0, - ) - - university_news_articles = news.get_articles_by_topic("university-news") - - self.assertEqual(len(university_news_articles), news.NUM_ARTICLES_PER_PAGE) - self.assertEqual( - university_news_articles[0], - news.Article( - title="Questions for the ‘Connecting King’", - description="Vernard Alexander, the new director of Pitt’s Homewood Community Engagement Center, " - "sees himself as the ultimate connector.", - url="https://www.pitt.edu/pittwire/pittmagazine/features-articles/vernard-alexander-community-engagement", - tags=["University News", "Community Impact"], - ), - ) - self.assertEqual( - university_news_articles[-1], - news.Article( - title="John Surma is Pitt’s 2024 spring commencement speaker", - description="The University will also honor the Board of Trustees member and former U. S. Steel CEO " - "with an honorary degree.", - url="https://www.pitt.edu/pittwire/features-articles/2024-spring-commencement-speaker-john-surma", - tags=["University News", "Commencement"], - ), - ) - - @responses.activate - def test_get_articles_by_topic_query(self): - query = "fulbright" - responses.add( - responses.GET, - "https://www.pitt.edu/pittwire/news/features-articles?field_topics_target_id=432&field_article_date_value=" - f"&title={query}&field_category_target_id=All", - body=self.university_news_features_articles_fulbright, - ) - - university_news_articles = news.get_articles_by_topic("university-news", query=query) - - self.assertEqual(len(university_news_articles), 3) - self.assertEqual( - university_news_articles[0], - news.Article( - title="Meet Pitt’s 2024 faculty Fulbright winners", - description="The Fulbright U.S. Scholar Program offers faculty the opportunity " - "to teach and conduct research abroad.", - url="https://www.pitt.edu/pittwire/features-articles/faculty-fulbright-scholars-2024", - tags=["University News", "Innovation and Research", "Global", "Faculty"], - ), - ) - self.assertEqual( - university_news_articles[-1], - news.Article( - title="Pitt has been named a top producer of Fulbright U.S. students for 2022-23", - description="Meet the nine Pitt scholars in this year’s cohort.", - url="https://www.pitt.edu/pittwire/features-articles/pitt-fulbright-top-producing-institution-2022-2023", - tags=[ - "University News", - "Global", - "David C. Frederick Honors College", - "Kenneth P. Dietrich School of Arts and Sciences", - "School of Education", - "Swanson School of Engineering", - ], - ), - ) - - @responses.activate - def test_get_articles_by_topic_year(self): - year = 2020 - responses.add( - responses.GET, - f"https://www.pitt.edu/pittwire/news/features-articles?field_topics_target_id=432&field_article_date_value={year}" - "&title=&field_category_target_id=All", - body=self.university_news_features_articles_2020, - ) - - university_news_articles = news.get_articles_by_topic("university-news", year=year) - - self.assertEqual(len(university_news_articles), 5) - self.assertEqual( - university_news_articles[0], - news.Article( - title="University of Pittsburgh Library System acquires archive of renowned playwright August Wilson", - description="The late playwright and Pittsburgh native is best known for his unprecedented " - "American Century Cycle—10 plays that convey the Black experience in each decade of the 20th century. " - "All 10 of the plays", - url="https://www.pitt.edu/pittwire/features-articles/university-pittsburgh-library-system-acquires-archive-" - "renowned-playwright-august-wilson", - tags=["University News", "Arts and Humanities"], - ), - ) - self.assertEqual( - university_news_articles[-1], - news.Article( - title="Track and field Olympian reflects on time at Pitt and plans for new facilities", - description="Alumnus Herb Douglas (EDUC ’48, ’50G), the oldest living African American Olympic medalist, " - "says plans for new training spaces for athletes will bring recruiting and Pitt Athletics to new heights.", - url="https://www.pitt.edu/pittwire/features-articles/track-and-field-olympian-reflects-time-pitt-" - "plans-new-facilities", - tags=["University News", "Athletics"], - ), - ) - - @responses.activate - def test_get_articles_by_topic_less_than_one_page(self): - num_results = 5 - responses.add( - responses.GET, - "https://www.pitt.edu/pittwire/news/features-articles?field_topics_target_id=432&field_article_date_value=&title=" - "&field_category_target_id=All", - body=self.university_news_features_articles_page_0, - ) - - university_news_articles = news.get_articles_by_topic("university-news", max_num_results=num_results) - - self.assertEqual(len(university_news_articles), num_results) - self.assertEqual( - university_news_articles[0], - news.Article( - title="Questions for the ‘Connecting King’", - description="Vernard Alexander, the new director of Pitt’s Homewood Community Engagement Center, " - "sees himself as the ultimate connector.", - url="https://www.pitt.edu/pittwire/pittmagazine/features-articles/vernard-alexander-community-engagement", - tags=["University News", "Community Impact"], - ), - ) - self.assertEqual( - university_news_articles[-1], - news.Article( - title="Panthers Forward can now help graduates find loan forgiveness and repayment options", - description="Pitt’s innovative debt-relief program has partnered with Savi, " - "which can help some borrowers save thousands.", - url="https://www.pitt.edu/pittwire/features-articles/panthers-forward-savi-affordability", - tags=["University News", "Students"], - ), - ) - - @responses.activate - def test_get_articles_by_topic_multiple_pages(self): - num_results = news.NUM_ARTICLES_PER_PAGE + 5 - responses.add( - responses.GET, - "https://www.pitt.edu/pittwire/news/features-articles?field_topics_target_id=432&field_article_date_value=&title=" - "&field_category_target_id=All", - body=self.university_news_features_articles_page_0, - ) - responses.add( - responses.GET, - "https://www.pitt.edu/pittwire/news/features-articles?field_topics_target_id=432&field_article_date_value=&title=" - "&field_category_target_id=All&page=1", - body=self.university_news_features_articles_page_1, - ) - - university_news_articles = news.get_articles_by_topic("university-news", max_num_results=num_results) - - self.assertEqual(len(university_news_articles), num_results) - self.assertEqual( - university_news_articles[0], - news.Article( - title="Questions for the ‘Connecting King’", - description="Vernard Alexander, the new director of Pitt’s Homewood Community Engagement Center, " - "sees himself as the ultimate connector.", - url="https://www.pitt.edu/pittwire/pittmagazine/features-articles/vernard-alexander-community-engagement", - tags=["University News", "Community Impact"], - ), - ) - self.assertEqual( - university_news_articles[-1], - news.Article( - title="Pitt has 2 new Goldwater Scholars", - description="The prestigious scholarship is awarded to sophomores and juniors who plan to pursue " - "research careers in the sciences and engineering fields. Meet our winners.", - url="https://www.pitt.edu/pittwire/features-articles/goldwater-scholars-2024", - tags=[ - "University News", - "Technology & Science", - "David C. Frederick Honors College", - "Kenneth P. Dietrich School of Arts and Sciences", - ], - ), - ) - - @responses.activate - def test_get_articles_by_topic_rejects_malformed_card(self): - responses.add( - responses.GET, - "https://www.pitt.edu/pittwire/news/features-articles?field_topics_target_id=432&field_article_date_value=&title=" - "&field_category_target_id=All", - body=( - "
Description
" - "Description
" + "