From 7ea555e3861ca3a4e9e5bfdc9530b1ca8c57e759 Mon Sep 17 00:00:00 2001 From: Ross <9055337+chadsr@users.noreply.github.com> Date: Mon, 27 May 2024 16:42:46 +0200 Subject: [PATCH] feat: split out read config for easier testing (#80) * move read_config out of init for easier mocking * always store coin symbols uppercase in config * use mock patches instead of environ replace * prevent test api key clash * test cmc invalid key error * store CI key secret in separate env * skip test_get_coinmarketcap_latest if no test key provided * only give test coverage for main file * v1.5.1 --- .github/workflows/test.yml | 2 +- pyproject.toml | 2 +- tests/test_waybar_crypto.py | 161 +++++++++++++++++++++++++--- waybar_crypto.py | 206 ++++++++++++++++++++---------------- 4 files changed, 264 insertions(+), 107 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19b6c1b..a912350 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: COINMARKETCAP_API_KEY: ${{ secrets.COINMARKETCAP_API_KEY }} run: | source .venv/bin/activate - pytest -vv --cov=./ --cov-report=xml tests/ + pytest -vv --cov=./waybar_crypto.py --cov-report=xml tests/ - name: Upload Coverage to Codecov uses: codecov/codecov-action@v4 with: diff --git a/pyproject.toml b/pyproject.toml index eb32080..e8391a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "waybar-crypto" -version = "v1.5.0" +version = "v1.5.1" description = "A Waybar module for displaying cryptocurrency market information from CoinMarketCap." authors = ["Ross "] license = "MIT" diff --git a/tests/test_waybar_crypto.py b/tests/test_waybar_crypto.py index f3bae10..f83f73e 100644 --- a/tests/test_waybar_crypto.py +++ b/tests/test_waybar_crypto.py @@ -4,36 +4,91 @@ from unittest import mock from waybar_crypto import ( + API_KEY_ENV, CLASS_NAME, + DEFAULT_DISPLAY_OPTIONS_FORMAT, DEFAULT_XDG_CONFIG_HOME_PATH, XDG_CONFIG_HOME_ENV, + CoinmarketcapApiException, + Config, + NoApiKeyException, ResponseQuotesLatest, WaybarCrypto, parse_args, + read_config, ) +TEST_API_KEY_ENV = "TEST_CMC_API_KEY" +API_KEY = os.getenv(TEST_API_KEY_ENV) -# Get the absolute path of this script -ABS_DIR = os.path.dirname(os.path.abspath(__file__)) -CONFIG_PATH = f"{ABS_DIR}/../config.ini.example" -TEST_CONFIG_PATH = "/test_path" +TEST_CONFIG_PATH = "./config.ini.example" +TEST_API_KEY = "test_key" @pytest.fixture() -def waybar_crypto(): - yield WaybarCrypto(CONFIG_PATH) +def config() -> Config: + return { + "general": { + "currency": "EUR", + "currency_symbol": "€", + "spacer_symbol": "|", + "display_options": [], + "display_options_format": DEFAULT_DISPLAY_OPTIONS_FORMAT, + "api_key": "some_api_key", + }, + "coins": { + "btc": { + "icon": "BTC", + "in_tooltip": False, + "price_precision": 1, + "change_precision": 2, + "volume_precision": 2, + }, + "eth": { + "icon": "ETH", + "in_tooltip": False, + "price_precision": 2, + "change_precision": 2, + "volume_precision": 2, + }, + "dot": { + "icon": "DOT", + "in_tooltip": True, + "price_precision": 4, + "change_precision": 2, + "volume_precision": 2, + }, + "avax": { + "icon": "AVAX", + "in_tooltip": True, + "price_precision": 3, + "change_precision": 2, + "volume_precision": 2, + }, + }, + } + + +@pytest.fixture() +def waybar_crypto(config: Config) -> WaybarCrypto: + return WaybarCrypto(config) @pytest.fixture() -def quotes_latest(): - yield { +def quotes_latest() -> ResponseQuotesLatest: + return { "status": { "timestamp": "2024-05-20T17:29:45.646Z", "error_code": 0, - "error_message": None, + "error_message": "", + "elapsed": 5, + "credit_count": 1, }, "data": { "BTC": { + "id": 1, + "name": "Bitcoin", + "symbol": "BTC", "quote": { "EUR": { "price": 62885.47621569202, @@ -45,10 +100,14 @@ def quotes_latest(): "percent_change_30d": 4.71056688, "percent_change_60d": 3.13017816, "percent_change_90d": 33.96699196, + "last_updated": "2024-05-27T12:58:04.000Z", } }, }, "ETH": { + "id": 1027, + "name": "Ethereum", + "symbol": "ETH", "quote": { "EUR": { "price": 2891.33408409618, @@ -60,10 +119,14 @@ def quotes_latest(): "percent_change_30d": 0.04147897, "percent_change_60d": -10.18412449, "percent_change_90d": 8.36092599, + "last_updated": "2024-05-27T12:58:04.000Z", } }, }, "AVAX": { + "id": 5805, + "name": "Avalanche", + "symbol": "AVAX", "quote": { "EUR": { "price": 34.15081432131667, @@ -75,10 +138,14 @@ def quotes_latest(): "percent_change_30d": 3.78312279, "percent_change_60d": -30.74974196, "percent_change_90d": -0.83220421, + "last_updated": "2024-05-27T12:58:04.000Z", } }, }, "DOT": { + "id": 6636, + "name": "Polkadot", + "symbol": "DOT", "quote": { "EUR": { "price": 6.9338115798384905, @@ -90,16 +157,17 @@ def quotes_latest(): "percent_change_30d": 8.73368475, "percent_change_60d": -19.8413195, "percent_change_90d": -2.24744556, + "last_updated": "2024-05-27T12:58:04.000Z", } - } + }, }, }, } +@mock.patch.dict(os.environ, {XDG_CONFIG_HOME_ENV: ""}) def test_parse_args_default_path(): with mock.patch("sys.argv", ["waybar_crypto.py"]): - os.environ[XDG_CONFIG_HOME_ENV] = "" args = parse_args() assert "config_path" in args assert os.path.expanduser(DEFAULT_XDG_CONFIG_HOME_PATH) in os.path.expanduser( @@ -107,9 +175,9 @@ def test_parse_args_default_path(): ) +@mock.patch.dict(os.environ, {XDG_CONFIG_HOME_ENV: TEST_CONFIG_PATH}) def test_parse_args_custom_xdg_data_home(): with mock.patch("sys.argv", ["waybar_crypto.py"]): - os.environ[XDG_CONFIG_HOME_ENV] = TEST_CONFIG_PATH args = parse_args() assert "config_path" in args assert TEST_CONFIG_PATH in args["config_path"] @@ -125,9 +193,63 @@ def test_parse_args_custom_path(mock: mock.MagicMock): assert args["config_path"] == TEST_CONFIG_PATH +@mock.patch.dict(os.environ, {API_KEY_ENV: ""}) +def test_read_config(): + config = read_config(TEST_CONFIG_PATH) + assert "general" in config + general = config["general"] + assert isinstance(general, dict) + + assert "currency" in general + assert isinstance(general["currency"], str) + assert general["currency"].isupper() is True + + assert "currency_symbol" in general + assert isinstance(general["currency_symbol"], str) + + assert "spacer_symbol" in general + assert isinstance(general["spacer_symbol"], str) + + assert "display_options" in general + assert isinstance(general["display_options"], list) + + assert "display_options_format" in general + assert isinstance(general["display_options_format"], dict) + + assert "api_key" in general + assert isinstance(general["api_key"], str) + + assert "coins" in config + coins = config["coins"] + assert isinstance(coins, dict) + for coin_symbol, coin_config in coins.items(): + assert coin_symbol.isupper() is True + assert isinstance(coin_config, dict) + assert "icon" in coin_config + assert isinstance(coin_config["icon"], str) + assert "in_tooltip" in coin_config + assert isinstance(coin_config["in_tooltip"], bool) + assert "price_precision" in coin_config + assert isinstance(coin_config["price_precision"], int) + assert "change_precision" in coin_config + assert isinstance(coin_config["change_precision"], int) + assert "volume_precision" in coin_config + assert isinstance(coin_config["volume_precision"], int) + + +@mock.patch.dict(os.environ, {API_KEY_ENV: TEST_API_KEY}) +def test_read_config_env(): + config = read_config(TEST_CONFIG_PATH) + assert config["general"]["api_key"] == TEST_API_KEY + + class TestWaybarCrypto: """Tests for the WaybarCrypto.""" + @pytest.mark.skipif( + API_KEY is None, reason=f"test API key not provided in '{TEST_API_KEY_ENV}'" + ) + @mock.patch.dict(os.environ, {API_KEY_ENV: API_KEY}) def test_get_coinmarketcap_latest(self, waybar_crypto: WaybarCrypto): resp_quotes_latest = waybar_crypto.coinmarketcap_latest() assert isinstance(resp_quotes_latest, dict) @@ -159,6 +281,13 @@ def test_get_coinmarketcap_latest(self, waybar_crypto: WaybarCrypto): assert field in quote_values assert isinstance(quote_values[field], field_type) + @mock.patch.dict(os.environ, {API_KEY_ENV: ""}) + def test_get_coinmarketcap_latest_invalid_key(self, waybar_crypto: WaybarCrypto): + try: + _ = waybar_crypto.coinmarketcap_latest() + except Exception as e: + assert isinstance(e, CoinmarketcapApiException) + def test_waybar_output(self, waybar_crypto: WaybarCrypto, quotes_latest: ResponseQuotesLatest): output = waybar_crypto.waybar_output(quotes_latest) assert isinstance(output, dict) @@ -168,3 +297,11 @@ def test_waybar_output(self, waybar_crypto: WaybarCrypto, quotes_latest: Respons assert isinstance(output[field], str) assert output["class"] == CLASS_NAME + + @mock.patch.dict(os.environ, {API_KEY_ENV: ""}) + def test_no_api_key(self, config: Config): + try: + config["general"]["api_key"] = "" + _ = WaybarCrypto(config) + except Exception as e: + assert isinstance(e, NoApiKeyException) diff --git a/waybar_crypto.py b/waybar_crypto.py index e75d0e2..d176344 100755 --- a/waybar_crypto.py +++ b/waybar_crypto.py @@ -122,6 +122,10 @@ def __init__(self, message: str) -> None: self.message = message +class NoApiKeyException(WaybarCryptoException): + pass + + class CoinmarketcapApiException(WaybarCryptoException): def __init__(self, message: str, error_code: int | None) -> None: super().__init__(message) @@ -131,103 +135,118 @@ def __str__(self) -> str: return f"{self.message} ({self.error_code})" -class WaybarCrypto(object): - def __init__(self, config_path: str): - self.config: Config = self.__parse_config_path(config_path) +def read_config(config_path: str) -> Config: + """Read a configuration file - def __parse_config_path(self, config_path: str) -> Config: - # Attempt to load crypto.ini configuration file - cfp = configparser.ConfigParser(allow_no_value=True, interpolation=None) + Args: + config_path (str): Path to a .ini configuration file - try: - with open(config_path, "r", encoding="utf-8") as f: - cfp.read_file(f) - except Exception as e: - raise WaybarCryptoException(f"failed to open config file: {e}") - - # Assume any section that isn't 'general', is a coin - coin_names = [section for section in cfp.sections() if section != "general"] - - # Construct the coin configuration dict - coins: dict[str, ConfigCoin] = {} - for coin_name in coin_names: - if coin_name in coins: - # duplicate entry, skip - continue - - display_in_tooltip = DEFAULT_COIN_CONFIG_TOOLTIP - if "in_tooltip" in cfp[coin_name]: - display_in_tooltip = cfp.getboolean(coin_name, "in_tooltip") - - coins[coin_name] = { - "icon": cfp.get(coin_name, "icon"), - "in_tooltip": display_in_tooltip, - "price_precision": DEFAULT_PRECISION, - "change_precision": DEFAULT_PRECISION, - "volume_precision": DEFAULT_PRECISION, - } - - for coin_precision_option in COIN_PRECISION_OPTIONS: - if coin_precision_option in cfp[coin_name]: - if not cfp[coin_name][coin_precision_option].isdigit(): - raise WaybarCryptoException( - f"configured option '{coin_precision_option}' for cryptocurrency '{coin_name}' must be an integer" - ) - - precision_value = cfp.getint(coin_name, coin_precision_option) - if precision_value < MIN_PRECISION: - raise WaybarCryptoException( - f"value of option '{coin_precision_option}' for cryptocurrency '{coin_name}' must be greater than {MIN_PRECISION}", - ) - - coins[coin_name][coin_precision_option] = precision_value - - # The fiat currency used in the trading pair - currency = cfp.get("general", "currency").upper() - currency_symbol = cfp.get("general", "currency_symbol") - - spacer_symbol = "" - if "spacer_symbol" in cfp["general"]: - spacer_symbol = cfp.get("general", "spacer_symbol") - - # Get a list of the chosen display options - display_options: list[str] = cfp.get("general", "display").split(",") - - if len(display_options) == 0: - display_options = DEFAULT_DISPLAY_OPTIONS - - for display_option in display_options: - if display_option not in DEFAULT_DISPLAY_OPTIONS_FORMAT: - raise WaybarCryptoException(f"invalid display option '{display_option}") - - display_options_format = DEFAULT_DISPLAY_OPTIONS_FORMAT - display_format_price = display_options_format["price"] - display_options_format["price"] = f"{currency_symbol}{display_format_price}" - - api_key: str | None = None - if "api_key" in cfp["general"]: - api_key = cfp.get("general", "api_key") - - # If API_KEY_ENV exists, take precedence over the config file value - api_key = os.getenv(key=API_KEY_ENV, default=api_key) - if api_key is None: - raise WaybarCryptoException( - f"no API key provided in configuration file or with environment variable '{API_KEY_ENV}'" - ) + Returns: + Config: Configuration dict object + """ + + cfp = configparser.ConfigParser(allow_no_value=True, interpolation=None) + + try: + with open(config_path, "r", encoding="utf-8") as f: + cfp.read_file(f) + except Exception as e: + raise WaybarCryptoException(f"failed to open config file: {e}") - config: Config = { - "general": { - "currency": currency, - "currency_symbol": currency_symbol, - "spacer_symbol": spacer_symbol, - "display_options": display_options, - "display_options_format": display_options_format, - "api_key": api_key, - }, - "coins": coins, + # Assume any section that isn't 'general', is a coin + coin_names = [section for section in cfp.sections() if section != "general"] + + # Construct the coin configuration dict + coins: dict[str, ConfigCoin] = {} + for coin_name in coin_names: + coin_symbol = coin_name.upper() + if coin_symbol in coins: + # duplicate entry, skip + continue + + display_in_tooltip = DEFAULT_COIN_CONFIG_TOOLTIP + if "in_tooltip" in cfp[coin_name]: + display_in_tooltip = cfp.getboolean(coin_name, "in_tooltip") + + coins[coin_symbol] = { + "icon": cfp.get(coin_name, "icon"), + "in_tooltip": display_in_tooltip, + "price_precision": DEFAULT_PRECISION, + "change_precision": DEFAULT_PRECISION, + "volume_precision": DEFAULT_PRECISION, } - return config + for coin_precision_option in COIN_PRECISION_OPTIONS: + if coin_precision_option in cfp[coin_name]: + if not cfp[coin_name][coin_precision_option].isdigit(): + raise WaybarCryptoException( + f"configured option '{coin_precision_option}' for cryptocurrency '{coin_name}' must be an integer" + ) + + precision_value = cfp.getint(coin_name, coin_precision_option) + if precision_value < MIN_PRECISION: + raise WaybarCryptoException( + f"value of option '{coin_precision_option}' for cryptocurrency '{coin_name}' must be greater than {MIN_PRECISION}", + ) + + coins[coin_symbol][coin_precision_option] = precision_value + + # The fiat currency used in the trading pair + currency = cfp.get("general", "currency").upper() + currency_symbol = cfp.get("general", "currency_symbol") + + spacer_symbol = "" + if "spacer_symbol" in cfp["general"]: + spacer_symbol = cfp.get("general", "spacer_symbol") + + # Get a list of the chosen display options + display_options: list[str] = cfp.get("general", "display").split(",") + + if len(display_options) == 0: + display_options = DEFAULT_DISPLAY_OPTIONS + + for display_option in display_options: + if display_option not in DEFAULT_DISPLAY_OPTIONS_FORMAT: + raise WaybarCryptoException(f"invalid display option '{display_option}") + + display_options_format = DEFAULT_DISPLAY_OPTIONS_FORMAT + display_format_price = display_options_format["price"] + display_options_format["price"] = f"{currency_symbol}{display_format_price}" + + api_key: str | None = None + if "api_key" in cfp["general"]: + api_key = cfp.get("general", "api_key") + if api_key == "": + api_key = None + + # If API_KEY_ENV exists, take precedence over the config file value + api_key = os.getenv(key=API_KEY_ENV, default=api_key) + if api_key is None: + raise NoApiKeyException( + f"no API key provided in configuration file or with environment variable '{API_KEY_ENV}'" + ) + + config: Config = { + "general": { + "currency": currency, + "currency_symbol": currency_symbol, + "spacer_symbol": spacer_symbol, + "display_options": display_options, + "display_options_format": display_options_format, + "api_key": api_key, + }, + "coins": coins, + } + + return config + + +class WaybarCrypto(object): + def __init__(self, config: Config): + if config["general"]["api_key"] == "": + raise NoApiKeyException("No API key provided") + + self.config: Config = config def coinmarketcap_latest(self) -> ResponseQuotesLatest: # Construct API query parameters @@ -350,7 +369,8 @@ def main(): if not os.path.isfile(config_path): raise WaybarCryptoException(f"configuration file not found at '{config_path}'") - waybar_crypto = WaybarCrypto(args["config_path"]) + config = read_config(args["config_path"]) + waybar_crypto = WaybarCrypto(config) quotes_latest = waybar_crypto.coinmarketcap_latest() output = waybar_crypto.waybar_output(quotes_latest)