From 67ffafc849df0d12182e492191d490fffa628bf4 Mon Sep 17 00:00:00 2001 From: sergiormb Date: Wed, 17 Jun 2026 12:47:05 +0200 Subject: [PATCH] Prepare 0.1.0 usability release --- ...h-to-test-pypi.yml => publish-to-pypi.yml} | 3 +- CHANGES.rst | 12 + INSTALL.rst | 21 +- MANIFEST.in | 3 +- README.rst | 77 +++++- USAGE.rst | 29 ++- python_filmaffinity/__init__.py | 14 +- python_filmaffinity/__meta__.py | 2 +- python_filmaffinity/cli.py | 119 +++++++++ python_filmaffinity/client.py | 11 +- python_filmaffinity/exporters.py | 79 ++++++ python_filmaffinity/models.py | 88 +++++++ setup.py | 9 + tests/fixtures/card.html | 26 ++ tests/fixtures/detail.html | 29 +++ tests/fixtures/images.html | 11 + tests/fixtures/search.html | 30 +++ tests/fixtures/service.html | 30 +++ tests/fixtures/top.html | 32 +++ tests/test.py | 239 ++++++++++-------- 20 files changed, 743 insertions(+), 121 deletions(-) rename .github/workflows/{publish-to-test-pypi.yml => publish-to-pypi.yml} (85%) create mode 100644 python_filmaffinity/cli.py create mode 100644 python_filmaffinity/exporters.py create mode 100644 python_filmaffinity/models.py create mode 100644 tests/fixtures/card.html create mode 100644 tests/fixtures/detail.html create mode 100644 tests/fixtures/images.html create mode 100644 tests/fixtures/search.html create mode 100644 tests/fixtures/service.html create mode 100644 tests/fixtures/top.html diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-pypi.yml similarity index 85% rename from .github/workflows/publish-to-test-pypi.yml rename to .github/workflows/publish-to-pypi.yml index 74ea354..caafe05 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -1,6 +1,7 @@ name: Publish Python Package on: + workflow_dispatch: release: types: [created] @@ -24,5 +25,5 @@ jobs: - name: Check distributions run: twine check dist/* - name: Publish - if: ${{ vars.PUBLISH_TO_PYPI == 'true' }} + if: ${{ github.event_name == 'release' && vars.PUBLISH_TO_PYPI == 'true' }} uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGES.rst b/CHANGES.rst index a117867..107367b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,18 @@ Changelog ========= +v0.1.0 (17-06-2026) +******************* + +- Added the ``filmaffinity`` command line interface. +- Added optional Pydantic models via ``as_model=True`` and + ``python-filmaffinity[models]``. +- Added JSON, CSV and Markdown export helpers. +- Moved the default persistent cache to the user's cache directory. +- Moved parser tests to reusable HTML fixtures. +- Renamed the PyPI publishing workflow to ``publish-to-pypi.yml`` and added + manual build/check runs. + v0.0.21 (17-06-2026) ******************** diff --git a/INSTALL.rst b/INSTALL.rst index bba2034..528c38b 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -5,7 +5,13 @@ Pip *** :: - pip install python_filmaffinity + pip install python-filmaffinity + +Optional Pydantic models: + +:: + + pip install "python-filmaffinity[models]" From Source @@ -13,9 +19,9 @@ From Source :: - git clone git@github.com:sergiormb/python_filmaffinity.git` - cd python_filmaffinity` - python setup.py install` + git clone git@github.com:sergiormb/python_filmaffinity.git + cd python_filmaffinity + python -m pip install -e ".[dev]" Requirements @@ -23,5 +29,8 @@ Requirements :: - requests >= 2.0.1` - bs4 >= 0.0.1` + beautifulsoup4 >= 4.9.1 + requests >= 2.24.0 + requests-cache >= 1.0.0 + +Python 3.9 or newer is required. diff --git a/MANIFEST.in b/MANIFEST.in index ce6d838..5390ee2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include LICENSE.rst README.rst CHANGES.rst AUTHORS.rst EXAMPLES.rst \ No newline at end of file +include LICENSE.rst README.rst CHANGES.rst AUTHORS.rst EXAMPLES.rst +recursive-include tests/fixtures *.html diff --git a/README.rst b/README.rst index 6d50444..fdbbc24 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,7 @@ This is a simple python scraping for the FilmAffinity. :target: https://github.com/sergiormb/python_filmaffinity/actions/workflows/python-test.yml?query=branch%3Amaster .. image:: https://img.shields.io/github/license/mashape/apistatus.svg :target: https://github.com/sergiormb/python_filmaffinity/blob/master/LICENSE.rst -.. image:: https://img.shields.io/pypi/pyversions/Django.svg +.. image:: https://img.shields.io/pypi/pyversions/python-filmaffinity.svg :target: https://pypi.python.org/pypi/python_filmaffinity/ .. image:: https://readthedocs.org/projects/python-filmaffinity/badge/?version=latest :target: http://python-filmaffinity.readthedocs.io/en/latest/?badge=latest @@ -23,6 +23,12 @@ Pip pip install python-filmaffinity +Optional Pydantic models: + +:: + + pip install "python-filmaffinity[models]" + From Source *********** @@ -31,7 +37,7 @@ From Source git clone git@github.com:sergiormb/python_filmaffinity.git cd python_filmaffinity - python setup.py install + python -m pip install -e ".[dev]" Requirements @@ -39,9 +45,14 @@ Requirements :: - requests >= 2.0.1 - requests-cache >= 0.4.13 - bs4 >= 0.0.1 + beautifulsoup4 >= 4.9.1 + requests >= 2.24.0 + requests-cache >= 1.0.0 + +Python 3.9 or newer is required. + +FilmAffinity does not provide a public API for this package. The library uses +HTML scraping, so selectors can break when FilmAffinity changes its layout. Examples @@ -62,6 +73,53 @@ Examples ['Luis Tosar', 'Alberto Ammann', 'Antonio Resines', 'Carlos Bardem', 'Marta Etura', 'Vicente Romero', 'Manuel Morón', 'Manolo Solo', 'Fernando Soto', 'Luis Zahera', 'Patxi Bisquert', 'Félix Cubero', 'Josean Bengoetxea', 'Juan Carlos Mangas', 'Jesús Carroza'] +Optional models and exporters +***************************** + +The public API keeps returning dictionaries by default. Install the ``models`` +extra and pass ``as_model=True`` to receive Pydantic models: + +.. code-block:: python + + service = python_filmaffinity.FilmAffinity() + movie = service.get_movie(id='495280', as_model=True) + movie.title + +Export helpers accept dictionaries, lists and models: + +.. code-block:: python + + movies = service.search(title='Batman') + python_filmaffinity.to_json(movies, 'movies.json') + python_filmaffinity.to_csv(movies, 'movies.csv') + python_filmaffinity.to_markdown(movies, 'movies.md') + + +Command line +************ + +.. code-block:: bash + + filmaffinity search "Batman" --top 5 + filmaffinity movie 197671 --images --json + filmaffinity top --kind filmaffinity --top 10 + + +Development +*********** + +.. code-block:: bash + + python -m pip install -e ".[dev]" + python -m pytest + RUN_INTEGRATION=1 python -m pytest -m integration + python -m build + twine check dist/* + +The persistent sqlite cache is stored in the user's cache directory by default. +Use ``cache_backend="memory"`` for runs that should not write to disk. + + Usage ===== @@ -213,6 +271,15 @@ recommend HBO, Netflix, Filmin, Movistar, Rakuten Changelog ========= +v0.1.0 (17-06-2026) +******************* + +- Added the ``filmaffinity`` command line interface. +- Added optional Pydantic models via ``as_model=True``. +- Added JSON, CSV and Markdown export helpers. +- Moved the default persistent cache to the user's cache directory. +- Renamed the PyPI publishing workflow to ``publish-to-pypi.yml``. + v0.0.21 (17-06-2026) ******************** diff --git a/USAGE.rst b/USAGE.rst index 8144237..af9e4cb 100644 --- a/USAGE.rst +++ b/USAGE.rst @@ -42,6 +42,7 @@ search .. code-block:: python movies = service.search(cast='Nicolas Cage') + movie_models = service.search(title='Batman', as_model=True) get_movie @@ -65,6 +66,32 @@ get_movie movies = service.get_movie(title='Avatar') movies = service.get_movie(id='495280') + movie_model = service.get_movie(id='495280', as_model=True) + + +exporters +********* + +- Example + +.. code-block:: python + + movies = service.search(title='Batman') + python_filmaffinity.to_json(movies, 'movies.json') + python_filmaffinity.to_csv(movies, 'movies.csv') + python_filmaffinity.to_markdown(movies, 'movies.md') + + +command line +************ + +- Example + +.. code-block:: bash + + filmaffinity search "Batman" --top 5 + filmaffinity movie 197671 --images --json + filmaffinity top --kind filmaffinity --top 10 top_filmaffinity @@ -144,4 +171,4 @@ recommend HBO, Netflix, Filmin, Movistar, Rakuten movies = service.recommend_hbo() movies = service.recommend_filmin() movies = service.recommend_movistar() - movies = service.recommend_rakuten() \ No newline at end of file + movies = service.recommend_rakuten() diff --git a/python_filmaffinity/__init__.py b/python_filmaffinity/__init__.py index 7f48db1..138eb31 100644 --- a/python_filmaffinity/__init__.py +++ b/python_filmaffinity/__init__.py @@ -2,10 +2,12 @@ from .__meta__ import * from .client import Client from .config import FIELDS_TYPE +from .exporters import to_csv, to_json, to_markdown +from .models import _require_pydantic, movie_to_model, movies_to_models class FilmAffinity(Client): - def get_movie(self, trailer=False, images=False, **kwargs): + def get_movie(self, trailer=False, images=False, as_model=False, **kwargs): """Return a dictionary with the data of the movie. Args: @@ -16,6 +18,8 @@ def get_movie(self, trailer=False, images=False, **kwargs): Returns: TYPE: Dictionary with movie data """ + if as_model: + _require_pydantic() movie = {} if kwargs is not None: for key, value in iter(kwargs.items()): @@ -25,9 +29,9 @@ def get_movie(self, trailer=False, images=False, **kwargs): else: movie = self._get_movie_by_args( key, value, trailer, images) - return movie + return movie_to_model(movie) if as_model else movie - def search(self, top=10, **kwargs): + def search(self, top=10, as_model=False, **kwargs): """Return a list with the data of the movies. Args: @@ -57,6 +61,8 @@ def search(self, top=10, **kwargs): Returns: TYPE: Lis with movies data """ + if as_model: + _require_pydantic() top = 20 if top > 20 else top movies = [] if kwargs is not None: @@ -84,7 +90,7 @@ def search(self, top=10, **kwargs): url = self.url + 'advsearch.php?' + options page = self._load_url(url) movies = self._return_list_movies(page, 'search', top) - return movies + return movies_to_models(movies) if as_model else movies def top_filmaffinity(self, top=10, **kwargs): """Return a list with the top filmaffinity movies. diff --git a/python_filmaffinity/__meta__.py b/python_filmaffinity/__meta__.py index 2adf466..f4edee2 100644 --- a/python_filmaffinity/__meta__.py +++ b/python_filmaffinity/__meta__.py @@ -5,7 +5,7 @@ __summary__ = 'Python wrapper for FilmAffinity' __url__ = 'https://github.com/sergiormb/python_filmaffinity' -__version__ = '0.0.21' +__version__ = '0.1.0' __install_requires__ = [ 'beautifulsoup4>=4.9.1', diff --git a/python_filmaffinity/cli.py b/python_filmaffinity/cli.py new file mode 100644 index 0000000..ec6c430 --- /dev/null +++ b/python_filmaffinity/cli.py @@ -0,0 +1,119 @@ +"""Command line interface for python-filmaffinity.""" + +import argparse +import sys + +from . import FilmAffinity, to_json + + +TOP_METHODS = { + "filmaffinity": "top_filmaffinity", + "tv_series": "top_tv_series", + "premieres": "top_premieres", + "dvd": "top_dvd", + "netflix": "top_netflix", + "hbo": "top_hbo", + "filmin": "top_filmin", + "movistar": "top_movistar", + "rakuten": "top_rakuten", +} + + +def _build_service(args): + return FilmAffinity(lang=args.lang, cache_backend=args.cache_backend) + + +def _as_rows(data): + if isinstance(data, list): + return data + return [data] + + +def _print_table(data): + rows = _as_rows(data) + columns = ["id", "title", "year", "rating"] + widths = { + column: max( + len(column), + *[len(str(row.get(column, ""))) for row in rows if isinstance(row, dict)], + ) + for column in columns + } + header = " ".join(column.upper().ljust(widths[column]) for column in columns) + print(header) + print(" ".join("-" * widths[column] for column in columns)) + for row in rows: + print( + " ".join( + str(row.get(column, "") if row.get(column, "") is not None else "").ljust( + widths[column] + ) + for column in columns + ) + ) + + +def _print_result(data, as_json): + if as_json: + print(to_json(data)) + else: + _print_table(data) + + +def _search(args): + service = _build_service(args) + result = service.search(title=args.title, top=args.top) + _print_result(result, args.json) + + +def _movie(args): + service = _build_service(args) + result = service.get_movie(id=args.id, images=args.images, trailer=args.trailer) + _print_result(result, args.json) + + +def _top(args): + service = _build_service(args) + method = getattr(service, TOP_METHODS[args.kind]) + result = method(top=args.top) + _print_result(result, args.json) + + +def build_parser(): + parser = argparse.ArgumentParser(prog="filmaffinity") + parser.add_argument("--lang", default="es") + parser.add_argument("--cache-backend", default="sqlite") + + subparsers = parser.add_subparsers(dest="command", required=True) + + search = subparsers.add_parser("search") + search.add_argument("title") + search.add_argument("--top", type=int, default=10) + search.add_argument("--json", action="store_true") + search.set_defaults(func=_search) + + movie = subparsers.add_parser("movie") + movie.add_argument("id") + movie.add_argument("--images", action="store_true") + movie.add_argument("--trailer", action="store_true") + movie.add_argument("--json", action="store_true") + movie.set_defaults(func=_movie) + + top = subparsers.add_parser("top") + top.add_argument("--kind", choices=sorted(TOP_METHODS), default="filmaffinity") + top.add_argument("--top", type=int, default=10) + top.add_argument("--json", action="store_true") + top.set_defaults(func=_top) + + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + args.func(args) + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/python_filmaffinity/client.py b/python_filmaffinity/client.py index 27940c8..1f52a04 100644 --- a/python_filmaffinity/client.py +++ b/python_filmaffinity/client.py @@ -8,7 +8,8 @@ from bs4 import BeautifulSoup from inspect import getsourcefile -from os.path import join, dirname, abspath +from os import makedirs, getenv, name as os_name +from os.path import join, dirname, abspath, expanduser from .config import FIELDS_MOVIE from .pages import DetailPage, SearchPage, TopPage, TopServicePage, ImagesPage from .exceptions import ( @@ -106,7 +107,13 @@ def _get_cache_file(self, cache_path=None): elif cache_path: c = join(cache_path, "cache-film-affinity") else: - c = join(current_folder, "cache-film-affinity") + if os_name == "nt": + cache_dir = getenv("LOCALAPPDATA") or expanduser("~\\AppData\\Local") + else: + cache_dir = getenv("XDG_CACHE_HOME") or join(expanduser("~"), ".cache") + c = join(cache_dir, "python-filmaffinity", "cache-film-affinity") + if c and self.cache_backend not in ['memory']: + makedirs(dirname(c), exist_ok=True) return c def _get_requests_session(self): diff --git a/python_filmaffinity/exporters.py b/python_filmaffinity/exporters.py new file mode 100644 index 0000000..d233470 --- /dev/null +++ b/python_filmaffinity/exporters.py @@ -0,0 +1,79 @@ +"""Export helpers for FilmAffinity results.""" + +import csv +import io +import json + + +def _as_plain(value): + if hasattr(value, "model_dump"): + return value.model_dump() + if hasattr(value, "dict") and callable(value.dict): + return value.dict() + if isinstance(value, list): + return [_as_plain(item) for item in value] + if isinstance(value, tuple): + return [_as_plain(item) for item in value] + if isinstance(value, dict): + return {key: _as_plain(item) for key, item in value.items()} + return value + + +def _rows(data): + plain = _as_plain(data) + if plain is None: + return [] + if isinstance(plain, list): + return plain + return [plain] + + +def _stringify_cell(value): + if isinstance(value, (dict, list, tuple)): + return json.dumps(value, ensure_ascii=False, sort_keys=True) + if value is None: + return "" + return str(value) + + +def _write_or_return(content, path): + if path is None: + return content + with open(path, "w", encoding="utf-8", newline="") as file_handle: + file_handle.write(content) + return content + + +def to_json(data, path=None): + """Export a movie or movie list as formatted JSON.""" + content = json.dumps(_as_plain(data), ensure_ascii=False, indent=2) + return _write_or_return(content, path) + + +def to_csv(data, path): + """Export a movie or movie list to CSV.""" + rows = _rows(data) + fieldnames = sorted({key for row in rows if isinstance(row, dict) for key in row}) + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow({key: _stringify_cell(row.get(key)) for key in fieldnames}) + return _write_or_return(buffer.getvalue(), path) + + +def to_markdown(data, path=None): + """Export a movie or movie list as a Markdown table.""" + rows = _rows(data) + fieldnames = sorted({key for row in rows if isinstance(row, dict) for key in row}) + if not fieldnames: + return _write_or_return("", path) + lines = [ + "| " + " | ".join(fieldnames) + " |", + "| " + " | ".join(["---"] * len(fieldnames)) + " |", + ] + for row in rows: + lines.append( + "| " + " | ".join(_stringify_cell(row.get(key)) for key in fieldnames) + " |" + ) + return _write_or_return("\n".join(lines) + "\n", path) diff --git a/python_filmaffinity/models.py b/python_filmaffinity/models.py new file mode 100644 index 0000000..fc238c9 --- /dev/null +++ b/python_filmaffinity/models.py @@ -0,0 +1,88 @@ +"""Optional Pydantic models for FilmAffinity results.""" + +from typing import Any, Dict, List, Optional + + +INSTALL_EXTRA = "pip install python-filmaffinity[models]" + + +try: + from pydantic import BaseModel, Field + try: + from pydantic import ConfigDict + except ImportError: + ConfigDict = None +except ImportError: # pragma: no cover - exercised via import hook tests + BaseModel = None + Field = None + ConfigDict = None + + +def _require_pydantic(): + if BaseModel is None: + raise ImportError( + "Pydantic is required for as_model=True. " + "Install it with: {}".format(INSTALL_EXTRA) + ) + + +class _BaseMovieModel(BaseModel if BaseModel is not None else object): + if ConfigDict is not None: + model_config = ConfigDict(extra="allow") + elif BaseModel is not None: + class Config: + extra = "allow" + + +def _list_default(): + return Field(default_factory=list) if Field is not None else [] + + +class Image(_BaseMovieModel): + image: Optional[str] = None + thumbnail: Optional[str] = None + country: Optional[str] = None + + +class MovieImages(_BaseMovieModel): + posters: List[Image] = _list_default() + stills: List[Image] = _list_default() + promo: List[Image] = _list_default() + events: List[Image] = _list_default() + shootings: List[Image] = _list_default() + + +class Movie(_BaseMovieModel): + id: Optional[str] = None + title: Optional[str] = None + original_title: Optional[str] = None + year: Optional[str] = None + duration: Optional[str] = None + rating: Optional[float] = None + votes: Optional[Any] = None + description: Optional[str] = None + directors: Optional[List[str]] = None + writers: Optional[List[str]] = None + music: Optional[List[str]] = None + cinematography: Optional[List[str]] = None + actors: Optional[List[str]] = None + producers: Optional[List[str]] = None + poster: Optional[str] = None + country: Optional[str] = None + genre: Optional[Any] = None + awards: Optional[List[Dict[str, Any]]] = None + reviews: Optional[List[Dict[str, Any]]] = None + images: Optional[MovieImages] = None + trailer: Optional[List[str]] = None + + +def movie_to_model(movie): + """Convert a movie dict to a Pydantic Movie model.""" + _require_pydantic() + return Movie(**movie) + + +def movies_to_models(movies): + """Convert a list of movie dicts to Pydantic Movie models.""" + _require_pydantic() + return [movie_to_model(movie) for movie in movies] diff --git a/setup.py b/setup.py index b87a687..e590800 100644 --- a/setup.py +++ b/setup.py @@ -28,12 +28,21 @@ def read(fname): install_requires=meta['__install_requires__'], python_requires='>=3.9', extras_require={ + 'models': [ + 'pydantic>=1.10', + ], 'dev': [ 'build>=1.0', + 'pydantic>=1.10', 'pytest>=8.0', 'twine>=5.0', ], }, + entry_points={ + 'console_scripts': [ + 'filmaffinity=python_filmaffinity.cli:main', + ], + }, zip_safe=False, # use the URL to the github repo download_url='https://github.com/sergiormb/python_filmaffinity/tarball/' \ diff --git a/tests/fixtures/card.html b/tests/fixtures/card.html new file mode 100644 index 0000000..3b01206 --- /dev/null +++ b/tests/fixtures/card.html @@ -0,0 +1,26 @@ +
+
+
+ +
+ + Estados Unidos + 2008 +
8,1
+
158.246
+
152 min.
+ Accion + Batman se enfrenta al Joker. + + +
+
diff --git a/tests/fixtures/detail.html b/tests/fixtures/detail.html new file mode 100644 index 0000000..719902c --- /dev/null +++ b/tests/fixtures/detail.html @@ -0,0 +1,29 @@ + +
+
+ Piratas del Caribe: La venganza de Salazar +
+
Titulo originalPirates of the Caribbean
+
Guion
Jeff Nathanson
+
Musica
Geoff Zanelli
+
Fotografia
Paul Cameron
+
+ +
129 min.
+
Jack Sparrow vuelve al mar.
+
+ + Joachim Ronning +
  • Johnny Depp
  • + + Estados Unidos + Aventuras +
    Jerry Bruckheimer
    +
    2017 - Premio de prueba
    +
    + +
    Una critica.
    + Review +
    +
    + diff --git a/tests/fixtures/images.html b/tests/fixtures/images.html new file mode 100644 index 0000000..515d916 --- /dev/null +++ b/tests/fixtures/images.html @@ -0,0 +1,11 @@ + +
    +
    +
    + +
    +
    +
    +
    + diff --git a/tests/fixtures/search.html b/tests/fixtures/search.html new file mode 100644 index 0000000..afe01d6 --- /dev/null +++ b/tests/fixtures/search.html @@ -0,0 +1,30 @@ + +
  • +
    +
    +
    + +
    + + Estados Unidos + 2008 +
    8,1
    +
    158.246
    +
    152 min.
    + Accion + Batman se enfrenta al Joker. + + +
    +
    +
  • + diff --git a/tests/fixtures/service.html b/tests/fixtures/service.html new file mode 100644 index 0000000..ca46f61 --- /dev/null +++ b/tests/fixtures/service.html @@ -0,0 +1,30 @@ + +
  • +
    +
    +
    + +
    + + Estados Unidos + 2008 +
    8,1
    +
    158.246
    +
    152 min.
    + Accion + Batman se enfrenta al Joker. + + +
    +
    +
  • + diff --git a/tests/fixtures/top.html b/tests/fixtures/top.html new file mode 100644 index 0000000..b2b1f6a --- /dev/null +++ b/tests/fixtures/top.html @@ -0,0 +1,32 @@ + + + diff --git a/tests/test.py b/tests/test.py index ad3c3cb..d4c6bd4 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,10 +1,15 @@ +import csv +import importlib +import json import os +from pathlib import Path from types import SimpleNamespace import pytest import python_filmaffinity import python_filmaffinity.__meta__ as meta_test +from python_filmaffinity import cli from python_filmaffinity.config import FIELDS_PAGE_DETAIL, FIELDS_PAGE_MOVIES from python_filmaffinity.exceptions import ( FilmAffinityConnectionError, @@ -13,100 +18,11 @@ ) -CARD_HTML = """ -
    -
    -
    - -
    - - Estados Unidos - 2008 -
    8,1
    -
    158.246
    -
    152 min.
    - Accion - Batman se enfrenta al Joker. - - -
    -
    -""" - -SEARCH_HTML = f""" - -
  • {CARD_HTML}
  • - -""" - -TOP_HTML = f""" - - - -""" - -SERVICE_HTML = f""" - -
  • {CARD_HTML}
  • - -""" - -DETAIL_HTML = """ - -
    -
    - Piratas del Caribe: La venganza de Salazar -
    -
    Titulo originalPirates of the Caribbean
    -
    Guion
    Jeff Nathanson
    -
    Musica
    Geoff Zanelli
    -
    Fotografia
    Paul Cameron
    -
    - -
    129 min.
    -
    Jack Sparrow vuelve al mar.
    -
    - - Joachim Ronning -
  • Johnny Depp
  • - - Estados Unidos - Aventuras -
    Jerry Bruckheimer
    -
    2017 - Premio de prueba
    -
    - -
    Una critica.
    - Review -
    -
    - -""" - -IMAGES_HTML = """ - -
    -
    -
    - -
    -
    -
    -
    - -""" +FIXTURES = Path(__file__).parent / "fixtures" + + +def fixture(name): + return (FIXTURES / name).read_text(encoding="utf-8") def response(html): @@ -128,22 +44,65 @@ def assert_movie_list_shape(movies, exact_fixture=True): def test_search_parses_current_movie_cards(monkeypatch): service = python_filmaffinity.FilmAffinity(cache_backend="memory") - monkeypatch.setattr(service, "_load_url", lambda *args, **kwargs: response(SEARCH_HTML)) + monkeypatch.setattr( + service, "_load_url", lambda *args, **kwargs: response(fixture("search.html")) + ) assert_movie_list_shape(service.search(title="Batman")) +def test_search_can_return_pydantic_models(monkeypatch): + pytest.importorskip("pydantic") + service = python_filmaffinity.FilmAffinity(cache_backend="memory") + monkeypatch.setattr( + service, "_load_url", lambda *args, **kwargs: response(fixture("search.html")) + ) + + movies = service.search(title="Batman", as_model=True) + + assert movies[0].id == "867354" + assert movies[0].title == "El caballero oscuro" + + +def test_as_model_requires_pydantic(monkeypatch): + models = importlib.import_module("python_filmaffinity.models") + monkeypatch.setattr(models, "BaseModel", None) + + with pytest.raises(ImportError, match=r"python-filmaffinity\[models\]"): + models.movie_to_model({"id": "1"}) + + +def test_as_model_fails_before_request_when_pydantic_is_missing(monkeypatch): + models = importlib.import_module("python_filmaffinity.models") + monkeypatch.setattr(models, "BaseModel", None) + service = python_filmaffinity.FilmAffinity(cache_backend="memory") + monkeypatch.setattr( + service, + "_load_url", + lambda *args, **kwargs: pytest.fail("as_model should fail before requests"), + ) + + with pytest.raises(ImportError, match=r"python-filmaffinity\[models\]"): + service.get_movie(id="197671", as_model=True) + + def test_top_filmaffinity_parses_current_cards(monkeypatch): service = python_filmaffinity.FilmAffinity(cache_backend="memory") - monkeypatch.setattr(service, "_load_url", lambda *args, **kwargs: response(TOP_HTML)) + monkeypatch.setattr( + service, "_load_url", lambda *args, **kwargs: response(fixture("top.html")) + ) assert_movie_list_shape(service.top_filmaffinity()) def test_top_service_and_recommend_parse_current_cards(monkeypatch): service = python_filmaffinity.FilmAffinity(cache_backend="memory") - monkeypatch.setattr(service, "_load_url", lambda *args, **kwargs: response(SERVICE_HTML)) - monkeypatch.setattr(service, "_get_movie_by_id", lambda id, trailer=False, images=False: {"id": id}) + monkeypatch.setattr( + service, "_load_url", lambda *args, **kwargs: response(fixture("service.html")) + ) + monkeypatch.setattr( + service, "_get_movie_by_id", lambda id, trailer=False, images=False: {"id": id} + ) assert_movie_list_shape(service.top_netflix()) assert service.recommend_netflix() == {"id": "867354"} @@ -154,8 +113,8 @@ def test_get_movie_by_id_with_images(monkeypatch): def fake_load_url(url, *args, **kwargs): if "filmimages.php" in url: - return response(IMAGES_HTML) - return response(DETAIL_HTML) + return response(fixture("images.html")) + return response(fixture("detail.html")) monkeypatch.setattr(service, "_load_url", fake_load_url) movie = service.get_movie(id="197671", images=True) @@ -169,6 +128,86 @@ def fake_load_url(url, *args, **kwargs): assert movie["images"]["posters"][0]["image"].endswith("poster-large.jpg") +def test_get_movie_can_return_pydantic_model(monkeypatch): + pytest.importorskip("pydantic") + service = python_filmaffinity.FilmAffinity(cache_backend="memory") + monkeypatch.setattr( + service, "_load_url", lambda *args, **kwargs: response(fixture("detail.html")) + ) + + movie = service.get_movie(id="197671", as_model=True) + + assert movie.id == "197671" + assert movie.title == "Piratas del Caribe: La venganza de Salazar" + + +def test_export_helpers(tmp_path): + data = [{"id": "867354", "title": "El caballero oscuro", "rating": 8.1}] + + assert json.loads(python_filmaffinity.to_json(data))[0]["id"] == "867354" + + csv_path = tmp_path / "movies.csv" + python_filmaffinity.to_csv(data, csv_path) + with csv_path.open(encoding="utf-8") as file_handle: + rows = list(csv.DictReader(file_handle)) + assert rows[0]["title"] == "El caballero oscuro" + + markdown = python_filmaffinity.to_markdown(data) + assert "| id | rating | title |" in markdown + assert "El caballero oscuro" in markdown + + +def test_default_sqlite_cache_uses_user_cache(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + service = python_filmaffinity.FilmAffinity(cache_backend="sqlite") + + assert service.cache_path.startswith(str(tmp_path)) + assert "python-filmaffinity" in service.cache_path + assert "python_filmaffinity/cache-film-affinity" not in service.cache_path + + +class FakeFilmAffinity: + def __init__(self, lang="es", cache_backend="sqlite"): + self.lang = lang + self.cache_backend = cache_backend + + def search(self, title, top=10): + return [{"id": "867354", "title": title, "year": "2008", "rating": 8.1}] + + def get_movie(self, id, images=False, trailer=False): + return [{"id": id, "title": "Movie", "year": "2017", "rating": 6.2}][0] + + def top_filmaffinity(self, top=10): + return [{"id": "809297", "title": "El padrino", "year": "1972", "rating": 9.0}] + + +def test_cli_search_json(monkeypatch, capsys): + monkeypatch.setattr(cli, "FilmAffinity", FakeFilmAffinity) + + assert cli.main(["search", "Batman", "--top", "1", "--json"]) == 0 + + output = json.loads(capsys.readouterr().out) + assert output[0]["title"] == "Batman" + + +def test_cli_movie_table(monkeypatch, capsys): + monkeypatch.setattr(cli, "FilmAffinity", FakeFilmAffinity) + + assert cli.main(["movie", "197671"]) == 0 + + output = capsys.readouterr().out + assert "TITLE" in output + assert "Movie" in output + + +def test_cli_top(monkeypatch, capsys): + monkeypatch.setattr(cli, "FilmAffinity", FakeFilmAffinity) + + assert cli.main(["top", "--kind", "filmaffinity", "--top", "1"]) == 0 + + assert "El padrino" in capsys.readouterr().out + + def test_invalid_language(): with pytest.raises(FilmAffinityInvalidLanguage): python_filmaffinity.FilmAffinity(lang="abc")