diff --git a/omdb/api.py b/omdb/api.py new file mode 100644 index 0000000..34233b8 --- /dev/null +++ b/omdb/api.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import Any, Literal + +from httpx import URL, Client, QueryParams + +from .error import OmdbError +from .result import ExtendedResult, MovieExtendedResult, SeriesExtendedResult + +PlotMode = Literal["short", "full"] + + +OMDB_SEARCH_API = URL("https://www.omdbapi.com/") +OMDB_POSTER_API = URL("https://img.omdbapi.com/") + + +class OmdbApi: + api_key: str + client: Client + + def __init__(self, api_key: str, client: Client = Client()) -> None: + self.api_key = api_key + self.client = client + + def _search(self, params: QueryParams) -> Any: + response = self.client.get( + OMDB_SEARCH_API, + params=params.set("apikey", self.api_key), + ) + data = response.json() + + if response.is_error: + raise OmdbError(response.status_code, data["Error"]) + + return data + + def query_imdb_id( + self, + imdb_id: str, + *, + plot_mode: PlotMode | None = None, + ) -> ExtendedResult: + params = QueryParams(i=imdb_id) + if plot_mode: + params = params.set("plot", plot_mode) + + data = self._search(params) + return ExtendedResult.parse(data) + + def query_title( + self, + title: str, + *, + year: int | None = None, + plot_mode: PlotMode | None = None, + ) -> ExtendedResult: + params = QueryParams(t=title) + if year is not None: + params = params.set("y", year) + if plot_mode is not None: + params = params.set("plot", plot_mode) + + data = self._search(params) + return ExtendedResult.parse(data) + + def query_movie_title( + self, + title: str, + *, + year: int | None = None, + plot_mode: PlotMode | None = None, + ) -> MovieExtendedResult: + params = QueryParams(t=title, type="movie") + if year is not None: + params = params.set("y", year) + if plot_mode is not None: + params = params.set("plot", plot_mode) + + data = self._search(params) + return MovieExtendedResult.parse(data) + + def query_series_title( + self, + title: str, + *, + year: int | None = None, + plot_mode: PlotMode | None = None, + ) -> SeriesExtendedResult: + params = QueryParams(t=title, type="series") + if year is not None: + params = params.set("y", year) + if plot_mode is not None: + params = params.set("plot", plot_mode) + + data = self._search(params) + return SeriesExtendedResult.parse(data) + + # def query_movie_by_title( + # self, + # title: str, + # *, + # year: Optional[int] = None, + # plot: Optional[PlotMode] = None, + # ) -> MovieSearchResult: + # params = httpx.QueryParams(t=title, type="movie") + # if year: + # params = params.set("y", year) + # if plot: + # params = params.set("plot", plot) + + # result = self._search(params) + + # if not isinstance(result, MovieSearchResult): + # raise TypeError(result) + # return result diff --git a/omdb/error.py b/omdb/error.py new file mode 100644 index 0000000..59e92b8 --- /dev/null +++ b/omdb/error.py @@ -0,0 +1,10 @@ +from __future__ import annotations + + +class OmdbError(Exception): + status: int + + def __init__(self, status: int, *args: object) -> None: + self.status = status + + super().__init__(*args) diff --git a/pyproject.toml b/pyproject.toml index fa8814c..409f561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">= 3.12" dependencies = ["httpx"] [project.optional-dependencies] -dev = ["pytest", "pytest-httpx", "pytest-cov"] +dev = ["pytest", "pytest-cov"] [tool.pytest.ini_options] addopts = "--cov=omdb --cov-report term-missing" diff --git a/tests/api_test.py b/tests/api_test.py new file mode 100644 index 0000000..4e9731b --- /dev/null +++ b/tests/api_test.py @@ -0,0 +1,130 @@ +from unittest.mock import patch + +import pytest +from httpx import Client, MockTransport, QueryParams, Request, Response + +from omdb.api import OMDB_SEARCH_API, OmdbApi +from omdb.error import OmdbError +from omdb.result import SeriesExtendedResult + + +def test_omdb_api(): + """Tests the `OmdbApi` class initialization.""" + + client = Client() + omdb = OmdbApi("key", client) + assert omdb.api_key == "key" + assert omdb.client is client + + +def test_omdb_api_search_data(): + """Tests the `OmdbApi._search` method when data is returned.""" + + def handler(request: Request): + assert request.method == "GET" + assert request.url.copy_with(params=None) == OMDB_SEARCH_API + assert request.url.params == QueryParams(apikey="key") + return Response(200, json={"Response": "True"}) + + omdb = OmdbApi("key", Client(transport=MockTransport(handler))) + data = omdb._search(QueryParams()) + assert data == {"Response": "True"} + + +def test_omdb_api_search_error(): + """Tests the `OmdbApi._search` method when an error is raised.""" + + def handler(request: Request): + assert request.method == "GET" + assert request.url.copy_with(params=None) == OMDB_SEARCH_API + assert request.url.params == QueryParams(apikey="key") + return Response(404, json={"Response": "False", "Error": "Not Found"}) + + omdb = OmdbApi("key", Client(transport=MockTransport(handler))) + with pytest.raises(OmdbError, match="Not Found"): + omdb._search(QueryParams()) + + +def test_omdb_api_query_imdb_id(): + """Tests the `OmdbApi.query_imdb_id` method.""" + + def handler(request: Request): + assert request.method == "GET" + assert request.url.copy_with(params=None) == OMDB_SEARCH_API + assert request.url.params == QueryParams( + apikey="key", + i="tt0000000", + plot="short", + ) + return Response(200, json={}) + + omdb = OmdbApi("key", Client(transport=MockTransport(handler))) + + with patch("omdb.result.ExtendedResult.parse") as mock_parse: + omdb.query_imdb_id("tt0000000", plot_mode="short") + mock_parse.assert_called() + + +def test_omdb_api_query_title(): + """Tests the `OmdbApi.query_title` method.""" + + def handler(request: Request): + assert request.method == "GET" + assert request.url.copy_with(params=None) == OMDB_SEARCH_API + assert request.url.params == QueryParams( + apikey="key", + t="Title", + y="2000", + plot="short", + ) + return Response(200, json={}) + + omdb = OmdbApi("key", Client(transport=MockTransport(handler))) + + with patch("omdb.result.ExtendedResult.parse") as mock_parse: + omdb.query_title("Title", year=2000, plot_mode="short") + mock_parse.assert_called() + + +def test_omdb_api_query_movie_title(): + """Tests the `OmdbApi.query_movie_title` method.""" + + def handler(request: Request): + assert request.method == "GET" + assert request.url.copy_with(params=None) == OMDB_SEARCH_API + assert request.url.params == QueryParams( + apikey="key", + t="Title", + type="movie", + y="2000", + plot="short", + ) + return Response(200, json={}) + + omdb = OmdbApi("key", Client(transport=MockTransport(handler))) + + with patch("omdb.result.MovieExtendedResult.parse") as mock_parse: + omdb.query_movie_title("Title", year=2000, plot_mode="short") + mock_parse.assert_called() + + +def test_omdb_api_query_series_title(): + """Tests the `OmdbApi.query_series_title` method.""" + + def handler(request: Request): + assert request.method == "GET" + assert request.url.copy_with(params=None) == OMDB_SEARCH_API + assert request.url.params == QueryParams( + apikey="key", + t="Title", + type="series", + y="2000", + plot="short", + ) + return Response(200, json={}) + + omdb = OmdbApi("key", Client(transport=MockTransport(handler))) + + with patch("omdb.result.SeriesExtendedResult.parse") as mock_parse: + omdb.query_series_title("Title", year=2000, plot_mode="short") + mock_parse.assert_called() diff --git a/tests/error_test.py b/tests/error_test.py new file mode 100644 index 0000000..8b773f9 --- /dev/null +++ b/tests/error_test.py @@ -0,0 +1,9 @@ +from omdb.error import OmdbError + + +def test_omdb_error(): + """Tests the `OmdbError` class initialization.""" + + error = OmdbError(404, "Not Found") + assert error.status == 404 + assert str(error) == "Not Found" diff --git a/tests/result_test.py b/tests/result_test.py index b52f44e..bbf3741 100644 --- a/tests/result_test.py +++ b/tests/result_test.py @@ -1,4 +1,5 @@ from datetime import date +from unittest.mock import patch import pytest from httpx import URL @@ -21,96 +22,56 @@ from omdb.result import ( _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) + with patch("omdb.result.MovieSearchResult.parse") as mock_parse: + SearchResult.parse({"Type": "movie"}) + mock_parse.assert_called() - series_result = SearchResult.parse(mock_series_search_result) - assert series_result == SeriesSearchResult.parse(mock_series_search_result) + with patch("omdb.result.SeriesSearchResult.parse") as mock_parse: + SearchResult.parse({"Type": "series"}) + mock_parse.assert_called() - with pytest.raises( - NotImplementedError, - match='Result type "unknown" is not supported.', - ): + with pytest.raises(NotImplementedError): SearchResult.parse({"Type": "unknown"}) def test_movie_search_result_parse(): """Tests the `MovieSearchResult.parse` class method.""" - result = MovieSearchResult.parse(mock_movie_search_result) + result = MovieSearchResult.parse( + { + "Type": "movie", + "imdbID": "tt0000000", + "Title": "Title", + "Year": "2000", + "Poster": "https://example.com/poster.jpg", + } + ) assert result == MovieSearchResult( - title="Movie Title", imdb_id="tt0000000", - poster=URL("https://example.com/poster.jpg"), + title="Title", year=2000, + poster=URL("https://example.com/poster.jpg"), ) def test_series_search_result_parse(): """Tests the `SeriesSearchResult.parse` class method.""" - result = SeriesSearchResult.parse(mock_series_search_result) + result = SeriesSearchResult.parse( + { + "Type": "series", + "imdbID": "tt0000000", + "Title": "Title", + "Year": "2000–2010", + "Poster": "https://example.com/poster.jpg", + } + ) assert result == SeriesSearchResult( - title="Series Title", + title="Title", imdb_id="tt0000000", poster=URL("https://example.com/poster.jpg"), year_start=2000, @@ -152,16 +113,15 @@ def test_rating_nfo_element(): 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) + with patch("omdb.result.MovieExtendedResult.parse") as mock_parse: + ExtendedResult.parse({"Type": "movie"}) + mock_parse.assert_called() - series_result = ExtendedResult.parse(mock_series_extended_result) - assert series_result == SeriesExtendedResult.parse(mock_series_extended_result) + with patch("omdb.result.SeriesExtendedResult.parse") as mock_parse: + ExtendedResult.parse({"Type": "series"}) + mock_parse.assert_called() - with pytest.raises( - NotImplementedError, - match='Result type "unknown" is not supported.', - ): + with pytest.raises(NotImplementedError): ExtendedResult.parse({"Type": "unknown"}) @@ -230,29 +190,62 @@ def test_extended_result_nfo_subelements(): def test_movie_extended_result_parse(): """Tests the `MovieExtendedResult.parse` class method.""" - result = MovieExtendedResult.parse(mock_movie_extended_result) + result = MovieExtendedResult.parse( + { + "imdbID": "tt0000000", + "Title": "Title", + "Year": "2000", + "Rated": "R", + "Released": "01 Jan 2000", + "Genre": "Action, Adventure, Comedy", + "Plot": "Plot", + "Language": "English, Esperanto", + "Country": "United States, Canada", + "Poster": "https://example.com/poster.jpg", + "Ratings": [{"Source": "Internet Movie Database", "Value": "10.0/10"}], + "Runtime": "123 min", + "Director": "Alan Smithee", + "Writer": "Alan Smithee", + "Actors": "Jenna Maroney, Tracy Jordan", + } + ) assert result == MovieExtendedResult( - title="Movie Title", + title="Title", imdb_id="tt0000000", mpaa_rating="R", release_date=date(2000, 1, 1), genres={"Action", "Adventure", "Comedy"}, - plot="Example movie", + plot="Plot", language={"English", "Esperanto"}, - countries={"United States", "Molossia"}, + countries={"United States", "Canada"}, 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"}, + actors={"Jenna Maroney", "Tracy Jordan"}, ) def test_movie_extended_result_nfo_element(): """Tests the `MovieExtendedResult.nfo_element` method.""" - result = MovieExtendedResult.parse(mock_movie_extended_result) + result = MovieExtendedResult( + title="Title", + imdb_id="tt0000000", + mpaa_rating="R", + release_date=date(2000, 1, 1), + genres={"Action", "Adventure", "Comedy"}, + plot="Plot", + language={"English", "Esperanto"}, + countries={"United States", "Canada"}, + poster=URL("https://example.com/poster.jpg"), + ratings=[Rating("imdb", 10.0, 10)], + runtime=123, + directors={"Alan Smithee"}, + writers={"Alan Smithee"}, + actors={"Jenna Maroney", "Tracy Jordan"}, + ) element = result.nfo_element() assert element.tag == "movie" @@ -281,9 +274,9 @@ def test_movie_extended_result_nfo_element(): assert credits_elements[0].text == "Alan Smithee" actor_elements = element.findall("actor") - assert len(actor_elements) == 3 + assert len(actor_elements) == 2 assert any( - actor_element[0].tag == "name" and actor_element[0].text == "Anthony Hopkins" + actor_element[0].tag == "name" and actor_element[0].text == "Jenna Maroney" for actor_element in actor_elements ) @@ -291,16 +284,31 @@ def test_movie_extended_result_nfo_element(): 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", + result = SeriesExtendedResult.parse( + { + "imdbID": "tt0000000", + "Title": "Title", + "Year": "2000", + "Rated": "R", + "Released": "01 Jan 2000", + "Genre": "Action, Adventure, Comedy", + "Plot": "Plot", + "Language": "English, Esperanto", + "Country": "United States, Canada", + "Poster": "https://example.com/poster.jpg", + "Ratings": [{"Source": "Internet Movie Database", "Value": "10.0/10"}], + "totalSeasons": 10, + } + ) + assert result == SeriesExtendedResult( + title="Title", imdb_id="tt0000000", - mpaa_rating="TV-MA", + mpaa_rating="R", release_date=date(2000, 1, 1), genres={"Action", "Adventure", "Comedy"}, - plot="Example series", + plot="Plot", language={"English", "Esperanto"}, - countries={"United States", "Molossia"}, + countries={"United States", "Canada"}, poster=URL("https://example.com/poster.jpg"), ratings=[Rating("imdb", 10.0, 10)], total_seasons=10, @@ -310,7 +318,19 @@ def test_series_extended_result_parse(): def test_series_extended_result_nfo(): """Tests the `SeriesExtendedResult.nfo_element` method.""" - result = SeriesExtendedResult.parse(mock_series_extended_result) + result = SeriesExtendedResult( + title="Title", + imdb_id="tt0000000", + mpaa_rating="R", + release_date=date(2000, 1, 1), + genres={"Action", "Adventure", "Comedy"}, + plot="Plot", + language={"English", "Esperanto"}, + countries={"United States", "Canada"}, + poster=URL("https://example.com/poster.jpg"), + ratings=[Rating("imdb", 10.0, 10)], + total_seasons=10, + ) element = result.nfo_element() assert element.tag == "tvshow"