commit 15e468ea415f6544bd07ab47bd8566cff6781b47 Author: Nettika Date: Sat May 4 23:10:12 2024 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a02f655 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +*.egg-info/ +.pytest_cache/ +.coverage diff --git a/omdb/__init__.py b/omdb/__init__.py new file mode 100644 index 0000000..354b73f --- /dev/null +++ b/omdb/__init__.py @@ -0,0 +1,9 @@ +from .result import ( + ExtendedResult, + MovieExtendedResult, + MovieSearchResult, + Rating, + SearchResult, + SeriesExtendedResult, + SeriesSearchResult, +) diff --git a/omdb/result.py b/omdb/result.py new file mode 100644 index 0000000..9ce107f --- /dev/null +++ b/omdb/result.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import date, datetime +from typing import Literal, override +from xml.etree.ElementTree import Element, SubElement + +from httpx import URL + +MediaType = Literal["movie", "series"] +RatingSource = Literal["imdb", "metacritic", "tomatometerallcritics"] + +YEAR_RANGE_PATTERN = re.compile(r"(\d{4})-(\d{4})$") +YEAR_ONGOING_PATTERN = re.compile(r"(\d{4})-$") +YEAR_SINGLE_PATTERN = re.compile(r"(\d{4})$") + +RATING_SOURCE_MAP: dict[str, RatingSource] = { + "Internet Movie Database": "imdb", + "Metacritic": "metacritic", + "Rotten Tomatoes": "tomatometerallcritics", +} + + +@dataclass(frozen=True) +class SearchResult(ABC): + title: str + imdb_id: str + poster: URL | None + + @classmethod + def parse(cls, data) -> SearchResult: + """Parses a search result object returned from OMDb.""" + + media_type: MediaType = data["Type"] + match media_type: + case "movie": + return MovieSearchResult.parse(data) + case "series": + return SeriesSearchResult.parse(data) + case _: + raise NotImplementedError( + f'Result type "{media_type}" is not supported.' + ) + + +@dataclass(frozen=True) +class MovieSearchResult(SearchResult): + year: int | None + + @override + @classmethod + def parse(cls, data) -> MovieSearchResult: + """Parses a movie search result object returned from OMDb.""" + + return cls( + data["Title"], + data["imdbID"], + _parse_optional_url(data["Poster"]), + _parse_optional_int(data["Year"]), + ) + + +@dataclass(frozen=True) +class SeriesSearchResult(SearchResult): + year_start: int | None + year_end: int | None + + @override + @classmethod + def parse(cls, data) -> SeriesSearchResult: + """Parses a series search result object returned from OMDb.""" + + year_start, year_end = _parse_optional_year_range(data["Year"]) + return cls( + data["Title"], + data["imdbID"], + _parse_optional_url(data["Poster"]), + year_start, + year_end, + ) + + +@dataclass(frozen=True) +class Rating: + source: RatingSource + value: float + max_value: int + + @classmethod + def parse(cls, data) -> Rating: + """Parses a rating object returned from OMDb.""" + + source = RATING_SOURCE_MAP.get(data["Source"]) + if source is None: + raise NotImplementedError( + f'Rating source "{data["Source"]}" is not supported.' + ) + + value, max_value = _parse_rating(data["Value"]) + return Rating(source, value, max_value) + + def nfo_element(self) -> Element: + """Generates a `` XML element for use in an NFO file.""" + + rating_element = Element( + "rating", + attrib={ + "name": self.source, + "max": str(self.max_value), + "default": "true" if self.source == "imdb" else "false", + }, + ) + value_element = SubElement(rating_element, "value") + value_element.text = str(self.value) + return rating_element + + +@dataclass(frozen=True) +class ExtendedResult(ABC): + title: str + imdb_id: str + mpaa_rating: str | None + release_date: date | None + genres: set[str] + plot: str | None + language: set[str] + countries: set[str] + poster: URL | None + ratings: list[Rating] + + @classmethod + def parse(cls, data) -> ExtendedResult: + """Parses an extended result object returned from OMDb.""" + + media_type: MediaType = data["Type"] + match media_type: + case "movie": + return MovieExtendedResult.parse(data) + case "series": + return SeriesExtendedResult.parse(data) + case _: + raise NotImplementedError( + f'Result type "{media_type}" is not supported.' + ) + + @abstractmethod + def nfo_element(self) -> Element: ... + + def _nfo_subelements(self) -> list[Element]: + subelements: list[Element] = [] + + title_element = Element("title") + title_element.text = self.title + subelements.append(title_element) + + uniqueid_element = Element( + "uniqueid", + attrib={"type": "imdb", "default": "true"}, + ) + uniqueid_element.text = self.imdb_id + subelements.append(uniqueid_element) + + if self.mpaa_rating is not None: + mpaa_element = Element("mpaa") + mpaa_element.text = self.mpaa_rating + subelements.append(mpaa_element) + + if self.release_date is not None: + premiered_element = Element("premiered") + premiered_element.text = self.release_date.isoformat() + subelements.append(premiered_element) + + if len(self.genres): + for genre in self.genres: + genre_element = Element("genre") + genre_element.text = genre + subelements.append(genre_element) + + if self.plot is not None: + plot_element = Element("plot") + plot_element.text = self.plot + subelements.append(plot_element) + + if len(self.countries): + for country in self.countries: + country_element = Element("country") + country_element.text = country + subelements.append(country_element) + + if len(self.ratings): + ratings_element = Element("ratings") + subelements.append(ratings_element) + for rating in self.ratings: + rating_element = rating.nfo_element() + ratings_element.append(rating_element) + + return subelements + + +@dataclass(frozen=True) +class MovieExtendedResult(ExtendedResult): + runtime: int | None + directors: set[str] + writers: set[str] + actors: set[str] + + @override + @classmethod + def parse(cls, data) -> MovieExtendedResult: + return cls( + data["Title"], + data["imdbID"], + _parse_optional_string(data["Rated"]), + _parse_optional_date(data["Released"]), + _parse_string_set(data["Genre"]), + _parse_optional_string(data["Plot"]), + _parse_string_set(data["Language"]), + _parse_string_set(data["Country"]), + _parse_optional_url(data["Poster"]), + [Rating.parse(rating_data) for rating_data in data["Ratings"]], + _parse_optional_minutes(data["Runtime"]), + _parse_string_set(data["Director"]), + _parse_string_set(data["Writer"]), + _parse_string_set(data["Actors"]), + ) + + def nfo_element(self) -> Element: + root = Element("movie") + + for subelement in self._nfo_subelements(): + root.append(subelement) + + if self.runtime is not None: + runtime_element = SubElement(root, "runtime") + runtime_element.text = str(self.runtime) + + if len(self.directors): + for director in self.directors: + director_element = SubElement(root, "director") + director_element.text = director + + if len(self.writers): + for writer in self.writers: + credits_element = SubElement(root, "credits") + credits_element.text = writer + + if len(self.actors): + for actor in self.actors: + actor_element = SubElement(root, "actor") + actor_name_element = SubElement(actor_element, "name") + actor_name_element.text = actor + + return root + + +@dataclass(frozen=True) +class SeriesExtendedResult(ExtendedResult): + total_seasons: int | None + + @override + @classmethod + def parse(cls, data) -> SeriesExtendedResult: + return cls( + title=data["Title"], + imdb_id=data["imdbID"], + mpaa_rating=_parse_optional_string(data["Rated"]), + release_date=_parse_optional_date(data["Released"]), + genres=_parse_string_set(data["Genre"]), + plot=_parse_optional_string(data["Plot"]), + language=_parse_string_set(data["Language"]), + countries=_parse_string_set(data["Country"]), + poster=_parse_optional_url(data["Poster"]), + ratings=[Rating.parse(rating_data) for rating_data in data["Ratings"]], + total_seasons=_parse_optional_int(data["totalSeasons"]), + ) + + def nfo_element(self) -> Element: + root = Element("tvshow") + + for subelement in self._nfo_subelements(): + root.append(subelement) + + if self.total_seasons is not None: + season_element = SubElement(root, "season") + season_element.text = str(self.total_seasons) + + return root + + +def _parse_optional_string(desc: str) -> str | None: + if desc == "N/A": + return None + + return desc + + +def _parse_optional_int(desc: str) -> int | None: + if desc == "N/A": + return None + + return int(desc) + + +def _parse_optional_url(desc: str) -> URL | None: + if desc == "N/A": + return None + + return URL(desc) + + +def _parse_optional_date(desc: str) -> date | None: + if desc == "N/A": + return None + + return datetime.strptime(desc, "%d %b %Y").date() + + +def _parse_optional_minutes(desc: str) -> int | None: + if desc == "N/A": + return None + + return int(desc[:-4]) + + +def _parse_optional_year_range(desc: str) -> tuple[int | None, int | None]: + if desc == "N/A": + return None, None + + y1, sep, y2 = desc.partition("–") + + if sep == "": + return int(y1), int(y1) + + if y2 == "": + return int(y1), None + + return int(y1), int(y2) + + +def _parse_rating(desc: str) -> tuple[float, int]: + if desc.endswith("%"): + return float(desc[:-1]), 100 + + n1, sep, n2 = desc.partition("/") + + return float(n1), int(n2) + + +def _parse_string_set(desc: str) -> set[str]: + if desc == "N/A": + return set() + + return {item.strip() for item in desc.split(",")} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa8814c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "omdb" +version = "0.1.0" +description = "OMDb API wrapper for the HTTPX client." +requires-python = ">= 3.12" +dependencies = ["httpx"] + +[project.optional-dependencies] +dev = ["pytest", "pytest-httpx", "pytest-cov"] + +[tool.pytest.ini_options] +addopts = "--cov=omdb --cov-report term-missing" diff --git a/tests/result_test.py b/tests/result_test.py new file mode 100644 index 0000000..b52f44e --- /dev/null +++ b/tests/result_test.py @@ -0,0 +1,392 @@ +from datetime import date + +import pytest +from httpx import URL + +from omdb.result import ( + ExtendedResult, + MovieExtendedResult, + MovieSearchResult, + Rating, + SearchResult, + SeriesExtendedResult, + SeriesSearchResult, + _parse_optional_date, + _parse_optional_int, + _parse_optional_minutes, + _parse_optional_string, + _parse_optional_url, + _parse_optional_year_range, + _parse_rating, + _parse_string_set, +) + +mock_movie_search_result = { + "Title": "Movie Title", + "Year": "2000", + "imdbID": "tt0000000", + "Type": "movie", + "Poster": "https://example.com/poster.jpg", +} + +mock_series_search_result = { + "Title": "Series Title", + "Year": "2000–2010", + "imdbID": "tt0000000", + "Type": "series", + "Poster": "https://example.com/poster.jpg", +} + +mock_movie_extended_result = { + "Type": "movie", + "Title": "Movie Title", + "Year": "2000", + "Rated": "R", + "Released": "01 Jan 2000", + "Genre": "Action, Adventure, Comedy", + "Plot": "Example movie", + "Language": "English, Esperanto", + "Country": "United States, Molossia", + "Poster": "https://example.com/poster.jpg", + "Ratings": [ + {"Source": "Internet Movie Database", "Value": "10.0/10"}, + ], + "imdbID": "tt0000000", + "Runtime": "123 min", + "Director": "Alan Smithee", + "Writer": "Alan Smithee", + "Actors": "Jack Nicholson, Robert De Niro, Anthony Hopkins", +} + +mock_series_extended_result = { + "Type": "series", + "Title": "Series Title", + "Year": "2000", + "Rated": "TV-MA", + "Released": "01 Jan 2000", + "Genre": "Action, Adventure, Comedy", + "Plot": "Example series", + "Language": "English, Esperanto", + "Country": "United States, Molossia", + "Poster": "https://example.com/poster.jpg", + "Ratings": [ + {"Source": "Internet Movie Database", "Value": "10.0/10"}, + ], + "imdbID": "tt0000000", + "totalSeasons": 10, +} + + +def test_search_result_parse(): + """Tests the `SearchResult.parse` class method.""" + + movie_result = SearchResult.parse(mock_movie_search_result) + assert movie_result == MovieSearchResult.parse(mock_movie_search_result) + + series_result = SearchResult.parse(mock_series_search_result) + assert series_result == SeriesSearchResult.parse(mock_series_search_result) + + with pytest.raises( + NotImplementedError, + match='Result type "unknown" is not supported.', + ): + SearchResult.parse({"Type": "unknown"}) + + +def test_movie_search_result_parse(): + """Tests the `MovieSearchResult.parse` class method.""" + + result = MovieSearchResult.parse(mock_movie_search_result) + assert result == MovieSearchResult( + title="Movie Title", + imdb_id="tt0000000", + poster=URL("https://example.com/poster.jpg"), + year=2000, + ) + + +def test_series_search_result_parse(): + """Tests the `SeriesSearchResult.parse` class method.""" + + result = SeriesSearchResult.parse(mock_series_search_result) + assert result == SeriesSearchResult( + title="Series Title", + imdb_id="tt0000000", + poster=URL("https://example.com/poster.jpg"), + year_start=2000, + year_end=2010, + ) + + +def test_rating_parse(): + """Tests the `Rating.parse` class method.""" + + rating = Rating.parse({"Source": "Internet Movie Database", "Value": "50.0/100"}) + assert rating == Rating("imdb", 50.0, 100) + + with pytest.raises( + NotImplementedError, + match='Rating source "Unknown" is not supported.', + ): + Rating.parse({"Source": "Unknown", "Value": "50%"}) + + +def test_rating_nfo_element(): + """Tests the `Rating.nfo_element` method.""" + + rating = Rating("imdb", 50.0, 100) + element = rating.nfo_element() + assert element.tag == "rating" + assert element.attrib == {"name": "imdb", "default": "true", "max": "100"} + assert element[0].tag == "value" + assert element[0].text == "50.0" + + rating = Rating("metacritic", 5.0, 10) + element = rating.nfo_element() + assert element.tag == "rating" + assert element.attrib == {"name": "metacritic", "default": "false", "max": "10"} + assert element[0].tag == "value" + assert element[0].text == "5.0" + + +def test_extended_result_parse(): + """Tests the `ExtendedResult.parse` class method.""" + + movie_result = ExtendedResult.parse(mock_movie_extended_result) + assert movie_result == MovieExtendedResult.parse(mock_movie_extended_result) + + series_result = ExtendedResult.parse(mock_series_extended_result) + assert series_result == SeriesExtendedResult.parse(mock_series_extended_result) + + with pytest.raises( + NotImplementedError, + match='Result type "unknown" is not supported.', + ): + ExtendedResult.parse({"Type": "unknown"}) + + +def test_extended_result_nfo_subelements(): + """Tests the `ExtendedResult._nfo_subelements` method.""" + + result = SeriesExtendedResult( + title="Series title", + imdb_id="tt0000000", + mpaa_rating="TV-MA", + release_date=date(2000, 1, 1), + genres={"Action", "Adventure", "Comedy"}, + plot="Example series", + language={"English", "Esperanto"}, + countries={"United States", "Molossia"}, + poster=URL("https://example.com/poster.jpg"), + ratings=[Rating("imdb", 10.0, 10)], + total_seasons=0, + ) + elements = result._nfo_subelements() + + title_elements = [element for element in elements if element.tag == "title"] + assert len(title_elements) == 1 + assert title_elements[0].text == "Series title" + + uniqueid_elements = [element for element in elements if element.tag == "uniqueid"] + assert len(uniqueid_elements) == 1 + assert uniqueid_elements[0].attrib == {"type": "imdb", "default": "true"} + assert uniqueid_elements[0].text == "tt0000000" + + mpaa_elements = [element for element in elements if element.tag == "mpaa"] + assert len(mpaa_elements) == 1 + assert mpaa_elements[0].text == "TV-MA" + + premiered_elements = [element for element in elements if element.tag == "premiered"] + assert len(premiered_elements) == 1 + assert premiered_elements[0].text == "2000-01-01" + + genre_elements = [element for element in elements if element.tag == "genre"] + assert len(genre_elements) == 3 + assert any(genre_element.text == "Action" for genre_element in genre_elements) + + plot_elements = [element for element in elements if element.tag == "plot"] + assert len(plot_elements) == 1 + assert plot_elements[0].text == "Example series" + + country_elements = [element for element in elements if element.tag == "country"] + assert len(country_elements) == 2 + assert any( + country_element.text == "United States" for country_element in country_elements + ) + + ratings_elements = [element for element in elements if element.tag == "ratings"] + assert len(ratings_elements) == 1 + assert len(ratings_elements[0]) == 1 + assert ratings_elements[0][0].tag == "rating" + assert ratings_elements[0][0].attrib == { + "name": "imdb", + "max": "10", + "default": "true", + } + assert ratings_elements[0][0][0].tag == "value" + assert ratings_elements[0][0][0].text == "10.0" + + +def test_movie_extended_result_parse(): + """Tests the `MovieExtendedResult.parse` class method.""" + + result = MovieExtendedResult.parse(mock_movie_extended_result) + assert result == MovieExtendedResult( + title="Movie Title", + imdb_id="tt0000000", + mpaa_rating="R", + release_date=date(2000, 1, 1), + genres={"Action", "Adventure", "Comedy"}, + plot="Example movie", + language={"English", "Esperanto"}, + countries={"United States", "Molossia"}, + poster=URL("https://example.com/poster.jpg"), + ratings=[Rating("imdb", 10.0, 10)], + runtime=123, + directors={"Alan Smithee"}, + writers={"Alan Smithee"}, + actors={"Jack Nicholson", "Robert De Niro", "Anthony Hopkins"}, + ) + + +def test_movie_extended_result_nfo_element(): + """Tests the `MovieExtendedResult.nfo_element` method.""" + + result = MovieExtendedResult.parse(mock_movie_extended_result) + element = result.nfo_element() + assert element.tag == "movie" + + for tag in ( + "title", + "uniqueid", + "mpaa", + "premiered", + "genre", + "plot", + "country", + "ratings", + ): + assert element.find(tag) is not None + + runtime_element = element.find("runtime") + assert runtime_element is not None + assert runtime_element.text == "123" + + director_elements = element.findall("director") + assert len(director_elements) == 1 + assert director_elements[0].text == "Alan Smithee" + + credits_elements = element.findall("credits") + assert len(credits_elements) == 1 + assert credits_elements[0].text == "Alan Smithee" + + actor_elements = element.findall("actor") + assert len(actor_elements) == 3 + assert any( + actor_element[0].tag == "name" and actor_element[0].text == "Anthony Hopkins" + for actor_element in actor_elements + ) + + +def test_series_extended_result_parse(): + """Tests the `SeriesExtendedResult.parse` class method.""" + + series_extended_result = SeriesExtendedResult.parse(mock_series_extended_result) + assert series_extended_result == SeriesExtendedResult( + title="Series Title", + imdb_id="tt0000000", + mpaa_rating="TV-MA", + release_date=date(2000, 1, 1), + genres={"Action", "Adventure", "Comedy"}, + plot="Example series", + language={"English", "Esperanto"}, + countries={"United States", "Molossia"}, + poster=URL("https://example.com/poster.jpg"), + ratings=[Rating("imdb", 10.0, 10)], + total_seasons=10, + ) + + +def test_series_extended_result_nfo(): + """Tests the `SeriesExtendedResult.nfo_element` method.""" + + result = SeriesExtendedResult.parse(mock_series_extended_result) + element = result.nfo_element() + assert element.tag == "tvshow" + + for tag in ( + "title", + "uniqueid", + "mpaa", + "premiered", + "genre", + "plot", + "country", + "ratings", + ): + assert element.find(tag) is not None + + season_element = element.find("season") + assert season_element is not None + assert season_element.text == "10" + + +def test_parse_optional_string(): + """Tests the `_parse_optional_string` function.""" + + assert _parse_optional_string("N/A") is None + assert _parse_optional_string("TV-MA") == "TV-MA" + + +def test_parse_optional_int(): + """Tests the `_parse_optional_int` function.""" + + assert _parse_optional_int("N/A") is None + assert _parse_optional_int("2000") == 2000 + + +def test_parse_optional_url(): + """Tests the `_parse_optional_url` function.""" + + assert _parse_optional_url("N/A") is None + assert _parse_optional_url("https://example.com/poster.jpg") == URL( + "https://example.com/poster.jpg" + ) + + +def test_parse_optional_date(): + """Tests the `_parse_optional_date` function.""" + + assert _parse_optional_date("N/A") is None + assert _parse_optional_date("01 Jan 2000") == date(2000, 1, 1) + + +def test_parse_optional_minutes(): + """Tests the `_parse_optional_minutes` function.""" + + assert _parse_optional_minutes("N/A") is None + assert _parse_optional_minutes("123 min") == 123 + + +def test_parse_optional_year_range(): + """Tests the `_parse_optional_year_range` function.""" + + assert _parse_optional_year_range("N/A") == (None, None) + assert _parse_optional_year_range("2000") == (2000, 2000) + assert _parse_optional_year_range("2000–") == (2000, None) + assert _parse_optional_year_range("2000–2001") == (2000, 2001) + + +def test_parse_rating(): + """Tests the `_parse_rating` function.""" + + assert _parse_rating("50%") == (50.0, 100) + assert _parse_rating("50/100") == (50.0, 100) + assert _parse_rating("5.0/10") == (5.0, 10) + + +def test_parse_string_set(): + """Tests the `_parse_string_set` function.""" + + assert _parse_string_set("N/A") == set() + assert _parse_string_set("Foo, Bar, Baz Qux") == {"Foo", "Bar", "Baz Qux"}