diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..e642bd5 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,9 @@ +[paths] +source = + listennotes + */site-packages + +[run] +branch = true +parallel = true +source = listennotes diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..dba2bb1 --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +# [flake8] +# exclude = +# setup.py + +# E501 is the "Line too long" error. We disable it because we use Black for +# code formatting. Black makes a best effort to keep lines under the max +# length, but can go over in some cases. +# W503 goes against PEP8 rules. It's disabled by default, but must be disabled +# explicitly when using `ignore`. +# ignore = E501, W503 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b97131e --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..af45578 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Listen Notes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fa8d195 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +VENV_NAME?=venv +PIP?=pip3 +PYTHON?=python3 + +venv: $(VENV_NAME)/bin/activate + +$(VENV_NAME)/bin/activate: setup.py + $(PIP) install --upgrade pip virtualenv + @test -d $(VENV_NAME) || $(PYTHON) -m virtualenv --clear $(VENV_NAME) + ${VENV_NAME}/bin/python -m pip install -U pip tox + ${VENV_NAME}/bin/python -m pip install -e . + @touch $(VENV_NAME)/bin/activate + +test: venv + @${VENV_NAME}/bin/tox -p auto $(TOX_ARGS) + +test-nomock: venv + @${VENV_NAME}/bin/tox -p auto -- --nomock $(TOX_ARGS) + +test-travis: venv + ${VENV_NAME}/bin/python -m pip install -U tox-travis + @${VENV_NAME}/bin/tox -p auto $(TOX_ARGS) + +fmt: venv + @${VENV_NAME}/bin/tox -e fmt + +fmtcheck: venv + @${VENV_NAME}/bin/tox -e fmt -- --check --verbose + +lint: venv + @${VENV_NAME}/bin/tox -e lint + +publish-test: test + ${VENV_NAME}/bin/python -m pip install --upgrade twine + ${VENV_NAME}/bin/python -m twine upload --repository testpypi .tox/dist/* + +publish: test + ${VENV_NAME}/bin/python -m pip install --upgrade twine + ${VENV_NAME}/bin/python -m twine upload --repository pypi .tox/dist/* + +clean: + @rm -rf $(VENV_NAME) .coverage .coverage.* build/ dist/ htmlcov/ *.egg-info .tox + +.PHONY: venv test test-nomock test-travis coveralls fmt fmtcheck lint clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ca3645 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Podcast API Python Library + +[![Build Status](https://travis-ci.com/ListenNotes/python-api-python.svg?branch=master)](https://travis-ci.com/ListenNotes/python-api-python) + +The Podcast API Python library provides convenient access to the [Listen Notes Podcast API](https://www.listennotes.com/api/) from +applications written in the Python language. + +Simple and no-nonsense podcast search & directory API. Search the meta data of all podcasts and episodes by people, places, or topics. + + + +## Documentation + +See the [Listen Notes Podcast API docs](https://www.listennotes.com/api/docs/). + + +## Installation + +You don't need this source code unless you want to modify the package. If you just +want to use the package, please run: + +```sh +pip install --upgrade podcast-api +``` + +Install from source with: + +```sh +make && source venv/bin/activate +``` + +### Requirements + +- Python 3.5+ + +## Usage + +The library needs to be configured with your account's API key which is +available in your [Listen API Dashboard](https://www.listennotes.com/api/dashboard/#apps). Set `api_key` to its +value: + +```python +from listennotes import podcast_api + +api_key = 'a6a1f7ae6a4a4cf7a208e5ba********' + +client = podcast_api.Client(api_key=api_key) + +response = client.search(q='star wars') + +print(response.json()) +``` + +If `api_key` is None, then we'll connect to a [mock server](https://www.listennotes.com/api/tutorials/#faq0) that returns fake data for testing purposes. + +You can see all available API endpoints and parameters on the API Docs page at [listennotes.com/api/docs/](https://www.listennotes.com/api/docs/). + +### Handling exceptions + +Unsuccessful requests raise exceptions. The class of the exception will reflect +the sort of error that occurred. + +All exception classes can be found in [this file](https://github.com/ListenNotes/podcast-api-python/blob/main/listennotes/errors.py). + +And you can see some sample code [here](https://github.com/ListenNotes/podcast-api-python/blob/main/examples/sample.py#L17). \ No newline at end of file diff --git a/examples/sample.py b/examples/sample.py new file mode 100644 index 0000000..c057a2c --- /dev/null +++ b/examples/sample.py @@ -0,0 +1,101 @@ +import json +import os + +from listennotes import podcast_api, errors + +# Get your api key here: https://www.listennotes.com/api/dashboard/ +api_key = os.environ.get("LISTEN_API_KEY", None) + +client = podcast_api.Client(api_key=api_key) + +# +# Boilerplate to make an api call +# +try: + response = client.typeahead(q="startup", show_podcasts=1) + print(json.dumps(response.json(), indent=2)) +except errors.APIConnectionError: + print("Failed ot connect to Listen API servers") +except errors.AuthenticationError: + print("Wrong api key, or your account has been suspended!") +except errors.InvalidRequestError: + print("Wrong parameters!") +except errors.NotFoundError: + print("Endpoint not exist or the podcast / episode not exist!") +except errors.RateLimitError: + print("You have reached your quota limit!") +except errors.ListenApiError: + print("Something wrong on Listen Notes servers") +except Exception: + print("Other errors that may not be related to Listen API") +else: + headers = response.headers + print("\n=== Some account info ===") + print( + "Free Quota this month: %s requests" + % headers.get("X-ListenAPI-FreeQuota") + ) + print("Usage this month: %s requests" % headers.get("X-ListenAPI-Usage")) + print("Next billing date: %s" % headers.get("X-Listenapi-NextBillingDate")) + +# response = client.search(q='startup') +# print(response.json()) + +# response = client.fetch_best_podcasts() +# print(response.json()) + +# response = client.fetch_best_podcasts() +# print(response.json()) + +# response = client.fetch_podcast_by_id(id='4d3fe717742d4963a85562e9f84d8c79') +# print(response.json()) + +# response = client.fetch_episode_by_id(id='6b6d65930c5a4f71b254465871fed370') +# print(response.json()) + +# response = client.batch_fetch_episodes(ids='c577d55b2b2b483c969fae3ceb58e362,0f34a9099579490993eec9e8c8cebb82') +# print(response.json()) + +# response = client.batch_fetch_podcasts(ids='3302bc71139541baa46ecb27dbf6071a,68faf62be97149c280ebcc25178aa731,' +# '37589a3e121e40debe4cef3d9638932a,9cf19c590ff0484d97b18b329fed0c6a') +# print(response.json()) + +# response = client.fetch_curated_podcasts_list_by_id(id='SDFKduyJ47r') +# print(response.json()) + +# response = client.fetch_curated_podcasts_lists(page=2) +# print(response.json()) + +# response = client.fetch_curated_podcasts_lists(page=2) +# print(response.json()) + +# response = client.fetch_podcast_genres(top_level_only=0) +# print(response.json()) + +# response = client.fetch_podcast_regions() +# print(response.json()) + +# response = client.fetch_podcast_languages() +# print(response.json()) + +# response = client.just_listen() +# print(response.json()) + +# response = client.fetch_recommendations_for_podcast(id='25212ac3c53240a880dd5032e547047b', safe_mode=1) +# print(response.json()) + +# response = client.fetch_recommendations_for_episode(id='914a9deafa5340eeaa2859c77f275799', safe_mode=1) +# print(response.json()) + +# response = client.fetch_playlist_by_id(id='m1pe7z60bsw', type='podcast_list') +# print(response.json()) + +# response = client.fetch_my_playlists() +# print(response.json()) + +# response = client.submit_podcast(rss='https://feeds.megaphone.fm/committed') +# print(response.json()) + +# response = client.delete_podcast( +# id='4d3fe717742d4963a85562e9f84d8c79', reason='the podcaster wants to delete it') +# print(response.json()) diff --git a/listennotes/__init__ b/listennotes/__init__ new file mode 100644 index 0000000..e69de29 diff --git a/listennotes/errors.py b/listennotes/errors.py new file mode 100644 index 0000000..ccbb906 --- /dev/null +++ b/listennotes/errors.py @@ -0,0 +1,52 @@ +class ListenApiError(Exception): + """ + Display a very generic error to the user + """ + + def __init__(self, message=None, response=None): + super(ListenApiError, self).__init__(message) + self._message = message + self.response = response + + def __str__(self): + return self._message + + +class NotFoundError(ListenApiError): + """ + Endpoint not exist or the podcast / episode not exist + """ + + pass + + +class InvalidRequestError(ListenApiError): + """ + Invalid parameters were supplied to Listen API + """ + + pass + + +class AuthenticationError(ListenApiError): + """ + Authentication with Listen API failed + """ + + pass + + +class RateLimitError(ListenApiError): + """ + Too many requests made to the API too quickly + """ + + pass + + +class APIConnectionError(ListenApiError): + """ + Network communication with Listen API failed + """ + + pass diff --git a/listennotes/http_utils.py b/listennotes/http_utils.py new file mode 100644 index 0000000..40759ea --- /dev/null +++ b/listennotes/http_utils.py @@ -0,0 +1,175 @@ +import requests +from requests import exceptions + +from listennotes import errors + + +class Request: + """Making HTTP requests. + + Apply the best practices of making http requests: + 1. timeout + 2. retry + 3. max redirects + 4. exposes python-request exceptions, callers are responsible + to handle them. + """ + + MAX_RETRIES = 3 + MAX_REDIRECTS = 15 + TIMEOUT = 5 # seconds + + def __init__( + self, + max_redirects=MAX_REDIRECTS, + max_retries=MAX_RETRIES, + adapter=None, + raise_exception=True, + **kwargs + ): + """Set up a requests.Session object. + + Args: + max_redirects: max redirects. + max_retries: max retries. + adapter: a custom requests.adapters.HTTPAdapter object. + If this argument is specified, max_retries argument is ignored. + kwargs: keyword args to set session attribute, e.g., auth. + """ + self.session = requests.Session() + self.raise_exception = raise_exception + if not adapter: + the_adapter = requests.adapters.HTTPAdapter( + max_retries=max_retries + ) + else: + the_adapter = adapter + + for key, value in kwargs.items(): + if hasattr(self.session, key): + setattr(self.session, key, value) + + self.session.max_redirects = max_redirects + self.session.mount("http://", the_adapter) + self.session.mount("https://", the_adapter) + + def request(self, method, url, timeout=TIMEOUT, **kwargs): + """Make a http(s) request. + + Args: + method: http method name, should be one of DELETE', 'GET', 'HEAD', + 'OPTIONS', 'PATCH', 'POST', 'PUT', and 'TRACE'. + url: the url to request. + timeout: request timeout. + kwargs: keyword arguments. + + Returns: + a response object. + + Raises: + requests.exceptions.RequestException if there was an ambiguous + exception that occurred while handling your request. This is + the base class for all the following exceptions. + + requests.exceptions.ConnectionError if a Connection error occurred, + e.g., DNS failure, refused connection, etc. + + requests.exceptions.HTTPError if an HTTP error occurred, i.e., + status code is 4xx or 5xx. + + requests.exceptions.URLRequired if a valid URL is required to make, + a request. + + requests.exceptions.TooManyRedirects if too many redirects. + """ + the_headers = {} + if "headers" in kwargs: + the_headers.update(kwargs["headers"]) + del kwargs["headers"] + + response = self.session.request( + method, url, timeout=timeout, headers=the_headers, **kwargs + ) + # If response.status_code is 4xx or 5xx, raise + # requests.exceptions.HTTPError + if self.raise_exception: + try: + response.raise_for_status() + except exceptions.ConnectionError: + raise errors.APIConnectionError( + "Failed to connect to Listen API", response=response + ) from None + except exceptions.HTTPError as e: + status_code = e.response.status_code + if status_code == 404: + # from None => suppress previous exception + raise errors.NotFoundError( + "Endpoint not exist, or podcast / episode not exist", + response=response, + ) from None + elif status_code == 401: + raise errors.AuthenticationError( + "Wrong api key or your account is suspended", + response=response, + ) from None + elif status_code == 429: + raise errors.RateLimitError( + "You use FREE plan and you exceed the quota limit", + response=response, + ) from None + elif status_code == 400: + raise errors.InvalidRequestError( + "Something wrong on your end (client side errors)," + " e.g., missing required parameters", + response=response, + ) from None + elif status_code >= 500: + raise errors.ListenApiError( + "Error on our end (unexpected server errors)", + response=response, + ) from None + else: + raise + except Exception: + raise errors.ListenApiError( + "Unknown error. Please report to hello@listennotes.com", + response=response, + ) from None + + return response + + def delete(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for DELETE request.""" + return self.request("DELETE", url, timeout, **kwargs) + + def get(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for GET request.""" + return self.request("GET", url, timeout, **kwargs) + + def head(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for HEAD request.""" + return self.request("HEAD", url, timeout, **kwargs) + + def options(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for OPTIONS request.""" + return self.request("OPTIONS", url, timeout, **kwargs) + + def patch(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for PATCH request.""" + return self.request("PATCH", url, timeout, **kwargs) + + def post(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for POST request.""" + return self.request("POST", url, timeout, **kwargs) + + def put(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for PUT request.""" + return self.request("PUT", url, timeout, **kwargs) + + def trace(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for TRACE request.""" + return self.request("TRACE", url, timeout, **kwargs) + + def purge(self, url, timeout=TIMEOUT, **kwargs): + """Shortcut for TRACE request.""" + return self.request("PURGE", url, timeout, **kwargs) diff --git a/listennotes/podcast_api.py b/listennotes/podcast_api.py new file mode 100644 index 0000000..7a85390 --- /dev/null +++ b/listennotes/podcast_api.py @@ -0,0 +1,166 @@ +from listennotes import version, http_utils + + +api_key = None +api_base_prod = "https://listen-api.listennotes.com/api/v2" +api_base_test = "https://listen-api-test.listennotes.com/api/v2" +default_user_agent = "podcasts-api-python %s" % version.VERSION + + +class Client(object): + def __init__(self, api_key=None, user_agent=None, max_retries=None): + self.api_base = api_base_prod if api_key else api_base_test + + self.request_headers = { + "X-ListenAPI-Key": api_key, + "User-Agent": user_agent if user_agent else default_user_agent, + } + + request_kwargs = {} + if max_retries: + request_kwargs["max_retries"] = max_retries + + self.http_client = http_utils.Request(**request_kwargs) + + # + # All endpoints + # + def search(self, **kwargs): + return self.http_client.get( + "%s/search" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def typeahead(self, **kwargs): + return self.http_client.get( + "%s/typeahead" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def fetch_best_podcasts(self, **kwargs): + return self.http_client.get( + "%s/best_podcasts" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def fetch_podcast_by_id(self, **kwargs): + podcast_id = kwargs.pop("id", None) + return self.http_client.get( + "%s/podcasts/%s" % (self.api_base, podcast_id), + params=kwargs, + headers=self.request_headers, + ) + + def fetch_episode_by_id(self, **kwargs): + episode_id = kwargs.pop("id", None) + return self.http_client.get( + "%s/episodes/%s" % (self.api_base, episode_id), + params=kwargs, + headers=self.request_headers, + ) + + def batch_fetch_podcasts(self, **kwargs): + return self.http_client.post( + "%s/podcasts" % self.api_base, + data=kwargs, + headers=self.request_headers, + ) + + def batch_fetch_episodes(self, **kwargs): + return self.http_client.post( + "%s/episodes" % self.api_base, + data=kwargs, + headers=self.request_headers, + ) + + def fetch_curated_podcasts_list_by_id(self, **kwargs): + curated_list_id = kwargs.pop("id", None) + return self.http_client.get( + "%s/curated_podcasts/%s" % (self.api_base, curated_list_id), + params=kwargs, + headers=self.request_headers, + ) + + def fetch_curated_podcasts_lists(self, **kwargs): + return self.http_client.get( + "%s/curated_podcasts" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def fetch_podcast_genres(self, **kwargs): + return self.http_client.get( + "%s/genres" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def fetch_podcast_regions(self, **kwargs): + return self.http_client.get( + "%s/regions" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def fetch_podcast_languages(self, **kwargs): + return self.http_client.get( + "%s/languages" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def just_listen(self, **kwargs): + return self.http_client.get( + "%s/just_listen" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def fetch_recommendations_for_podcast(self, **kwargs): + podcast_id = kwargs.pop("id", None) + return self.http_client.get( + "%s/podcasts/%s/recommendations" % (self.api_base, podcast_id), + params=kwargs, + headers=self.request_headers, + ) + + def fetch_recommendations_for_episode(self, **kwargs): + episode_id = kwargs.pop("id", None) + return self.http_client.get( + "%s/episodes/%s/recommendations" % (self.api_base, episode_id), + params=kwargs, + headers=self.request_headers, + ) + + def fetch_playlist_by_id(self, **kwargs): + playlist_id = kwargs.pop("id", None) + return self.http_client.get( + "%s/playlists/%s" % (self.api_base, playlist_id), + params=kwargs, + headers=self.request_headers, + ) + + def fetch_my_playlists(self, **kwargs): + return self.http_client.get( + "%s/playlists" % self.api_base, + params=kwargs, + headers=self.request_headers, + ) + + def submit_podcast(self, **kwargs): + return self.http_client.post( + "%s/podcasts/submit" % self.api_base, + data=kwargs, + headers=self.request_headers, + ) + + def delete_podcast(self, **kwargs): + podcast_id = kwargs.pop("id", None) + return self.http_client.delete( + "%s/podcasts/%s" % (self.api_base, podcast_id), + params=kwargs, + headers=self.request_headers, + ) diff --git a/listennotes/version.py b/listennotes/version.py new file mode 100644 index 0000000..cb66492 --- /dev/null +++ b/listennotes/version.py @@ -0,0 +1 @@ +VERSION = "1.0.9" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9aa604c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.black] +line-length=79 +exclude = ''' +/( + \.eggs/ + | \.git/ + | \.tox/ + | \.venv/ + | _build/ + | build/ + | dist/ + | venv/ +) +''' + +[build-system] +requires = [ + 'setuptools>=40.8.0', + 'wheel', +] \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..60bd34d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[bdist_wheel] +universal = 1 + +[metadata] +license_file = LICENSE \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..244cc60 --- /dev/null +++ b/setup.py @@ -0,0 +1,60 @@ +import os +from codecs import open +from setuptools import setup + + +here = os.path.abspath(os.path.dirname(__file__)) + +os.chdir(here) + +with open(os.path.join(here, "README.md"), "r", encoding="utf-8") as fp: + long_description = fp.read() + +version_contents = {} + +with open( + os.path.join(here, "listennotes", "version.py"), encoding="utf-8" +) as f: + exec(f.read(), version_contents) + +setup( + name="podcast-api", + version=version_contents.get("VERSION", "1.0.0"), + description="Python bindings for the Listen Notes Podcast API", + long_description=long_description, + long_description_content_type="text/markdown", + author="Listen Notes, Inc.", + author_email="hello@listennotes.com", + url="https://github.com/listennotes/podcast-api-python", + license="MIT", + keywords="listen notes podcast api", + packages=["listennotes", "examples"], + zip_safe=False, + install_requires=[ + 'requests >= 2.20; python_version >= "3.0"', + "setuptools>=41.0.1", + ], + python_requires=">=3.5", + project_urls={ + "Bug Tracker": ( + "https://github.com/listennotes/" "podcast-api-python/issues" + ), + "Documentation": "https://www.listennotes.com/api/docs/", + "Source Code": "https://github.com/listennotes/podcast-api-python/", + }, + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..28194cf --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,16 @@ +from listennotes import podcast_api + + +class TestClient(object): + def test_set_apikey(self): + client = podcast_api.Client() + assert client.request_headers.get("X-ListenAPI-Key") is None + + api_key = "abcd" + client = podcast_api.Client(api_key=api_key) + assert client.request_headers.get("X-ListenAPI-Key") == api_key + + def test_search(self): + client = podcast_api.Client() + response = client.search(q="dummy") + assert len(response.json().get("results", [])) > 0 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8916272 --- /dev/null +++ b/tox.ini @@ -0,0 +1,43 @@ +# Tox (http://tox.testrun.org/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = + fmt + lint + py{310,39,38,37,36,35,py3} +skip_missing_interpreters = true + +[tool:pytest] +testpaths = tests +addopts = + --cov-report=term-missing + +[testenv] +description = run the unit tests under {basepython} +setenv = + COVERAGE_FILE = {toxworkdir}/.coverage.{envname} +deps = + coverage >= 5 + py{310,39,38,37,36,35,py3}: pytest >= 6.0.0 + pytest-cov >= 2.8.1, < 2.11.0 + pytest-mock >= 2.0.0 + pytest-xdist >= 1.31.0 +commands = pytest --cov {posargs:-n auto} +passenv = LDFLAGS CFLAGS + +[testenv:fmt] +description = run code formatting using black +basepython = python3.8 +deps = black==20.8b1 +commands = black . {posargs} +skip_install = true + +[testenv:lint] +description = run static analysis and style check using flake8 +basepython = python3.8 +deps = flake8 +commands = python -m flake8 --show-source listennotes tests setup.py +skip_install = true