From ca1beee578e867fdfd461d2db2dd1fd6969be937 Mon Sep 17 00:00:00 2001 From: 2O4 <35725720+2O4@users.noreply.github.com> Date: Wed, 2 Mar 2022 19:15:24 +0100 Subject: [PATCH] version 1.0.0 --- .editorconfig | 14 + .github/workflows/python-publish.yml | 36 ++ .gitignore | 129 ++++ LICENSE | 21 + MANIFEST.in | 7 + README.md | 31 + coinmarketcap/__init__.py | 1 + coinmarketcap/coinmarketcap.py | 885 +++++++++++++++++++++++++++ coinmarketcap/utils.py | 30 + dev_requirements.txt | 5 + pyproject.toml | 10 + setup.py | 26 + test/__init__.py | 0 test/coinmarketcap_test.py | 256 ++++++++ test/utils_test.py | 33 + 15 files changed, 1484 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 coinmarketcap/__init__.py create mode 100644 coinmarketcap/coinmarketcap.py create mode 100644 coinmarketcap/utils.py create mode 100644 dev_requirements.txt create mode 100644 pyproject.toml create mode 100644 setup.py create mode 100644 test/__init__.py create mode 100644 test/coinmarketcap_test.py create mode 100644 test/utils_test.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..74be54e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# https://editorconfig.org/ + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 + +# Docstrings and comments use max_line_length = 79 +[*.py] +max_line_length = 80 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..3bfabfc --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,36 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# 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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5269766 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 nlnsaoadc + +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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..84a806b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include LICENSE +include README.md + +recursive-include tests * + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/README.md b/README.md new file mode 100644 index 0000000..192b67a --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# [CoinMarketCap API](https://coinmarketcap.com/) wrapper + +[![py-coinmarketcap-client-pypi](https://img.shields.io/pypi/v/py-coinmarketcap-client.svg)](https://pypi.python.org/pypi/py-coinmarketcap-client) + +CoinMarketCap API Doc: https://coinmarketcap.com/api/documentation/v1/ + +## Install + +```bash +pip install py-coinmarketcap-client +``` + +## Usage + +```python +from coinmarketcap import CoinMarketCap + +cmc = CoinMarketCap(key="", key_type="Basic") +cmc.get_info(id=1) +``` + +## Testing + +```bash +virtualenv venv +source ./venv/bin/activate +pip install -r dev_requirements.txt +deactivate +source ./venv/bin/activate +pytest +``` diff --git a/coinmarketcap/__init__.py b/coinmarketcap/__init__.py new file mode 100644 index 0000000..91e2e03 --- /dev/null +++ b/coinmarketcap/__init__.py @@ -0,0 +1 @@ +from .coinmarketcap import CoinMarketCap diff --git a/coinmarketcap/coinmarketcap.py b/coinmarketcap/coinmarketcap.py new file mode 100644 index 0000000..3938161 --- /dev/null +++ b/coinmarketcap/coinmarketcap.py @@ -0,0 +1,885 @@ +"""CoinMarketCap API wrapper. + +Web: https://coinmarketcap.com/ +Doc: https://coinmarketcap.com/api/documentation/v1/ +""" +import logging +from typing import Any, Callable, Dict, List, Optional + +import requests + +from .utils import clean_params + +logger = logging.getLogger(__name__) + + +class KeyTypeError(Exception): + """Wrong key type exception. + + Raised in case of wrong key type. + """ + + def __init__( + self, function, required_key_type, current_key_type, message="" + ): + super().__init__(message) + self.function_name = function.__name__ + self.required_key_type = required_key_type + self.current_key_type = current_key_type + self.message = message + logger.error(self.__str__()) + + def __str__(self): + return ( + f"{self.required_key_type} endpoint '{self.function_name}' is " + f"unavailable with key type: {self.current_key_type}." + ) + + +def requires_startup(func: Callable): + """Decorate function to impose startup key type requirement.""" + + def wrapper_func(self, *args, **kwargs): + if self.key_type not in [ + "startup", + "standard", + "professional", + "enterprise", + ]: + raise KeyTypeError(func, "Startup", self.key_type) + func(self, *args, **kwargs) + + return wrapper_func + + +def requires_standard(func: Callable): + """Decorate function to impose standard key type requirement.""" + + def wrapper_func(self, *args, **kwargs): + if self.key_type not in ["standard", "professional", "enterprise"]: + raise KeyTypeError(func, "Standard", self.key_type) + func(self, *args, **kwargs) + + return wrapper_func + + +def requires_enterprise(func: Callable): + """Decorate function to impose enterprise key type requirement.""" + + def wrapper_func(self, *args, **kwargs): + if self.key_type not in ["enterprise"]: + raise KeyTypeError(func, "Enterprise", self.key_type) + func(self, *args, **kwargs) + + return wrapper_func + + +class CoinMarketCapAPIError(Exception): + def __init__(self, response, message=""): + super().__init__(message) + self.response = response + self.message = message + + def __str__(self): + return f"{self.response.status_code} {self.response.content.decode()}" + + +class CoinMarketCap: + """CoinMarketCap API wrapper. + + Web: https://coinmarketcap.com/ + Doc: https://coinmarketcap.com/api/documentation/v1/ + """ + + BASE_URL = "https://pro-api.coinmarketcap.com/" + + def __init__( + self, + key: str, + key_type: str = "Basic", + fail_silently: bool = False, + ) -> None: + """Init the CoinMarketCap API. + + Args: + key (str): CoinMarketCap API key. + key_type (:obj:`str`, optional): CoinMarketCap API Key type can be + one of: Basic, Hobbyist, Startup, Standard, Professional, + Enterprise. Defaults to Basic. + fail_silently (:obj:`bool`, optional): If true an exception should + be raise in case of wrong status code. Defaults to False. + """ + self.key = key + self.key_type = key_type.lower() + self.fail_silently = fail_silently + + def _get_headers(self) -> Dict[str, str]: + return { + "X-CMC_PRO_API_KEY": self.key, + "Accept": "application/json", + } + + def _get( + self, + path: str, + params: Optional[Dict[str, Any]] = None, + ) -> Any: + """Get requests to the specified path on CoinMarketCap API.""" + r = requests.get( + url=self.BASE_URL + path, + params=clean_params(params), + headers=self._get_headers(), + ) + + if r.status_code == 200: + return r.json() + + self._fail(r) + + def _fail(self, r): + details = r.content.decode() + try: + details = r.json() + except Exception: + pass + + if not self.fail_silently: + logger.warning( + f"CoinMarketCap API error {r.status_code} on {r.url}: {details}" + ) + raise CoinMarketCapAPIError(response=r) + + logger.info( + f"CoinMarketCap API silent error {r.status_code} on {r.url}: " + f"{details}" + ) + + def get_airdrop(self, id: str): + """Airdrop.""" + return self._get( + "v1/cryptocurrency/airdrop", + params={"id": id}, + ) + + def get_airdrops( + self, + start: Optional[int] = None, + limit: Optional[int] = None, + status: Optional[str] = None, + id: Optional[str] = None, + slug: Optional[str] = None, + symbol: Optional[str] = None, + ): + """Airdrops.""" + return self._get( + "v1/cryptocurrency/airdrops", + params={ + "start": start, + "limit": limit, + "status": status, + "id": id, + "slug": slug, + "symbol": symbol, + }, + ) + + def get_categories( + self, + start: int = 1, + limit: int = 5000, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + ): + """Categories.""" + return self._get( + "v1/cryptocurrency/categories", + params={ + "start": start, + "limit": limit, + "id": id, + "slug": slug, + "symbol": symbol, + }, + ) + + def get_category( + self, + id: str, + start: int = 1, + limit: int = 200, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Category. + + Returns information about a single coin category available on + CoinMarketCap. Includes a paginated list of the cryptocurrency quotes + and metadata for the category. + """ + return self._get( + "v1/cryptocurrency/category", + params={ + "id": id, + "start": start, + "limit": limit, + "convert": convert, + "convert_id": convert_id, + }, + ) + + def get_info( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + address: Optional[str] = None, + aux: Optional[List[str]] = None, + ): + """Metadata. + + Returns all static metadata available for one or more cryptocurrencies. + This information includes details like logo, description, official + website URL, social links, and links to a cryptocurrency's technical + documentation. + """ + return self._get( + "v1/cryptocurrency/info", + params={ + "id": id, + "slug": slug, + "symbol": symbol, + "address": address, + "aux": aux, + }, + ) + + def get_map( + self, + listing_status: Optional[List[str]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + sort: str = "cmc_rank", + symbols: Optional[List[str]] = None, + aux: Optional[List[str]] = None, + ): + """Cryptocurrency ID Map. + + Returns a mapping of all cryptocurrencies to unique CoinMarketCap ids. + Per our Best Practices we recommend utilizing CMC ID instead of + cryptocurrency symbols to securely identify cryptocurrencies with our + other endpoints and in your own application logic. Each cryptocurrency + returned includes typical identifiers such as name, symbol, and + token_address for flexible mapping to id. + + By default this endpoint returns cryptocurrencies that have actively + tracked markets on supported exchanges. You may receive a map of all + inactive cryptocurrencies by passing listing_status=inactive. You may + also receive a map of registered cryptocurrency projects that are + listed but do not yet meet methodology requirements to have tracked + markets via listing_status=untracked. Please review our methodology + documentation for additional details on listing states. + + Cryptocurrencies returned include first_historical_data and + last_historical_data timestamps to conveniently reference historical + date ranges available to query with historical time-series data + endpoints. You may also use the aux parameter to only include + properties you require to slim down the payload if calling this + endpoint frequently. + + listing_status = "active" (default), "inactive", or "untracked" + sort = "id" (cmc default), or "cmc_rank" (our default) + """ + return self._get( + "v1/cryptocurrency/map", + params={ + "listing_status": listing_status, + "start": start, + "limit": limit, + "sort": sort, + "symbol": symbols, + "aux": aux, + }, + ) + + @requires_standard + def get_listings_historical( + self, + date: str, + start: Optional[int] = None, + limit: Optional[int] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + sort: Optional[str] = None, + sort_dir: Optional[str] = None, + cryptocurrency_type: Optional[str] = None, + aux: Optional[List[str]] = None, + ): + """Get Listings Historical.""" + return self._get( + "v1/cryptocurrency/listings/historical", + params={ + "date": date, + "start": start, + "limit": limit, + "convert": convert, + "convert_id": convert_id, + "sort": sort, + "sort_dir": sort_dir, + "cryptocurrency_type": sort_dir, + "aux": aux, + }, + ) + + def get_listings_latest( + self, + start: Optional[int] = None, + limit: int = 200, + price_min: Optional[int] = None, + price_max: Optional[int] = None, + market_cap_min: Optional[int] = None, + market_cap_max: Optional[int] = None, + volume_24h_min: Optional[int] = None, + volume_24h_max: Optional[int] = None, + circulating_supply_min: Optional[int] = None, + circulating_supply_max: Optional[int] = None, + percent_change_24h_min: Optional[int] = None, + percent_change_24h_max: Optional[int] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + sort: Optional[str] = None, + sort_dir: Optional[str] = None, + cryptocurrency_type: Optional[str] = None, + tag: Optional[str] = None, + aux: Optional[List[str]] = None, + ): + """Get Listings Latest.""" + return self._get( + "v1/cryptocurrency/listings/latest", + params={ + "start": start, + "limit": limit, + "price_min": price_min, + "price_max": price_max, + "volume_24h_min": volume_24h_min, + "volume_24h_max": volume_24h_max, + "circulating_supply_min": circulating_supply_min, + "circulating_supply_max": circulating_supply_max, + "percent_change_24h_min": percent_change_24h_min, + "percent_change_24h_max": percent_change_24h_max, + "convert": convert, + "convert_id": convert_id, + "sort": sort, + "sort_dir": sort_dir, + "cryptocurrency_type": cryptocurrency_type, + "tag": tag, + "aux": aux, + }, + ) + + @requires_standard + def get_market_pairs_latest( + self, + id: Optional[str] = None, + slug: Optional[str] = None, + symbol: Optional[str] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + sort_dir: Optional[str] = None, + sort: Optional[str] = None, + aux: Optional[List[str]] = None, + matched_id: Optional[str] = None, + matched_symbol: Optional[str] = None, + category: Optional[str] = None, + fee_type: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Market Pairs Latest.""" + return self._get( + "v1/cryptocurrency/market-pairs/latest", + params={ + "id": id, + "slug": slug, + "symbol": symbol, + "start": start, + "limit": limit, + "sort_dir": sort_dir, + "sort": sort, + "aux": aux, + "matched_id": matched_id, + "matched_symbol": matched_symbol, + "category": category, + "fee_type": fee_type, + "convert": convert, + "convert_id": convert_id, + }, + ) + + @requires_startup + def get_ohlcv_historical( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + time_period: Optional[str] = None, + time_start: Optional[str] = None, + time_end: Optional[str] = None, + count: Optional[int] = None, + interval: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + skip_invalid: Optional[bool] = None, + ): + """OHLCV Historical.""" + return self._get( + "v1/cryptocurrency/ohlcv/historical", + params={ + "id": id, + "slug": slug, + "symbol": symbol, + "time_period": time_period, + "time_start": time_start, + "time_end": time_end, + "count": count, + "interval": interval, + "convert": convert, + "convert_id": convert_id, + "skip_invalid": skip_invalid, + }, + ) + + @requires_startup + def get_ohlcv_latest( + self, + id: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + skip_invalid: Optional[bool] = None, + ): + """OHLCV Latest.""" + return self._get( + "v1/cryptocurrency/ohlcv/latest", + params={ + "id": id, + "symbol": symbol, + "convert": convert, + "convert_id": convert_id, + "skip_invalid": skip_invalid, + }, + ) + + @requires_startup + def get_price_performance_stats_latest( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + time_period: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + skip_invalid: Optional[bool] = None, + ): + """Price Performance Stats.""" + return self._get( + "v1/cryptocurrency/price-performance-stats/latest", + params={ + "id": id, + "slug": slug, + "symbol": symbol, + "time_period": time_period, + "convert": convert, + "convert_id": convert_id, + "skip_invalid": skip_invalid, + }, + ) + + @requires_standard + def get_quotes_historical( + self, + id: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + time_start: Optional[str] = None, + time_end: Optional[str] = None, + count: Optional[int] = None, + interval: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + aux: Optional[List[str]] = None, + skip_invalid: Optional[bool] = None, + ): + """Quotes Historical.""" + return self._get( + "v1/cryptocurrency/quotes/historical", + params={ + "id": id, + "symbol": symbol, + "time_start": time_start, + "time_end": time_end, + "count": count, + "interval": interval, + "convert": convert, + "convert_id": convert_id, + "aux": aux, + "skip_invalid": skip_invalid, + }, + ) + + def get_quotes_latest( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + aux: Optional[List[str]] = None, + skip_invalid: Optional[bool] = None, + ): + """Quotes Latest.""" + return self._get( + "v1/cryptocurrency/quotes/latest", + params={ + "id": id, + "slug": slug, + "symbol": symbol, + "convert": convert, + "convert_id": convert_id, + "aux": aux, + "skip_invalid": skip_invalid, + }, + ) + + @requires_startup + def get_trending_gainers_losers( + self, + start: Optional[int] = None, + limit: Optional[int] = None, + time_period: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Trending Gainers & Losers.""" + return self._get( + "v1/cryptocurrency/trending/gainers-losers", + params={ + "start": start, + "limit": limit, + "time_period": time_period, + "convert": convert, + "convert_id": convert_id, + }, + ) + + @requires_startup + def get_trending_latest( + self, + limit: int = 200, + start: Optional[int] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Trending Latest.""" + return self._get( + "v1/cryptocurrency/trending/latest", + params={ + "start": start, + "limit": limit, + "convert": convert, + "convert_id": convert_id, + }, + ) + + @requires_startup + def get_trending_most_visited( + self, + start: Optional[int] = None, + limit: Optional[int] = None, + time_period: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Trending Most Visited.""" + return self._get( + "v1/cryptocurrency/trending/most-visited", + params={ + "start": start, + "limit": limit, + "time_period": time_period, + "convert": convert, + "convert_id": convert_id, + }, + ) + + def get_fiat_map( + self, + start: Optional[int] = None, + limit: Optional[int] = None, + sort: Optional[str] = None, + include_metals: Optional[bool] = None, + ): + """Fiat ID Map.""" + return self._get( + "v1/fiat/map", + params={ + "start": start, + "limit": limit, + "sort": sort, + "include_metals": include_metals, + }, + ) + + def get_exchange_info( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + aux: Optional[List[str]] = None, + ): + """Metadata.""" + return self._get( + "v1/exchange/info", + params={ + "id": id, + "slug": slug, + "aux": aux, + }, + ) + + def get_exchange_map( + self, + listing_status: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + sort: Optional[str] = None, + aux: Optional[List[str]] = None, + crypto_id: Optional[str] = None, + ): + """Exchange ID Map.""" + return self._get( + "v1/exchange/map", + params={ + "listing_status": listing_status, + "slug": slug, + "start": start, + "limit": limit, + "sort": sort, + "aux": aux, + "crypto_id": crypto_id, + }, + ) + + @requires_standard + def get_exchange_listings_latest( + self, + start: Optional[int] = None, + limit: Optional[int] = None, + sort: Optional[str] = None, + sort_dir: Optional[str] = None, + market_type: Optional[str] = None, + category: Optional[str] = None, + aux: Optional[List[str]] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Get Listings Latest.""" + return self._get( + "v1/exchange/listings/latest", + params={ + "start": start, + "limit": limit, + "sort": sort, + "sort_dir": sort_dir, + "market_type": market_type, + "category": category, + "aux": aux, + "convert": convert, + "convert_id": convert_id, + }, + ) + + @requires_standard + def get_exchange_market_pairs_latest( + self, + id: Optional[str] = None, + slug: Optional[str] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + aux: Optional[List[str]] = None, + matched_id: Optional[str] = None, + matched_symbol: Optional[str] = None, + category: Optional[str] = None, + fee_type: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Market Pairs Latest.""" + return self._get( + "v1/exchange/market-pairs/latest", + params={ + "id": id, + "slug": slug, + "start": start, + "limit": limit, + "aux": aux, + "matched_id": matched_id, + "matched_symbol": matched_symbol, + "category": category, + "fee_type": fee_type, + "convert": convert, + "convert_id": convert_id, + }, + ) + + @requires_standard + def get_exchange_quotes_historical( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + time_start: Optional[str] = None, + time_end: Optional[str] = None, + count: Optional[int] = None, + interval: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Quotes Historical.""" + return self._get( + "v1/exchange/quotes/historical", + params={ + "id": id, + "slug": slug, + "time_start": time_start, + "time_end": time_end, + "count": count, + "interval": interval, + "convert": convert, + "convert_id": convert_id, + }, + ) + + @requires_standard + def get_exchange_quotes_latest( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + aux: Optional[List[str]] = None, + ): + """Quotes Latest.""" + return self._get( + "v1/exchange/quotes/latest", + params={ + "id": id, + "slug": slug, + "convert": convert, + "convert_id": convert_id, + "aux": aux, + }, + ) + + @requires_standard + def get_global_metrics_quotes_historical( + self, + time_start: Optional[str] = None, + time_end: Optional[str] = None, + count: Optional[int] = None, + interval: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + aux: Optional[List[str]] = None, + ): + """Quotes Historical.""" + return self._get( + "v1/global-metrics/quotes/historical", + params={ + "time_start": time_start, + "time_end": time_end, + "count": count, + "interval": interval, + "convert": convert, + "convert_id": convert_id, + "aux": aux, + }, + ) + + def get_global_metrics_quotes_latest( + self, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Quotes Latest.""" + return self._get( + "v1/global-metrics/quotes/latest", + params={"convert": convert, "convert_id": convert_id}, + ) + + def get_tools_price_conversion( + self, + amount: float, + id: Optional[str] = None, + symbol: Optional[str] = None, + time: Optional[str] = None, + convert: Optional[List[str]] = None, + convert_id: Optional[str] = None, + ): + """Price Conversion.""" + return self._get( + "v1/tools/price-conversion", + params={ + "amount": amount, + "id": id, + "symbol": symbol, + "time": time, + "convert": convert, + "convert_id": convert_id, + }, + ) + + @requires_enterprise + def get_blockchain_statistics_latest( + self, + id: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + ): + """Statistics Latest.""" + return self._get( + "v1/blockchain/statistics/latest", + params={"id": id, "symbol": symbol, "slug": slug}, + ) + + def get_partners_flipside_crypto_fcas_listings_latest( + self, + start: Optional[int] = None, + limit: Optional[str] = None, + aux: Optional[List[str]] = None, + ): + """FCAS Listings Latest.""" + return self._get( + "v1/partners/flipside-crypto/fcas/listings/latest", + params={"start": start, "limit": limit, "aux": aux}, + ) + + def get_partners_flipside_crypto_fcas_quotes_latest( + self, + id: Optional[List[str]] = None, + slug: Optional[List[str]] = None, + symbol: Optional[List[str]] = None, + aux: Optional[List[str]] = None, + ): + """FCAS Quotes Latest.""" + return self._get( + "v1/partners/flipside-crypto/fcas/quotes/latest", + params={ + "id": id, + "slug": slug, + "symbol": symbol, + "aux": aux, + }, + ) + + def get_key_info(self): + """Key Info.""" + return self._get("v1/key/info") diff --git a/coinmarketcap/utils.py b/coinmarketcap/utils.py new file mode 100644 index 0000000..634ccda --- /dev/null +++ b/coinmarketcap/utils.py @@ -0,0 +1,30 @@ +from typing import Any, Dict, List, Optional, Union + + +def remove_empty_dict_values(dic: Dict[str, Any]) -> Dict[str, Any]: + """Remove empty values inside a dict.""" + return {k: v for k, v in dic.items() if v is not None} + + +def clean_dict_values(dic: Dict[str, Any]) -> Dict[str, Any]: + """Convert booleans and lists to strings in a dict.""" + for key, value in dic.items(): + + if isinstance(value, bool): + # convert a boolean to a string + dic[key] = str(value).lower() + + elif isinstance(value, list): + # convert a list to a string + dic[key] = ",".join([str(i) for i in value]) + + return dic + + +def clean_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Clean requests params removing empty values.""" + if not params: + return None + params = remove_empty_dict_values(params) + params = clean_dict_values(params) + return params diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..b865a23 --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,5 @@ +black +isort +pytest +pytest-cov +requests diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1dd698c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.black] +line-length = 80 + +[tool.isort] +skip_gitignore = true +profile = "black" +line_length = 80 + +[tool.pytest.ini_options] +addopts = "--cov=coinmarketcap --no-cov-on-fail" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..318b105 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +import pathlib + +from setuptools import setup + +ROOT = pathlib.Path(__file__).parent + +README = (ROOT / "README.md").read_text() + +setup( + name="py-coinmarketcap-client", + version="1.0.0", + description="CoinMarketCap API wrapper", + long_description=README, + long_description_content_type="text/markdown", + url="https://github.com/nlnsaoadc/py-coinmarketcap", + author="nlnsaoadc", + license="MIT", + classifiers=[ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + ], + packages=["coinmarketcap"], + include_package_data=True, + install_requires=["requests"], +) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/coinmarketcap_test.py b/test/coinmarketcap_test.py new file mode 100644 index 0000000..cf5d4a1 --- /dev/null +++ b/test/coinmarketcap_test.py @@ -0,0 +1,256 @@ +from unittest import TestCase, mock + +from coinmarketcap.coinmarketcap import CoinMarketCap, KeyTypeError + + +class CoinMarketCapTestCase(TestCase): + def setUp(self): + self.api = CoinMarketCap(key="123test", key_type="enterprise") + self.api_basic = CoinMarketCap(key="123test", key_type="basic") + self.api_hobbyist = CoinMarketCap(key="123test", key_type="hobbyist") + self.api_startup = CoinMarketCap(key="123test", key_type="startup") + self.api_standard = CoinMarketCap(key="123test", key_type="standard") + self.api_professional = CoinMarketCap( + key="123test", key_type="professional" + ) + self.api_enterprise = CoinMarketCap( + key="123test", key_type="enterprise" + ) + + def test_get_headers(self): + headers = self.api._get_headers() + self.assertIsNotNone(headers["Accept"]) + self.assertIsNotNone(headers["X-CMC_PRO_API_KEY"]) + + @mock.patch( + "requests.get", return_value=mock.Mock(status_code=200, json=lambda: {}) + ) + def test_get(self, mock_get): + self.api._get("test") + mock_get.assert_called_once_with( + url="https://pro-api.coinmarketcap.com/test", + headers={ + "Accept": "application/json", + "X-CMC_PRO_API_KEY": "123test", + }, + params=None, + ) + + @mock.patch("coinmarketcap.coinmarketcap.logger.warning") + @mock.patch( + "requests.get", + return_value=mock.Mock( + status_code=404, + json=lambda: {"message": "Not Found"}, + content=b"404 Not Found Message", + ), + ) + def test_get_404_status(self, mock_get, mock_log): + with self.assertRaises(Exception) as context: + self.api._get("test") + self.assertEqual( + "404 404 Not Found Message", + str(context.exception), + ) + mock_log.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap.logger.info") + @mock.patch( + "requests.get", + return_value=mock.Mock( + status_code=404, + json=mock.Mock(side_effect=Exception("")), + content=b"404 Not Found Message", + ), + ) + def test_get_404_status_fail_silently(self, mock_get, mock_log): + self.api.fail_silently = True + self.assertEqual(self.api._get("test"), None) + mock_log.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap.logger.error") + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_wrong_key_type_str(self, mock_get, mock_log): + try: + self.api_basic.get_trending_latest() + except KeyTypeError as error: + self.assertEqual(type(str(error)), str) + + @mock.patch("coinmarketcap.coinmarketcap.logger.error") + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_requires_startup(self, mock_get, mock_log): + with self.assertRaises(KeyTypeError): + self.api_basic.get_trending_latest() + self.api_startup.get_trending_latest() + mock_log.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap.logger.error") + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_requires_standard(self, mock_get, mock_log): + with self.assertRaises(KeyTypeError): + self.api_basic.get_exchange_quotes_latest() + self.api_standard.get_exchange_quotes_latest() + mock_log.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap.logger.error") + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_requires_enterprise(self, mock_get, mock_log): + with self.assertRaises(KeyTypeError): + self.api_basic.get_blockchain_statistics_latest() + self.api_enterprise.get_blockchain_statistics_latest() + mock_log.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_airdrop(self, mock_get): + self.api.get_airdrop(id="") + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_airdrops(self, mock_get): + self.api.get_airdrops() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_categories(self, mock_get): + self.api.get_categories() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_category(self, mock_get): + self.api.get_category(id="") + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_info(self, mock_get): + self.api.get_info(id=[""]) + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_map(self, mock_get): + self.api.get_map(sort="cmc_rank") + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_listings_historical(self, mock_get): + self.api.get_listings_historical(date="") + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_listings_latest(self, mock_get): + self.api.get_listings_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_market_pairs_latest(self, mock_get): + self.api.get_market_pairs_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_ohlcv_historical(self, mock_get): + self.api.get_ohlcv_historical() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_ohlcv_latest(self, mock_get): + self.api.get_ohlcv_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_price_performance_stats_latest(self, mock_get): + self.api.get_price_performance_stats_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_quotes_historical(self, mock_get): + self.api.get_quotes_historical() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_quotes_latest(self, mock_get): + self.api.get_quotes_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_trending_gainers_losers(self, mock_get): + self.api.get_trending_gainers_losers() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_trending_latest(self, mock_get): + self.api.get_trending_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_trending_most_visited(self, mock_get): + self.api.get_trending_most_visited() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_fiat_map(self, mock_get): + self.api.get_fiat_map() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_exchange_info(self, mock_get): + self.api.get_exchange_info() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_exchange_map(self, mock_get): + self.api.get_exchange_map() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_exchange_listings_latest(self, mock_get): + self.api.get_exchange_listings_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_exchange_market_pairs_latest(self, mock_get): + self.api.get_exchange_market_pairs_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_exchange_quotes_historical(self, mock_get): + self.api.get_exchange_quotes_historical() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_exchange_quotes_latest(self, mock_get): + self.api.get_exchange_quotes_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_global_metrics_quotes_historical(self, mock_get): + self.api.get_global_metrics_quotes_historical() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_global_metrics_quotes_latest(self, mock_get): + self.api.get_global_metrics_quotes_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_tools_price_conversion(self, mock_get): + self.api.get_tools_price_conversion(amount=1.0) + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_blockchain_statistics_latest(self, mock_get): + self.api.get_blockchain_statistics_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_partners_flipside_crypto_fcas_listings_latest(self, mock_get): + self.api.get_partners_flipside_crypto_fcas_listings_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_partners_flipside_crypto_fcas_quotes_latest(self, mock_get): + self.api.get_partners_flipside_crypto_fcas_quotes_latest() + mock_get.assert_called_once() + + @mock.patch("coinmarketcap.coinmarketcap" ".CoinMarketCap._get") + def test_get_key_info(self, mock_get): + self.api.get_key_info() + mock_get.assert_called_once() diff --git a/test/utils_test.py b/test/utils_test.py new file mode 100644 index 0000000..bc688b8 --- /dev/null +++ b/test/utils_test.py @@ -0,0 +1,33 @@ +from coinmarketcap.utils import ( + clean_dict_values, + clean_params, + remove_empty_dict_values, +) + + +def test_remove_empty_dict_values(): + dict_with_empty_values = {"a": None, "b": 123, "c": "foo", "d": None} + new_dict = remove_empty_dict_values(dict_with_empty_values) + for value in new_dict.values(): + assert value is not None + + +def test_clean_dict_values(): + input_dict = {"c": "foo", "e": True, "f": ["foo", "bar"]} + new_dict = clean_dict_values(input_dict) + assert isinstance(new_dict["e"], str) + assert isinstance(new_dict["f"], str) + assert new_dict["e"] == "true" + assert new_dict["f"] == "foo,bar" + + +def test_clean_params(): + new_dict = clean_params({"a": None, "b": ["foo", "bar"], "c": True}) + assert "a" not in new_dict + assert new_dict["b"] == "foo,bar" + assert new_dict["c"] == "true" + + +def test_clean_params_empty(): + new_dict = clean_params(None) + assert new_dict is None