├── .flake8 ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── podsearch ├── __init__.py ├── http.py └── searcher.py ├── pyproject.toml ├── tests ├── __init__.py ├── test_get.py ├── test_http.py └── test_search.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | max-complexity = 5 -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [3.7, 3.8, 3.9] 16 | 17 | env: 18 | USING_COVERAGE: "3.9" 19 | 20 | steps: 21 | - name: Checkout sources 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | make deps 32 | 33 | - name: Run tox 34 | run: | 35 | make tox 36 | 37 | - name: Upload coverage to Codecov 38 | uses: codecov/codecov-action@v1 39 | if: contains(env.USING_COVERAGE, matrix.python-version) 40 | with: 41 | fail_ci_if_error: true 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout sources 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: "3.9" 18 | 19 | - name: Install dependencies 20 | run: | 21 | make deps 22 | 23 | - name: Publish to PyPi 24 | env: 25 | FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} 26 | FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 27 | run: | 28 | make publish 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | .vscode/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.3.1 (2021-04-06) 2 | 3 | Switched from Travis CI to Github Actions. Otherwise, nothing changed. 4 | 5 | # 0.3.0 (2020-07-03) 6 | 7 | ### Features 8 | 9 | - Get podcast by iTunes ID. 10 | - Default search country is US. 11 | 12 | # 0.2.0 (2020-07-01) 13 | 14 | ### Features 15 | 16 | - Filter by country and return episode count 17 | 18 | # 0.1.0 (2020-05-13) 19 | 20 | ### Features 21 | 22 | - Search iTunes 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Anton Zhiyanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | .PHONY: coverage deps help lint publish push test tox 3 | 4 | coverage: ## Run tests with coverage 5 | python -m coverage erase 6 | python -m coverage run --include=podsearch/* -m pytest -ra 7 | python -m coverage report -m 8 | 9 | deps: ## Install dependencies 10 | python -m pip install --upgrade pip 11 | python -m pip install black coverage flake8 flit mccabe mypy pylint pytest tox tox-gh-actions 12 | 13 | lint: ## Lint and static-check 14 | python -m flake8 podsearch 15 | python -m pylint podsearch 16 | python -m mypy podsearch 17 | 18 | publish: ## Publish to PyPi 19 | python -m flit publish 20 | 21 | push: ## Push code with tags 22 | git push && git push --tags 23 | 24 | test: ## Run tests 25 | python -m pytest -ra 26 | 27 | tox: ## Run tox 28 | python -m tox 29 | 30 | help: ## Show help message 31 | @IFS=$$'\n' ; \ 32 | help_lines=(`fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##/:/'`); \ 33 | printf "%s\n\n" "Usage: make [task]"; \ 34 | printf "%-20s %s\n" "task" "help" ; \ 35 | printf "%-20s %s\n" "------" "----" ; \ 36 | for help_line in $${help_lines[@]}; do \ 37 | IFS=$$':' ; \ 38 | help_split=($$help_line) ; \ 39 | help_command=`echo $${help_split[0]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \ 40 | help_info=`echo $${help_split[2]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \ 41 | printf '\033[36m'; \ 42 | printf "%-20s %s" $$help_command ; \ 43 | printf '\033[0m'; \ 44 | printf "%s\n" $$help_info; \ 45 | done -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Podcast searcher 2 | 3 | > Search any podcast in iTunes library 4 | 5 | [![PyPI Version][pypi-image]][pypi-url] 6 | [![Build Status][build-image]][build-url] 7 | [![Code Coverage][coverage-image]][coverage-url] 8 | [![Code Quality][quality-image]][quality-url] 9 | 10 | `podsearch` finds podcasts via [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/index.html). 11 | 12 | Search parameters: 13 | 14 | - _query_ - search string (name, author etc) 15 | - _country_ - ISO alpha-2 country code (us, de, fr etc), default: us 16 | - _limit_ - maximum number or search results, default: 5 17 | 18 | Returned attributes for each podcast: 19 | 20 | - _id_ - iTunes ID (e.g., `979020229`) 21 | - _name_ - podcast name (`Talk Python To Me`) 22 | - _author_ - author name (`Michael Kennedy (@mkennedy)`) 23 | - _url_ - Apple Podcasts URL (`https://podcasts.apple.com/us/podcast/id979020229`) 24 | - _feed_ - podcast RSS URL (`https://talkpython.fm/episodes/rss`) 25 | - _category_ - main category (`Technology`) 26 | - _image_ - 600x600px image URL (`https://is4-ssl.mzstatic.com/image/.../600x600bb.jpg`) 27 | - _country_ - ISO alpha-3 country code (`USA`) 28 | - _episode_count_ - episode count this year (`26`) 29 | 30 | ## Installation 31 | 32 | ```sh 33 | pip install podsearch 34 | ``` 35 | 36 | ## Usage 37 | 38 | Search podcasts by query: 39 | 40 | ```python 41 | >>> import podsearch 42 | >>> podcasts = podsearch.search("python", country="us", limit=10) 43 | >>> podcasts[0].name 44 | 'Talk Python To Me' 45 | >>> podcasts[0].author 46 | 'Michael Kennedy (@mkennedy)' 47 | >>> podcasts[0].url 48 | 'https://podcasts.apple.com/us/podcast/id979020229' 49 | ``` 50 | 51 | Retrieve podcast by iTunes ID: 52 | 53 | ```python 54 | >>> import podsearch 55 | >>> podcast = podsearch.get(979020229) 56 | >>> podcast.name 57 | 'Talk Python To Me' 58 | ``` 59 | 60 | ## Development setup 61 | 62 | ```sh 63 | $ python3 -m venv env 64 | $ . env/bin/activate 65 | $ make deps 66 | $ tox 67 | ``` 68 | 69 | ## Contributing 70 | 71 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 72 | 73 | Make sure to add or update tests as appropriate. 74 | 75 | Use [Black](https://black.readthedocs.io/en/stable/) for code formatting and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) for commit messages. 76 | 77 | ## [Changelog](CHANGELOG.md) 78 | 79 | ## License 80 | 81 | [MIT](https://choosealicense.com/licenses/mit/) 82 | 83 | 84 | 85 | [pypi-image]: https://img.shields.io/pypi/v/podsearch 86 | [pypi-url]: https://pypi.org/project/podsearch/ 87 | [build-image]: https://github.com/nalgeon/podsearch-py/actions/workflows/build.yml/badge.svg 88 | [build-url]: https://github.com/nalgeon/podsearch-py/actions/workflows/build.yml 89 | [coverage-image]: https://codecov.io/gh/nalgeon/podsearch-py/branch/main/graph/badge.svg 90 | [coverage-url]: https://codecov.io/gh/nalgeon/podsearch-py 91 | [quality-image]: https://api.codeclimate.com/v1/badges/3130fa0ba3b7993fbf0a/maintainability 92 | [quality-url]: https://codeclimate.com/github/nalgeon/podsearch-py 93 | -------------------------------------------------------------------------------- /podsearch/__init__.py: -------------------------------------------------------------------------------- 1 | """Let's find some podcasts!""" 2 | from podsearch.searcher import get, search, Podcast # noqa 3 | 4 | __version__ = "0.3.1" 5 | -------------------------------------------------------------------------------- /podsearch/http.py: -------------------------------------------------------------------------------- 1 | """HTTP requests wrapper.""" 2 | 3 | from typing import Optional 4 | import json 5 | import urllib.request 6 | import urllib.parse 7 | from urllib.error import ( 8 | HTTPError, 9 | URLError, 10 | ) 11 | 12 | 13 | def get(url: str, params: Optional[dict] = None) -> dict: 14 | """Perform HTTP GET request and return response as JSON""" 15 | try: 16 | query_str = urllib.parse.urlencode(params or {}) 17 | req = urllib.request.Request(f"{url}?{query_str}") 18 | with urllib.request.urlopen(req) as response: 19 | return json.loads(response.read()) 20 | except HTTPError as exc: 21 | raise Exception(f"HTTP error {exc.code}: {exc.reason}") from exc 22 | except URLError as exc: 23 | raise Exception(f"Network error: {exc.reason}") from exc 24 | except json.JSONDecodeError as exc: 25 | raise Exception(f"Failed to parse response: {exc}") from exc 26 | -------------------------------------------------------------------------------- /podsearch/searcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | Podcast searching via iTunes. 3 | See https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/index.html # noqa: E501 pylint: disable=line-too-long 4 | for iTunes API Description. 5 | """ 6 | 7 | from dataclasses import dataclass 8 | from typing import List, Optional 9 | from podsearch import http 10 | 11 | BASE_URL = "https://itunes.apple.com" 12 | SEARCH_URL = f"{BASE_URL}/search" 13 | GET_URL = f"{BASE_URL}/lookup" 14 | URL_TEMPLATE = "https://podcasts.apple.com/us/podcast/id{}" 15 | 16 | 17 | # pylint: disable=too-many-instance-attributes 18 | @dataclass 19 | class Podcast: 20 | """Podcast metadata.""" 21 | 22 | # see https://github.com/schemaorg/schemaorg/issues/373 23 | id: int # pylint: disable=invalid-name 24 | name: str 25 | author: str 26 | url: str 27 | feed: Optional[str] = None 28 | category: Optional[str] = None 29 | image: Optional[str] = None 30 | country: Optional[str] = None 31 | episode_count: Optional[int] = None 32 | 33 | 34 | # pylint: disable=too-few-public-methods 35 | class ItunesPodcast: 36 | """iTunes podcast description.""" 37 | 38 | def __init__(self, source: dict): 39 | self._source = source 40 | 41 | def as_podcast(self) -> Podcast: 42 | """Converts iTunes description to Podcast object.""" 43 | 44 | id_ = self._source["collectionId"] 45 | name = self._source["collectionName"] 46 | author = self._source["artistName"] 47 | url = URL_TEMPLATE.format(id_) 48 | podcast = Podcast(id=id_, name=name, author=author, url=url) 49 | podcast.feed = self._source.get("feedUrl") 50 | podcast.category = self._source.get("primaryGenreName") 51 | podcast.image = self._source.get("artworkUrl600") 52 | podcast.country = self._source.get("country") 53 | podcast.episode_count = self._source.get("trackCount") 54 | return podcast 55 | 56 | 57 | class ItunesResults: 58 | """iTunes search results collection.""" 59 | 60 | def __init__(self, source: dict): 61 | self.items = source.get("results", []) 62 | 63 | def as_podcasts(self) -> List[Podcast]: 64 | """Converts iTunes search results to Podcast list.""" 65 | 66 | podcast_items = filter(ItunesResults._is_podcast, self.items) 67 | return [ItunesPodcast(item).as_podcast() for item in podcast_items] 68 | 69 | @staticmethod 70 | def _is_podcast(item): 71 | return item.get("wrapperType") == "track" and item.get("kind") == "podcast" 72 | 73 | 74 | def search(query: str, country: str = "us", limit: int = 5) -> List[Podcast]: 75 | """ 76 | Search podcast by query. 77 | 78 | Arguments: 79 | query -- search string (name, author etc) 80 | country -- ISO alpha-2 country code 81 | limit -- max number or search results 82 | """ 83 | 84 | params = {"term": query, "country": country, "limit": limit, "media": "podcast"} 85 | response = http.get(url=SEARCH_URL, params=params) 86 | return ItunesResults(response).as_podcasts() 87 | 88 | 89 | def get(ident: int) -> Optional[Podcast]: 90 | """Get podcast by iTunes ID.""" 91 | 92 | params = {"id": ident} 93 | response = http.get(url=GET_URL, params=params) 94 | podcasts = ItunesResults(response).as_podcasts() 95 | return podcasts[0] if podcasts else None 96 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "podsearch" 7 | author = "Anton Zhiyanov" 8 | author-email = "m@antonz.ru" 9 | home-page = "https://github.com/nalgeon/podsearch-py" 10 | requires-python = ">=3.7" 11 | description-file = "README.md" 12 | classifiers = [ 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python :: 3", 15 | ] 16 | 17 | [tool.black] 18 | line-length = 100 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nalgeon/podsearch-py/c4d6d3f65d7bd190007424eca4cdf3a197719cd5/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_get.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | import pytest 3 | import podsearch 4 | 5 | 6 | def test_get(): 7 | with patch("podsearch.http.get") as mock: 8 | mock.return_value = { 9 | "resultCount": 1, 10 | "results": [ 11 | { 12 | "wrapperType": "track", 13 | "kind": "podcast", 14 | "collectionId": 979020229, 15 | "artistName": "Michael Kennedy (@mkennedy)", 16 | "collectionName": "Talk Python To Me", 17 | "collectionViewUrl": "https://podcasts.apple.com/us/podcast/id979020229?uo=4", 18 | "feedUrl": "https://talkpython.fm/episodes/rss", 19 | "primaryGenreName": "Technology", 20 | "artworkUrl600": "https://whatever/image/979020229.png", 21 | "trackCount": 26, 22 | "country": "USA", 23 | } 24 | ], 25 | } 26 | 27 | podcast = podsearch.get(979020229) 28 | assert isinstance(podcast, podsearch.Podcast) 29 | assert podcast.id == 979020229 30 | assert podcast.name == "Talk Python To Me" 31 | assert podcast.author == "Michael Kennedy (@mkennedy)" 32 | assert podcast.url == "https://podcasts.apple.com/us/podcast/id979020229" 33 | assert podcast.feed == "https://talkpython.fm/episodes/rss" 34 | assert podcast.category == "Technology" 35 | assert podcast.image == "https://whatever/image/979020229.png" 36 | assert podcast.country == "USA" 37 | assert podcast.episode_count == 26 38 | 39 | 40 | def test_invalid_kind(): 41 | with patch("podsearch.http.get") as mock: 42 | mock.return_value = { 43 | "resultCount": 1, 44 | "results": [ 45 | { 46 | "kind": "ebook", 47 | "trackId": 1435797751, 48 | "trackName": "Python Programming For Beginners", 49 | } 50 | ], 51 | } 52 | podcast = podsearch.get(1435797751) 53 | assert podcast is None 54 | 55 | 56 | def test_nothing_found(): 57 | with patch("podsearch.http.get") as mock: 58 | mock.return_value = {"resultCount": 0, "results": []} 59 | podcast = podsearch.get(979020229) 60 | assert podcast is None 61 | 62 | 63 | def test_failed(): 64 | with patch("podsearch.http.get") as mock: 65 | mock.side_effect = Exception() 66 | with pytest.raises(Exception): 67 | podsearch.get(979020229) 68 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | from urllib.error import HTTPError, URLError 3 | import pytest 4 | from podsearch import http 5 | 6 | 7 | def test_get(): 8 | with patch("urllib.request.urlopen") as mock: 9 | mock.return_value.__enter__.return_value.read.return_value = """ 10 | { 11 | "resultCount": 1, 12 | "results": [ 13 | { 14 | "collectionId": 979020229, 15 | "artistName": "Michael Kennedy (@mkennedy)", 16 | "collectionName": "Talk Python To Me", 17 | "collectionViewUrl": "https://podcasts.apple.com/us/podcast/id979020229?uo=4", 18 | "feedUrl": "https://talkpython.fm/episodes/rss", 19 | "primaryGenreName": "Technology", 20 | "artworkUrl600": "https://whatever/image/979020229.png" 21 | } 22 | ] 23 | } 24 | """ 25 | response = http.get( 26 | "https://itunes.apple.com/search", {"term": "talk python", "media": "podcast"} 27 | ) 28 | req = mock.call_args[0][0] 29 | assert req.full_url == "https://itunes.apple.com/search?term=talk+python&media=podcast" 30 | assert "resultCount" in response 31 | assert "results" in response 32 | assert response["resultCount"] == 1 33 | 34 | 35 | def test_network_error(): 36 | with patch("urllib.request.urlopen") as mock: 37 | mock.side_effect = URLError(reason="certificate verify failed") 38 | with pytest.raises(Exception) as exc: 39 | http.get("https://itunes.apple.com/search", {"term": "python"}) 40 | assert str(exc.value) == "Network error: certificate verify failed" 41 | 42 | 43 | def test_http_error(): 44 | with patch("urllib.request.urlopen") as mock: 45 | mock.return_value.__enter__.return_value.read.side_effect = HTTPError( 46 | "https://itunes.apple.com/search", 503, "Service Unavailable", {}, None 47 | ) 48 | with pytest.raises(Exception) as exc: 49 | http.get("https://itunes.apple.com/search", {"term": "python"}) 50 | assert str(exc.value) == "HTTP error 503: Service Unavailable" 51 | 52 | 53 | def test_json_decode_error(): 54 | with patch("urllib.request.urlopen") as mock: 55 | mock.return_value.__enter__.return_value.read.return_value = "hi there" 56 | with pytest.raises(Exception) as exc: 57 | http.get("https://itunes.apple.com/search", {"term": "python"}) 58 | assert str(exc.value).startswith("Failed to parse response") 59 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | import pytest 3 | import podsearch 4 | 5 | 6 | def test_search(): 7 | with patch("podsearch.http.get") as mock: 8 | mock.return_value = { 9 | "resultCount": 2, 10 | "results": [ 11 | { 12 | "wrapperType": "track", 13 | "kind": "podcast", 14 | "collectionId": 979020229, 15 | "artistName": "Michael Kennedy (@mkennedy)", 16 | "collectionName": "Talk Python To Me", 17 | "collectionViewUrl": "https://podcasts.apple.com/us/podcast/id979020229?uo=4", 18 | "feedUrl": "https://talkpython.fm/episodes/rss", 19 | "primaryGenreName": "Technology", 20 | "artworkUrl600": "https://whatever/image/979020229.png", 21 | "trackCount": 26, 22 | "country": "USA", 23 | }, 24 | { 25 | "wrapperType": "track", 26 | "kind": "podcast", 27 | "collectionId": 981834425, 28 | "artistName": "Tobias Macey", 29 | "collectionName": "The Python Podcast.__init__", 30 | "collectionViewUrl": "https://podcasts.apple.com/us/podcast/id981834425?uo=4", 31 | "feedUrl": "https://www.podcastinit.com/feed/mp3/", 32 | "primaryGenreName": "Technology", 33 | "artworkUrl600": "https://whatever/image/981834425.png", 34 | "trackCount": 16, 35 | "country": "USA", 36 | }, 37 | ], 38 | } 39 | 40 | podcasts = podsearch.search("Python") 41 | assert len(podcasts) == 2 42 | talkpython = podcasts[0] 43 | assert talkpython.id == 979020229 44 | assert talkpython.name == "Talk Python To Me" 45 | assert talkpython.author == "Michael Kennedy (@mkennedy)" 46 | assert talkpython.url == "https://podcasts.apple.com/us/podcast/id979020229" 47 | assert talkpython.feed == "https://talkpython.fm/episodes/rss" 48 | assert talkpython.category == "Technology" 49 | assert talkpython.image == "https://whatever/image/979020229.png" 50 | assert talkpython.country == "USA" 51 | assert talkpython.episode_count == 26 52 | 53 | 54 | def test_search_defaults(): 55 | with patch("podsearch.http.get") as mock: 56 | podsearch.search("Python") 57 | mock.assert_called_with( 58 | url=podsearch.searcher.SEARCH_URL, 59 | params={"term": "Python", "country": "us", "limit": 5, "media": "podcast"}, 60 | ) 61 | 62 | 63 | def test_search_params(): 64 | with patch("podsearch.http.get") as mock: 65 | podsearch.search("Python", country="us", limit=10) 66 | mock.assert_called_with( 67 | url=podsearch.searcher.SEARCH_URL, 68 | params={"term": "Python", "country": "us", "limit": 10, "media": "podcast"}, 69 | ) 70 | 71 | 72 | def test_nothing_found(): 73 | with patch("podsearch.http.get") as mock: 74 | mock.return_value = {"resultCount": 0, "results": []} 75 | podcasts = podsearch.search("Python") 76 | assert len(podcasts) == 0 77 | 78 | 79 | def test_failed(): 80 | with patch("podsearch.http.get") as mock: 81 | mock.side_effect = Exception() 82 | with pytest.raises(Exception): 83 | podsearch.search("Python") 84 | 85 | 86 | def test_parsing_failed(): 87 | with patch("podsearch.http.get") as mock: 88 | mock.return_value = { 89 | "resultCount": 1, 90 | "results": [{"wrapperType": "track", "kind": "podcast", "collectionId": 979020229}], 91 | } 92 | with pytest.raises(Exception): 93 | podsearch.search("Python") 94 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.7: py37 4 | 3.8: py38 5 | 3.9: py39 6 | 7 | [tox] 8 | isolated_build = True 9 | envlist = py37,py38,py39 10 | 11 | [testenv] 12 | deps = 13 | black 14 | coverage 15 | flake8 16 | mccabe 17 | mypy 18 | pylint 19 | pytest 20 | commands = 21 | black podsearch 22 | flake8 podsearch 23 | pylint podsearch 24 | mypy podsearch 25 | coverage erase 26 | coverage run --include=podsearch/* -m pytest -ra 27 | coverage report -m 28 | coverage xml --------------------------------------------------------------------------------