Initial commit

This commit is contained in:
2024-05-04 23:10:12 -07:00
commit 15e468ea41
5 changed files with 776 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.pyc
*.egg-info/
.pytest_cache/
.coverage

9
omdb/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from .result import (
ExtendedResult,
MovieExtendedResult,
MovieSearchResult,
Rating,
SearchResult,
SeriesExtendedResult,
SeriesSearchResult,
)

355
omdb/result.py Normal file
View File

@@ -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 `<rating>` 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(",")}

16
pyproject.toml Normal file
View File

@@ -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"

392
tests/result_test.py Normal file
View File

@@ -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": "20002010",
"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("20002001") == (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"}