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