diff --git a/.coverage b/.coverage index f962c70..fbe3ba3 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc909ab..98285b0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,60 +1,60 @@ -repos: - - repo: https://github.com/asottile/pyupgrade - rev: v2.7.2 - hooks: - - id: pyupgrade - args: [--py38-plus] - - repo: https://github.com/psf/black - rev: 20.8b1 - hooks: - - id: black - args: - - --safe - - --quiet - files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ - - repo: https://github.com/codespell-project/codespell - rev: v2.0.0 - hooks: - - id: codespell - args: - - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort - - --skip="./.*,*.csv,*.json" - - --quiet-level=2 - exclude_types: [csv, json] - exclude: ^tests/fixtures/ - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 - hooks: - - id: flake8 - additional_dependencies: - - flake8-docstrings==1.5.0 - - pydocstyle==5.1.1 - files: ^(homeassistant|script|tests)/.+\.py$ - - repo: https://github.com/PyCQA/bandit - rev: 1.7.0 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=tests/bandit.yaml - files: ^(homeassistant|script|tests)/.+\.py$ - - repo: https://github.com/PyCQA/isort - rev: 5.5.3 - hooks: - - id: isort - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 - hooks: - - id: check-executables-have-shebangs - stages: [manual] - - id: check-json - exclude: (.vscode|.devcontainer) - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.790 - hooks: - - id: mypy - args: - - --pretty - - --show-error-codes +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.7.2 + hooks: + - id: pyupgrade + args: [--py38-plus] + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/codespell-project/codespell + rev: v2.0.0 + hooks: + - id: codespell + args: + - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort + - --skip="./.*,*.csv,*.json" + - --quiet-level=2 + exclude_types: [csv, json] + exclude: ^tests/fixtures/ + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.1.1 + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/bandit + rev: 1.7.0 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=tests/bandit.yaml + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/isort + rev: 5.5.3 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + exclude: (.vscode|.devcontainer) + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.790 + hooks: + - id: mypy + args: + - --pretty + - --show-error-codes - --show-error-context \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 43d3d7f..4ad70a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ -{ - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "files.trimTrailingWhitespace": true, - "git.ignoreLimitWarning": true, +{ + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "git.ignoreLimitWarning": true, } \ No newline at end of file diff --git a/custom_components/__init__.py b/custom_components/__init__.py index 4af8cf9..6ae0cb5 100644 --- a/custom_components/__init__.py +++ b/custom_components/__init__.py @@ -1 +1 @@ -"""Dummy __init__.py to make imports with pytest-homeassistant-custom-component work.""" +"""Dummy __init__.py to make imports with pytest-homeassistant-custom-component work.""" diff --git a/custom_components/yahoofinance/__init__.py b/custom_components/yahoofinance/__init__.py index 0a949e7..28e9801 100644 --- a/custom_components/yahoofinance/__init__.py +++ b/custom_components/yahoofinance/__init__.py @@ -6,11 +6,13 @@ from datetime import timedelta import logging -from typing import Union +from typing import Final, Union from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType import voluptuous as vol from custom_components.yahoofinance.coordinator import YahooSymbolUpdateCoordinator @@ -35,11 +37,10 @@ HASS_DATA_COORDINATOR, SERVICE_REFRESH, ) -from .coordinator import YahooSymbolUpdateCoordinator _LOGGER = logging.getLogger(__name__) -DEFAULT_SCAN_INTERVAL = timedelta(hours=6) -MINIMUM_SCAN_INTERVAL = timedelta(seconds=30) +DEFAULT_SCAN_INTERVAL: Final = timedelta(hours=6) +MINIMUM_SCAN_INTERVAL: Final = timedelta(seconds=30) BASIC_SYMBOL_SCHEMA = vol.All(cv.string, vol.Upper) @@ -94,6 +95,34 @@ ) +class SymbolDefinition: + """Symbol definition.""" + + symbol: str + target_currency: str + + def __init__(self, symbol: str, target_currency: Union[str, None] = None) -> None: + """Create a new symbol definition.""" + self.symbol = symbol + self.target_currency = target_currency + + def __repr__(self) -> str: + """Return the representation.""" + return f"{self.symbol},{self.target_currency}" + + def __eq__(self, other: any) -> bool: + """Return the comparison.""" + return ( + isinstance(other, SymbolDefinition) + and self.symbol == other.symbol + and self.target_currency == other.target_currency + ) + + def __hash__(self) -> int: + """Make hashable.""" + return hash((self.symbol, self.target_currency)) + + def parse_scan_interval(scan_interval: Union[timedelta, str]) -> timedelta: """Parse and validate scan_interval.""" if isinstance(scan_interval, str): @@ -110,31 +139,34 @@ def parse_scan_interval(scan_interval: Union[timedelta, str]) -> timedelta: return scan_interval -def normalize_input(defined_symbols): +def normalize_input(defined_symbols: list) -> tuple[list[str], list[SymbolDefinition]]: """Normalize input and remove duplicates.""" symbols = set() - normalized_symbols = [] + symbol_definitions: list[SymbolDefinition] = [] for value in defined_symbols: if isinstance(value, str): if value not in symbols: symbols.add(value) - normalized_symbols.append({"symbol": value}) + symbol_definitions.append(SymbolDefinition(value)) else: - if value["symbol"] not in symbols: - symbols.add(value["symbol"]) - normalized_symbols.append(value) + symbol = value["symbol"] + if symbol not in symbols: + symbols.add(symbol) + symbol_definitions.append( + SymbolDefinition(symbol, value.get(CONF_TARGET_CURRENCY)) + ) - return (list(symbols), normalized_symbols) + return (list(symbols), symbol_definitions) -async def async_setup(hass, config) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" domain_config = config.get(DOMAIN, {}) defined_symbols = domain_config.get(CONF_SYMBOLS, []) - symbols, normalized_symbols = normalize_input(defined_symbols) - domain_config[CONF_SYMBOLS] = normalized_symbols + symbols, symbol_definitions = normalize_input(defined_symbols) + domain_config[CONF_SYMBOLS] = symbol_definitions scan_interval = parse_scan_interval(domain_config.get(CONF_SCAN_INTERVAL)) @@ -155,7 +187,7 @@ async def async_setup(hass, config) -> bool: HASS_DATA_CONFIG: domain_config, } - async def handle_refresh_symbols(_call): + async def handle_refresh_symbols(_call) -> None: """Refresh symbol data.""" _LOGGER.info("Processing refresh_symbols") await coordinator.async_request_refresh() diff --git a/custom_components/yahoofinance/const.py b/custom_components/yahoofinance/const.py index 2e2528e..7f4df66 100644 --- a/custom_components/yahoofinance/const.py +++ b/custom_components/yahoofinance/const.py @@ -1,49 +1,51 @@ """Constants for Yahoo Finance sensor.""" +from typing import Final + # Additional attributes exposed by the sensor -ATTR_CURRENCY_SYMBOL = "currencySymbol" -ATTR_QUOTE_TYPE = "quoteType" -ATTR_QUOTE_SOURCE_NAME = "quoteSourceName" -ATTR_SYMBOL = "symbol" -ATTR_TRENDING = "trending" -ATTR_MARKET_STATE = "marketState" +ATTR_CURRENCY_SYMBOL: Final = "currencySymbol" +ATTR_QUOTE_TYPE: Final = "quoteType" +ATTR_QUOTE_SOURCE_NAME: Final = "quoteSourceName" +ATTR_SYMBOL: Final = "symbol" +ATTR_TRENDING: Final = "trending" +ATTR_MARKET_STATE: Final = "marketState" # Hass data -HASS_DATA_CONFIG = "config" -HASS_DATA_COORDINATOR = "coordinator" +HASS_DATA_CONFIG: Final = "config" +HASS_DATA_COORDINATOR: Final = "coordinator" # JSON data pieces -DATA_CURRENCY_SYMBOL = "currency" -DATA_FINANCIAL_CURRENCY = "financialCurrency" -DATA_QUOTE_TYPE = "quoteType" -DATA_QUOTE_SOURCE_NAME = "quoteSourceName" -DATA_SHORT_NAME = "shortName" -DATA_MARKET_STATE = "marketState" +DATA_CURRENCY_SYMBOL: Final = "currency" +DATA_FINANCIAL_CURRENCY: Final = "financialCurrency" +DATA_QUOTE_TYPE: Final = "quoteType" +DATA_QUOTE_SOURCE_NAME: Final = "quoteSourceName" +DATA_SHORT_NAME: Final = "shortName" +DATA_MARKET_STATE: Final = "marketState" -DATA_REGULAR_MARKET_PREVIOUS_CLOSE = "regularMarketPreviousClose" -DATA_REGULAR_MARKET_PRICE = "regularMarketPrice" +DATA_REGULAR_MARKET_PREVIOUS_CLOSE: Final = "regularMarketPreviousClose" +DATA_REGULAR_MARKET_PRICE: Final = "regularMarketPrice" -CONF_DECIMAL_PLACES = "decimal_places" -CONF_INCLUDE_FIFTY_DAY_VALUES = "include_fifty_day_values" -CONF_INCLUDE_POST_VALUES = "include_post_values" -CONF_INCLUDE_PRE_VALUES = "include_pre_values" -CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES = "include_two_hundred_day_values" -CONF_SHOW_TRENDING_ICON = "show_trending_icon" -CONF_TARGET_CURRENCY = "target_currency" +CONF_DECIMAL_PLACES: Final = "decimal_places" +CONF_INCLUDE_FIFTY_DAY_VALUES: Final = "include_fifty_day_values" +CONF_INCLUDE_POST_VALUES: Final = "include_post_values" +CONF_INCLUDE_PRE_VALUES: Final = "include_pre_values" +CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES: Final = "include_two_hundred_day_values" +CONF_SHOW_TRENDING_ICON: Final = "show_trending_icon" +CONF_TARGET_CURRENCY: Final = "target_currency" -DEFAULT_CONF_DECIMAL_PLACES = 2 -DEFAULT_CONF_INCLUDE_FIFTY_DAY_VALUES = True -DEFAULT_CONF_INCLUDE_POST_VALUES = True -DEFAULT_CONF_INCLUDE_PRE_VALUES = True -DEFAULT_CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES = True -DEFAULT_CONF_SHOW_TRENDING_ICON = False +DEFAULT_CONF_DECIMAL_PLACES: Final = 2 +DEFAULT_CONF_INCLUDE_FIFTY_DAY_VALUES: Final = True +DEFAULT_CONF_INCLUDE_POST_VALUES: Final = True +DEFAULT_CONF_INCLUDE_PRE_VALUES: Final = True +DEFAULT_CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES: Final = True +DEFAULT_CONF_SHOW_TRENDING_ICON: Final = False -DEFAULT_NUMERIC_DATA_GROUP = "default" +DEFAULT_NUMERIC_DATA_GROUP: Final = "default" # Data keys grouped into categories. The values for the categories (except for DEFAULT_NUMERIC_DATA_GROUP) # can be conditionally pulled into attributes. The first value of the set is the key and the second # boolean value indicates if the attribute is a currency. -NUMERIC_DATA_GROUPS = { +NUMERIC_DATA_GROUPS: Final = { DEFAULT_NUMERIC_DATA_GROUP: [ ("averageDailyVolume10Day", False), ("averageDailyVolume3Month", False), @@ -80,7 +82,7 @@ ], } -STRING_DATA_KEYS = [ +STRING_DATA_KEYS: Final = [ DATA_CURRENCY_SYMBOL, DATA_FINANCIAL_CURRENCY, DATA_QUOTE_TYPE, @@ -90,17 +92,17 @@ ] -ATTRIBUTION = "Data provided by Yahoo Finance" -BASE = "https://query1.finance.yahoo.com/v7/finance/quote?symbols=" +ATTRIBUTION: Final = "Data provided by Yahoo Finance" +BASE: Final = "https://query1.finance.yahoo.com/v7/finance/quote?symbols=" -CONF_SYMBOLS = "symbols" -DEFAULT_CURRENCY = "USD" -DEFAULT_CURRENCY_SYMBOL = "$" -DEFAULT_ICON = "mdi:currency-usd" -DOMAIN = "yahoofinance" -SERVICE_REFRESH = "refresh_symbols" +CONF_SYMBOLS: Final = "symbols" +DEFAULT_CURRENCY: Final = "USD" +DEFAULT_CURRENCY_SYMBOL: Final = "$" +DEFAULT_ICON: Final = "mdi:currency-usd" +DOMAIN: Final = "yahoofinance" +SERVICE_REFRESH: Final = "refresh_symbols" -CURRENCY_CODES = { +CURRENCY_CODES: Final = { "bdt": "৳", "brl": "R$", "btc": "₿", diff --git a/custom_components/yahoofinance/coordinator.py b/custom_components/yahoofinance/coordinator.py index e8809ba..771cc94 100644 --- a/custom_components/yahoofinance/coordinator.py +++ b/custom_components/yahoofinance/coordinator.py @@ -6,9 +6,10 @@ from datetime import timedelta import logging +from typing import Final import async_timeout -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time @@ -23,22 +24,22 @@ ) _LOGGER = logging.getLogger(__name__) -WEBSESSION_TIMEOUT = 15 -DELAY_ASYNC_REQUEST_REFRESH = 5 -FAILURE_ASYNC_REQUEST_REFRESH = 20 +WEBSESSION_TIMEOUT: Final = 15 +DELAY_ASYNC_REQUEST_REFRESH: Final = 5 +FAILURE_ASYNC_REQUEST_REFRESH: Final = 20 class YahooSymbolUpdateCoordinator(DataUpdateCoordinator): """Yahoo finance data update coordinator.""" @staticmethod - def parse_symbol_data(symbol_data): + def parse_symbol_data(symbol_data: dict) -> dict[str, any]: """Return data pieces which we care about, use 0 for missing numeric values.""" data = {} # get() ensures that we have an entry in symbol_data. - for group in NUMERIC_DATA_GROUPS: - for value in NUMERIC_DATA_GROUPS[group]: + for data_group in NUMERIC_DATA_GROUPS.values(): + for value in data_group: key = value[0] data[key] = symbol_data.get(key, 0) @@ -49,7 +50,7 @@ def parse_symbol_data(symbol_data): @staticmethod def fix_conversion_symbol(symbol: str, symbol_data: any) -> str: - """ Fix the conversion symbol from data.""" + """Fix the conversion symbol from data.""" if symbol is None or symbol == "" or not symbol.endswith("=X"): return symbol @@ -74,7 +75,9 @@ def fix_conversion_symbol(symbol: str, symbol_data: any) -> str: return conversion_symbol - def __init__(self, symbols, hass, update_interval) -> None: + def __init__( + self, symbols: list[str], hass: HomeAssistant, update_interval: timedelta + ) -> None: """Initialize.""" self._symbols = symbols self.data = None @@ -91,7 +94,7 @@ def __init__(self, symbols, hass, update_interval) -> None: update_interval=update_interval, ) - def get_next_update_interval(self): + def get_next_update_interval(self) -> timedelta: """Get the update interval for the next async_track_point_in_utc_time call.""" if self.last_update_success: return self._update_interval @@ -122,7 +125,7 @@ def _schedule_refresh(self) -> None: utcnow().replace(microsecond=0) + update_interval, ) - def get_symbols(self): + def get_symbols(self) -> list[str]: """Return symbols tracked by the coordinator.""" return self._symbols @@ -130,7 +133,7 @@ async def _async_request_refresh_later(self, _now): """Request async_request_refresh.""" await self.async_request_refresh() - def add_symbol(self, symbol) -> bool: + def add_symbol(self, symbol: str) -> bool: """Add symbol to the symbol list.""" if symbol not in self._symbols: self._symbols.append(symbol) @@ -154,7 +157,7 @@ def add_symbol(self, symbol) -> bool: return False - async def get_json(self): + async def get_json(self) -> dict: """Get the JSON data.""" json = None url = BASE + ",".join(self._symbols) @@ -166,7 +169,7 @@ async def get_json(self): return json - async def _async_update(self): + async def _async_update(self) -> dict: """ Return updated data if new JSON is valid. @@ -205,8 +208,8 @@ async def _async_update(self): _LOGGER.info("All symbols updated") return data - def process_json_result(self, result): - """Processes json result and returns (error status, updated data).""" + def process_json_result(self, result) -> tuple[bool, dict]: + """Process json result and return (error status, updated data).""" # Using current data if available. If returned data is missing then we might be # able to use previous data. diff --git a/custom_components/yahoofinance/sensor.py b/custom_components/yahoofinance/sensor.py index c5a2a7d..9c6ae76 100644 --- a/custom_components/yahoofinance/sensor.py +++ b/custom_components/yahoofinance/sensor.py @@ -4,12 +4,19 @@ https://github.com/iprak/yahoofinance """ +from collections.abc import Mapping import logging from timeit import default_timer as timer +from typing import Any, Union from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from custom_components.yahoofinance import SymbolDefinition +from custom_components.yahoofinance.coordinator import YahooSymbolUpdateCoordinator from .const import ( ATTR_CURRENCY_SYMBOL, @@ -22,7 +29,6 @@ CONF_DECIMAL_PLACES, CONF_SHOW_TRENDING_ICON, CONF_SYMBOLS, - CONF_TARGET_CURRENCY, CURRENCY_CODES, DATA_CURRENCY_SYMBOL, DATA_FINANCIAL_CURRENCY, @@ -45,23 +51,23 @@ ENTITY_ID_FORMAT = SENSOR_DOMAIN + "." + DOMAIN + "_{}" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform(hass, _config, async_add_entities, _discovery_info=None): """Set up the Yahoo Finance sensor platform.""" coordinator = hass.data[DOMAIN][HASS_DATA_COORDINATOR] domain_config = hass.data[DOMAIN][HASS_DATA_CONFIG] - symbols = domain_config[CONF_SYMBOLS] + symbol_definitions: list[SymbolDefinition] = domain_config[CONF_SYMBOLS] sensors = [ YahooFinanceSensor(hass, coordinator, symbol, domain_config) - for symbol in symbols + for symbol in symbol_definitions ] async_add_entities(sensors, update_before_add=False) - _LOGGER.info("Entities added for %s", [item["symbol"] for item in symbols]) + _LOGGER.info("Entities added for %s", [item.symbol for item in symbol_definitions]) -class YahooFinanceSensor(Entity): +class YahooFinanceSensor(CoordinatorEntity): """Represents a Yahoo finance entity.""" _currency = DEFAULT_CURRENCY @@ -72,16 +78,22 @@ class YahooFinanceSensor(Entity): _original_currency = None _last_available_timer = None - def __init__(self, hass, coordinator, symbol_definition, domain_config) -> None: - """Initialize the sensor.""" - symbol = symbol_definition.get("symbol") - self._hass = hass + def __init__( + self, + hass, + coordinator: YahooSymbolUpdateCoordinator, + symbol_definition: SymbolDefinition, + domain_config, + ) -> None: + """Initialize the YahooFinance entity.""" + super().__init__(coordinator) + + symbol = symbol_definition.symbol self._symbol = symbol - self._coordinator = coordinator self._show_trending_icon = domain_config[CONF_SHOW_TRENDING_ICON] self._decimal_places = domain_config[CONF_DECIMAL_PLACES] self._previous_close = None - self._target_currency = symbol_definition.get(CONF_TARGET_CURRENCY) + self._target_currency = symbol_definition.target_currency self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, symbol, hass=hass) @@ -97,6 +109,8 @@ def __init__(self, hass, coordinator, symbol_definition, domain_config) -> None: # List of groups to include as attributes self._numeric_data_to_include = [] + # pylint: disable=consider-using-dict-items + # Initialize all numeric attributes which we want to include to None for group in NUMERIC_DATA_GROUPS: if group == DEFAULT_NUMERIC_DATA_GROUP or domain_config.get(group, True): @@ -120,12 +134,7 @@ def name(self) -> str: return self._symbol @property - def should_poll(self) -> bool: - """No need to poll. Coordinator notifies entity of updates.""" - return False - - @property - def state(self): + def state(self) -> StateType: """Return the state of the sensor.""" return self._round(self._market_price) @@ -135,7 +144,7 @@ def unit_of_measurement(self) -> str: return self._currency @property - def device_state_attributes(self): + def device_state_attributes(self) -> Union[Mapping[str, Any], None]: """Return the state attributes.""" return self._attributes @@ -144,7 +153,7 @@ def icon(self) -> str: """Return the icon to use in the frontend, if any.""" return self._icon - def _round(self, value): + def _round(self, value: Union[float, None]) -> Union[float, int, None]: """Return formatted value based on decimal_places.""" if value is None: return None @@ -156,7 +165,7 @@ def _round(self, value): return round(value, self._decimal_places) - def _get_target_currency_conversion(self) -> float: + def _get_target_currency_conversion(self) -> Union[float, None]: value = None if self._target_currency and self._original_currency: @@ -167,7 +176,7 @@ def _get_target_currency_conversion(self) -> float: conversion_symbol = ( f"{self._original_currency}{self._target_currency}=X".upper() ) - data = self._coordinator.data + data = self.coordinator.data if data is not None: symbol_data = data.get(conversion_symbol) @@ -181,12 +190,14 @@ def _get_target_currency_conversion(self) -> float: self._symbol, conversion_symbol, ) - self._coordinator.add_symbol(conversion_symbol) + self.coordinator.add_symbol(conversion_symbol) return value @staticmethod - def safe_convert(value, conversion): + def safe_convert( + value: Union[float, None], conversion: Union[float, None] + ) -> Union[float, None]: """Return the converted value. The original value is returned if there is no conversion.""" if value is None: return None @@ -218,7 +229,7 @@ def _update_original_currency(self, symbol_data) -> bool: def _update_properties(self) -> None: """Update local fields.""" - data = self._coordinator.data + data = self.coordinator.data if data is None: _LOGGER.debug("%s Coordinator data is None", self._symbol) return @@ -292,7 +303,7 @@ def _update_properties(self) -> None: # Don't show $ as the CurrencySymbol even if we can't get one. self._attributes[ATTR_CURRENCY_SYMBOL] = CURRENCY_CODES.get(lower_currency) - def _calc_trending_state(self): + def _calc_trending_state(self) -> Union[str, None]: """Return the trending state for the symbol.""" if self._market_price is None or self._previous_close is None: return None @@ -318,16 +329,4 @@ def available(self) -> bool: self._update_properties() self._last_available_timer = current_timer - return self._coordinator.last_update_success - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self._coordinator.async_add_listener(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """When entity will be removed from hass.""" - self._coordinator.async_remove_listener(self.async_write_ha_state) - - async def async_update(self) -> None: - """Update symbol data.""" - await self._coordinator.async_request_refresh() + return self.coordinator.last_update_success diff --git a/custom_components/yahoofinance/services.yaml b/custom_components/yahoofinance/services.yaml index 2bd4efe..215e152 100644 --- a/custom_components/yahoofinance/services.yaml +++ b/custom_components/yahoofinance/services.yaml @@ -1,4 +1,4 @@ -# Describes the format of available services for yahoofinance - -refresh_symbols: +# Describes the format of available services for yahoofinance + +refresh_symbols: description: Refresh data for all the symbols. \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 21f0adc..bc1929b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,20 +1,20 @@ -# Custom requirements - -# Pre-commit requirements -bandit==1.7.0 -black==21.5b1 -codespell==2.0.0 -flake8-docstrings==1.6.0 -flake8==3.9.2 -isort==5.8.0 -pydocstyle==6.0.0 -pyupgrade==2.16.0 -yamllint==1.26.1 - - -# Unit tests requirements -pre-commit==2.12.1 -pylint==2.8.2 -pytest==6.2.4 -pytest-cov==2.10.1 +# Custom requirements + +# Pre-commit requirements +bandit==1.7.0 +black==21.9b0 +codespell==2.0.0 +flake8-docstrings==1.6.0 +flake8==4.0.1 +isort==5.9.3 +pydocstyle==6.1.1 +pyupgrade==2.27.0 +yamllint==1.26.1 + + +# Unit tests requirements +pre-commit==2.15.0 +pylint==2.11.1 +pytest==6.2.5 +pytest-cov==2.12.1 pytest-homeassistant-custom-component \ No newline at end of file diff --git a/scripts/format.sh b/scripts/format.sh index 333cce8..ef7541b 100644 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -1,3 +1,3 @@ -#!/bin/bash -python -m isort -v --profile black . +#!/bin/bash +python -m isort -v --profile black . python -m black -v . \ No newline at end of file diff --git a/scripts/lint.sh b/scripts/lint.sh index 2ae4b4e..6136818 100644 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -1,2 +1,2 @@ -#!/bin/bash +#!/bin/bash flake8 . --count --exit-zero --max-complexity=15 --max-line-length=90 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 47fa40c..32dbb94 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,63 +1,64 @@ -[coverage:run] -source = - custom_components - -[coverage:report] -exclude_lines = - pragma: no cover - raise NotImplemented() - if __name__ == '__main__': - main() -fail_under = 93 -show_missing = true - -[tool:pytest] -testpaths = tests -norecursedirs = .git -addopts = - --strict - --cov=custom_components - -[flake8] -exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build -doctests = True -# To work with Black -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# W504 line break after binary operator -ignore = - E501, - W503, - E203, - D202, - W504 - -[isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces -multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -indent = " " -# will group `import x` and `from x import` of the same module. -force_sort_within_sections = true -sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -default_section = THIRDPARTY -known_first_party = custom_components,tests -forced_separate = tests -combine_as_imports = true - -[mypy] -python_version = 3.8 -show_error_codes = true -ignore_errors = true -follow_imports = silent -ignore_missing_imports = true -warn_incomplete_stub = true -warn_redundant_casts = true +[coverage:run] +source = + custom_components + +[coverage:report] +exclude_lines = + pragma: no cover + raise NotImplemented() + if __name__ == '__main__': + main() +fail_under = 93 +show_missing = true + +[tool:pytest] +testpaths = tests +norecursedirs = .git +addopts = + --strict + --cov=custom_components + +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +max-complexity = 25 +doctests = True +# To work with Black +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components,tests +forced_separate = tests +combine_as_imports = true + +[mypy] +python_version = 3.8 +show_error_codes = true +ignore_errors = true +follow_imports = silent +ignore_missing_imports = true +warn_incomplete_stub = true +warn_redundant_casts = true warn_unused_configs = true \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index 4ec6314..9a825f1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for Yahoo Finance component.""" +"""Tests for Yahoo Finance component.""" diff --git a/tests/bad_request.json b/tests/bad_request.json index 4505972..9cf7a55 100644 --- a/tests/bad_request.json +++ b/tests/bad_request.json @@ -1,9 +1,9 @@ -{ - "finance": { - "result": null, - "error": { - "code": "Bad Request", - "description": "Missing required query parameter=symbols" - } - } +{ + "finance": { + "result": null, + "error": { + "code": "Bad Request", + "description": "Missing required query parameter=symbols" + } + } } \ No newline at end of file diff --git a/tests/bandit.yaml b/tests/bandit.yaml index ebd284e..40d4837 100644 --- a/tests/bandit.yaml +++ b/tests/bandit.yaml @@ -1,17 +1,17 @@ -# https://bandit.readthedocs.io/en/latest/config.html - -tests: - - B108 - - B306 - - B307 - - B313 - - B314 - - B315 - - B316 - - B317 - - B318 - - B319 - - B320 - - B325 - - B602 - - B604 +# https://bandit.readthedocs.io/en/latest/config.html + +tests: + - B108 + - B306 + - B307 + - B313 + - B314 + - B315 + - B316 + - B317 + - B318 + - B319 + - B320 + - B325 + - B602 + - B604 diff --git a/tests/conftest.py b/tests/conftest.py index 4fc56d9..1c5faaf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,18 @@ -"""Tests for Yahoo Finance component.""" -import json -import os - -import pytest - - -def load_json(filename): - """Load sample JSON.""" - path = os.path.join(os.path.dirname(__file__), filename) - with open(path, encoding="utf-8") as fptr: - return fptr.read() - - -@pytest.fixture -def mock_json(): - """Return sample JSON data.""" - yield json.loads(load_json("yahoofinance.json")) +"""Tests for Yahoo Finance component.""" +import json +import os + +import pytest + + +def load_json(filename): + """Load sample JSON.""" + path = os.path.join(os.path.dirname(__file__), filename) + with open(path, encoding="utf-8") as fptr: + return fptr.read() + + +@pytest.fixture +def mock_json(): + """Return sample JSON data.""" + yield json.loads(load_json("yahoofinance.json")) diff --git a/tests/test_init.py b/tests/test_init.py index a2f25d0..c861b5d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,193 +1,195 @@ -"""Tests for Yahoo Finance component.""" - -from datetime import timedelta -from unittest.mock import AsyncMock, Mock, patch - -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.setup import async_setup_component -import pytest -import voluptuous as vol - -from custom_components.yahoofinance import ( - DEFAULT_SCAN_INTERVAL, - MINIMUM_SCAN_INTERVAL, - parse_scan_interval, -) -from custom_components.yahoofinance.const import ( - CONF_DECIMAL_PLACES, - CONF_INCLUDE_FIFTY_DAY_VALUES, - CONF_INCLUDE_POST_VALUES, - CONF_INCLUDE_PRE_VALUES, - CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES, - CONF_SHOW_TRENDING_ICON, - CONF_SYMBOLS, - DEFAULT_CONF_DECIMAL_PLACES, - DEFAULT_CONF_INCLUDE_FIFTY_DAY_VALUES, - DEFAULT_CONF_INCLUDE_POST_VALUES, - DEFAULT_CONF_INCLUDE_PRE_VALUES, - DEFAULT_CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES, - DEFAULT_CONF_SHOW_TRENDING_ICON, - DOMAIN, - HASS_DATA_CONFIG, - SERVICE_REFRESH, -) - -SAMPLE_CONFIG = {DOMAIN: {CONF_SYMBOLS: ["BABA"]}} -YSUC = "custom_components.yahoofinance.YahooSymbolUpdateCoordinator" -DEFAULT_OPTIONAL_CONFIG = { - CONF_DECIMAL_PLACES: DEFAULT_CONF_DECIMAL_PLACES, - CONF_INCLUDE_FIFTY_DAY_VALUES: DEFAULT_CONF_INCLUDE_FIFTY_DAY_VALUES, - CONF_INCLUDE_POST_VALUES: DEFAULT_CONF_INCLUDE_POST_VALUES, - CONF_INCLUDE_PRE_VALUES: DEFAULT_CONF_INCLUDE_PRE_VALUES, - CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES: DEFAULT_CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES, - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_SHOW_TRENDING_ICON: DEFAULT_CONF_SHOW_TRENDING_ICON, -} - - -@pytest.mark.parametrize( - "domain_config, expected_partial_config", - [ - ( - # Normalize test - {CONF_SYMBOLS: ["xyz"]}, - {CONF_SYMBOLS: [{"symbol": "XYZ"}]}, - ), - ( - # Another normalize test - {CONF_SYMBOLS: [{"symbol": "xyz"}]}, - {CONF_SYMBOLS: [{"symbol": "XYZ"}]}, - ), - ( - {CONF_SYMBOLS: [{"symbol": "xyz"}, "abc"]}, - {CONF_SYMBOLS: [{"symbol": "XYZ"}, {"symbol": "ABC"}]}, - ), - ( - # Duplicate removal test - {CONF_SYMBOLS: ["xyz", "xyz"]}, - {CONF_SYMBOLS: [{"symbol": "XYZ"}]}, - ), - ( - # Another duplicate removal test - {CONF_SYMBOLS: [{"symbol": "xyz"}, "xyz"]}, - {CONF_SYMBOLS: [{"symbol": "XYZ"}]}, - ), - ( - { - CONF_SYMBOLS: ["xyz"], - CONF_SCAN_INTERVAL: 3600, - CONF_DECIMAL_PLACES: 3, - }, - { - CONF_SYMBOLS: [{"symbol": "XYZ"}], - CONF_SCAN_INTERVAL: timedelta(hours=1), - CONF_DECIMAL_PLACES: 3, - }, - ), - ( - { - CONF_SYMBOLS: ["xyz"], - CONF_SCAN_INTERVAL: "None", - }, - { - CONF_SYMBOLS: [{"symbol": "XYZ"}], - CONF_SCAN_INTERVAL: None, - }, - ), - ( - { - CONF_SYMBOLS: ["xyz"], - CONF_SCAN_INTERVAL: "none", - }, - { - CONF_SYMBOLS: [{"symbol": "XYZ"}], - CONF_SCAN_INTERVAL: None, - }, - ), - ], -) -async def test_setup_refreshes_data_coordinator_and_loads_platform( - hass, domain_config, expected_partial_config -): - """Component setup refreshed data coordinator and loads the platform.""" - - with patch( - "homeassistant.helpers.discovery.async_load_platform" - ) as mock_async_load_platform, patch( - f"{YSUC}.async_refresh", AsyncMock(return_value=None) - ) as mock_coordinator_async_refresh: - - config = {DOMAIN: domain_config} - - assert await async_setup_component(hass, DOMAIN, config) is True - await hass.async_block_till_done() - - assert mock_coordinator_async_refresh.call_count == 1 - assert mock_async_load_platform.call_count == 1 - - expected_config = DEFAULT_OPTIONAL_CONFIG.copy() - expected_config.update(expected_partial_config) - assert expected_config == hass.data[DOMAIN][HASS_DATA_CONFIG] - - -@pytest.mark.parametrize( - "scan_interval", - [ - (timedelta(-1)), - (MINIMUM_SCAN_INTERVAL - timedelta(seconds=1)), - ("None2"), - ], -) -def test_invalid_scan_interval(hass, scan_interval): - """Test invalid scan interval.""" - - with pytest.raises(vol.Invalid): - parse_scan_interval(scan_interval) - - -async def test_setup_optionally_requests_coordinator_refresh(hass): - """Component setup requests data coordinator refresh if it failed to load data.""" - - with patch( - "homeassistant.helpers.discovery.async_load_platform" - ) as mock_async_load_platform, patch(YSUC) as mock_coordinator: - - mock_instance = Mock() - mock_instance.async_refresh = AsyncMock(return_value=None) - mock_instance.async_request_refresh = AsyncMock(return_value=None) - - # Mock `last_update_success` to be False which results in a call to `async_request_refresh` - mock_instance.last_update_success = False - - mock_coordinator.return_value = mock_instance - - assert await async_setup_component(hass, DOMAIN, SAMPLE_CONFIG) is True - await hass.async_block_till_done() - - assert mock_coordinator.called_with( - SAMPLE_CONFIG[DOMAIN][CONF_SYMBOLS], hass, DEFAULT_SCAN_INTERVAL - ) - assert mock_instance.async_refresh.call_count == 1 - assert mock_instance.async_request_refresh.call_count == 1 - assert mock_async_load_platform.call_count == 1 - - -async def test_refresh_symbols_service(hass): - """Test refresh_symbols service callback.""" - - with patch("homeassistant.helpers.discovery.async_load_platform"), patch( - f"{YSUC}.async_request_refresh", AsyncMock(return_value=None) - ) as mock_async_request_refresh: - - assert await async_setup_component(hass, DOMAIN, SAMPLE_CONFIG) is True - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert mock_async_request_refresh.call_count == 1 +"""Tests for Yahoo Finance component.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.setup import async_setup_component +import pytest +import voluptuous as vol + +from custom_components.yahoofinance import ( + DEFAULT_SCAN_INTERVAL, + MINIMUM_SCAN_INTERVAL, + SymbolDefinition, + parse_scan_interval, +) +from custom_components.yahoofinance.const import ( + CONF_DECIMAL_PLACES, + CONF_INCLUDE_FIFTY_DAY_VALUES, + CONF_INCLUDE_POST_VALUES, + CONF_INCLUDE_PRE_VALUES, + CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES, + CONF_SHOW_TRENDING_ICON, + CONF_SYMBOLS, + DEFAULT_CONF_DECIMAL_PLACES, + DEFAULT_CONF_INCLUDE_FIFTY_DAY_VALUES, + DEFAULT_CONF_INCLUDE_POST_VALUES, + DEFAULT_CONF_INCLUDE_PRE_VALUES, + DEFAULT_CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES, + DEFAULT_CONF_SHOW_TRENDING_ICON, + DOMAIN, + HASS_DATA_CONFIG, + SERVICE_REFRESH, +) + +SAMPLE_CONFIG = {DOMAIN: {CONF_SYMBOLS: ["BABA"]}} +YSUC = "custom_components.yahoofinance.YahooSymbolUpdateCoordinator" +DEFAULT_OPTIONAL_CONFIG = { + CONF_DECIMAL_PLACES: DEFAULT_CONF_DECIMAL_PLACES, + CONF_INCLUDE_FIFTY_DAY_VALUES: DEFAULT_CONF_INCLUDE_FIFTY_DAY_VALUES, + CONF_INCLUDE_POST_VALUES: DEFAULT_CONF_INCLUDE_POST_VALUES, + CONF_INCLUDE_PRE_VALUES: DEFAULT_CONF_INCLUDE_PRE_VALUES, + CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES: DEFAULT_CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES, + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_SHOW_TRENDING_ICON: DEFAULT_CONF_SHOW_TRENDING_ICON, +} + + +@pytest.mark.parametrize( + "domain_config, expected_partial_config", + [ + ( + # Normalize test + {CONF_SYMBOLS: ["xyz"]}, + {CONF_SYMBOLS: [SymbolDefinition("XYZ")]}, + ), + ( + # Another normalize test + {CONF_SYMBOLS: [{"symbol": "xyz"}]}, + {CONF_SYMBOLS: [SymbolDefinition("XYZ")]}, + ), + ( + {CONF_SYMBOLS: [{"symbol": "xyz"}, "abc"]}, + {CONF_SYMBOLS: [SymbolDefinition("XYZ"), SymbolDefinition("ABC")]}, + ), + ( + # Duplicate removal test + {CONF_SYMBOLS: ["xyz", "xyz"]}, + {CONF_SYMBOLS: [SymbolDefinition("XYZ")]}, + ), + ( + # Another duplicate removal test + {CONF_SYMBOLS: [{"symbol": "xyz"}, "xyz"]}, + {CONF_SYMBOLS: [SymbolDefinition("XYZ")]}, + ), + ( + { + CONF_SYMBOLS: ["xyz"], + CONF_SCAN_INTERVAL: 3600, + CONF_DECIMAL_PLACES: 3, + }, + { + CONF_SYMBOLS: [SymbolDefinition("XYZ")], + CONF_SCAN_INTERVAL: timedelta(hours=1), + CONF_DECIMAL_PLACES: 3, + }, + ), + ( + { + CONF_SYMBOLS: ["xyz"], + CONF_SCAN_INTERVAL: "None", + }, + { + CONF_SYMBOLS: [SymbolDefinition("XYZ")], + CONF_SCAN_INTERVAL: None, + }, + ), + ( + { + CONF_SYMBOLS: ["xyz"], + CONF_SCAN_INTERVAL: "none", + }, + { + CONF_SYMBOLS: [SymbolDefinition("XYZ")], + CONF_SCAN_INTERVAL: None, + }, + ), + ], +) +async def test_setup_refreshes_data_coordinator_and_loads_platform( + hass, domain_config, expected_partial_config, enable_custom_integrations +): + """Component setup refreshed data coordinator and loads the platform.""" + + config = {DOMAIN: domain_config} + + assert await async_setup_component(hass, DOMAIN, config) is True + await hass.async_block_till_done() + + assert DOMAIN in hass.data + + expected_config = DEFAULT_OPTIONAL_CONFIG.copy() + expected_config.update(expected_partial_config) + + assert expected_config == hass.data[DOMAIN][HASS_DATA_CONFIG] + + +@pytest.mark.parametrize( + "scan_interval", + [ + (timedelta(-1)), + (MINIMUM_SCAN_INTERVAL - timedelta(seconds=1)), + ("None2"), + ], +) +def test_invalid_scan_interval(hass, scan_interval): + """Test invalid scan interval.""" + + with pytest.raises(vol.Invalid): + parse_scan_interval(scan_interval) + + +async def test_setup_optionally_requests_coordinator_refresh( + hass, enable_custom_integrations +): + """Component setup requests data coordinator refresh if it failed to load data.""" + + with patch(YSUC) as mock_coordinator: + mock_instance = Mock() + mock_instance.async_refresh = AsyncMock(return_value=None) + mock_instance.async_request_refresh = AsyncMock(return_value=None) + + # Mock `last_update_success` to be False which results in a call to `async_request_refresh` + mock_instance.last_update_success = False + + mock_coordinator.return_value = mock_instance + + assert await async_setup_component(hass, DOMAIN, SAMPLE_CONFIG) is True + await hass.async_block_till_done() + + assert mock_coordinator.called_with( + SAMPLE_CONFIG[DOMAIN][CONF_SYMBOLS], hass, DEFAULT_SCAN_INTERVAL + ) + assert mock_instance.async_refresh.call_count == 1 + assert mock_instance.async_request_refresh.call_count == 1 + + +async def test_refresh_symbols_service(hass, enable_custom_integrations): + """Test refresh_symbols service callback.""" + + with patch( + f"{YSUC}.async_request_refresh", AsyncMock(return_value=None) + ) as mock_async_request_refresh: + + assert await async_setup_component(hass, DOMAIN, SAMPLE_CONFIG) is True + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_async_request_refresh.call_count == 1 + + +def test_SymbolDefinition_comparison(): + """Test SymbolDefinition instance comparison.""" + sym1 = SymbolDefinition("ABC") + sym2 = SymbolDefinition("ABC") + assert sym1 == sym2 + assert hash(sym1) == hash(sym2) + assert str(sym1) == str(sym2) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 2c01d6b..666a630 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -7,6 +7,7 @@ from custom_components.yahoofinance import ( DEFAULT_SCAN_INTERVAL, + SymbolDefinition, YahooSymbolUpdateCoordinator, ) from custom_components.yahoofinance.const import ( @@ -19,7 +20,6 @@ CONF_INCLUDE_TWO_HUNDRED_DAY_VALUES, CONF_SHOW_TRENDING_ICON, CONF_SYMBOLS, - CONF_TARGET_CURRENCY, DATA_CURRENCY_SYMBOL, DATA_REGULAR_MARKET_PREVIOUS_CLOSE, DATA_REGULAR_MARKET_PRICE, @@ -108,7 +108,7 @@ async def test_setup_platform(hass): mock_coordinator = Mock() config = copy.deepcopy(DEFAULT_OPTIONAL_CONFIG) - config[CONF_SYMBOLS] = [{"symbol": "BABA"}] + config[CONF_SYMBOLS] = [SymbolDefinition("BABA")] hass.data = { DOMAIN: { @@ -135,7 +135,7 @@ def test_sensor_creation( ) sensor = YahooFinanceSensor( - hass, mock_coordinator, {"symbol": symbol}, DEFAULT_OPTIONAL_CONFIG + hass, mock_coordinator, SymbolDefinition(symbol), DEFAULT_OPTIONAL_CONFIG ) # Accessing `available` triggers data population @@ -150,8 +150,8 @@ def test_sensor_creation( assert attributes[ATTR_TRENDING] == "up" # All numeric values besides DATA_REGULAR_MARKET_PRICE should be 0 - for numeric_data_key in NUMERIC_DATA_GROUPS: - for value in NUMERIC_DATA_GROUPS[numeric_data_key]: + for data_group in NUMERIC_DATA_GROUPS.values(): + for value in data_group: key = value[0] if key != DATA_REGULAR_MARKET_PRICE: assert attributes[key] == 0 @@ -183,7 +183,9 @@ def test_sensor_decimal_placs( config = copy.deepcopy(DEFAULT_OPTIONAL_CONFIG) config[CONF_DECIMAL_PLACES] = decimal_places - sensor = YahooFinanceSensor(hass, mock_coordinator, {"symbol": symbol}, config) + sensor = YahooFinanceSensor( + hass, mock_coordinator, SymbolDefinition(symbol), config + ) # Accessing `available` triggers data population assert sensor.available is True @@ -205,7 +207,10 @@ def test_sensor_data_when_coordinator_is_missing_symbol_data( # Create a sensor for some other symbol symbol_to_test = "ABC" sensor = YahooFinanceSensor( - hass, mock_coordinator, {"symbol": symbol_to_test}, DEFAULT_OPTIONAL_CONFIG + hass, + mock_coordinator, + SymbolDefinition(symbol_to_test), + DEFAULT_OPTIONAL_CONFIG, ) # Accessing `available` triggers data population @@ -228,7 +233,7 @@ def test_sensor_data_when_coordinator_returns_none(hass): ) sensor = YahooFinanceSensor( - hass, mock_coordinator, {"symbol": symbol}, DEFAULT_OPTIONAL_CONFIG + hass, mock_coordinator, SymbolDefinition(symbol), DEFAULT_OPTIONAL_CONFIG ) # Accessing `available` triggers data population @@ -246,7 +251,7 @@ async def test_sensor_update_calls_coordinator(hass): mock_coordinator = build_mock_coordinator(hass, True, symbol, None) mock_coordinator.async_request_refresh = AsyncMock(return_value=None) sensor = YahooFinanceSensor( - hass, mock_coordinator, {"symbol": symbol}, DEFAULT_OPTIONAL_CONFIG + hass, mock_coordinator, SymbolDefinition(symbol), DEFAULT_OPTIONAL_CONFIG ) await sensor.async_update() @@ -276,7 +281,9 @@ def test_sensor_trend( config = copy.deepcopy(DEFAULT_OPTIONAL_CONFIG) config[CONF_SHOW_TRENDING_ICON] = show_trending - sensor = YahooFinanceSensor(hass, mock_coordinator, {"symbol": symbol}, config) + sensor = YahooFinanceSensor( + hass, mock_coordinator, SymbolDefinition(symbol), config + ) # Accessing `available` triggers data population assert sensor.available is True @@ -304,7 +311,9 @@ def test_sensor_trending_state_is_not_populate_if_previous_closing_missing(hass) config = copy.deepcopy(DEFAULT_OPTIONAL_CONFIG) config[CONF_SHOW_TRENDING_ICON] = True - sensor = YahooFinanceSensor(hass, mock_coordinator, {"symbol": symbol}, config) + sensor = YahooFinanceSensor( + hass, mock_coordinator, SymbolDefinition(symbol), config + ) # Accessing `available` triggers data population assert sensor.available is True @@ -328,7 +337,7 @@ async def test_data_from_json(hass, mock_json): await hass.async_block_till_done() sensor = YahooFinanceSensor( - hass, coordinator, {"symbol": symbol}, DEFAULT_OPTIONAL_CONFIG + hass, coordinator, SymbolDefinition(symbol), DEFAULT_OPTIONAL_CONFIG ) # Accessing `available` triggers data population @@ -364,7 +373,7 @@ def test_conversion(hass): sensor = YahooFinanceSensor( hass, mock_coordinator, - {"symbol": symbol, CONF_TARGET_CURRENCY: "CHF"}, + SymbolDefinition(symbol, "CHF"), DEFAULT_OPTIONAL_CONFIG, ) @@ -385,7 +394,7 @@ def test_conversion_requests_additional_data_from_coordinator(hass): sensor = YahooFinanceSensor( hass, mock_coordinator, - {"symbol": symbol, CONF_TARGET_CURRENCY: "EUR"}, + SymbolDefinition(symbol, "EUR"), DEFAULT_OPTIONAL_CONFIG, ) @@ -408,7 +417,7 @@ def test_conversion_not_attempted_if_target_currency_same(hass): sensor = YahooFinanceSensor( hass, mock_coordinator, - {"symbol": symbol, CONF_TARGET_CURRENCY: "USD"}, + SymbolDefinition(symbol, "USD"), DEFAULT_OPTIONAL_CONFIG, ) @@ -433,36 +442,10 @@ def test_repeated_available(hass): type(mock_coordinator).data = mock_data sensor = YahooFinanceSensor( - hass, mock_coordinator, {"symbol": symbol}, DEFAULT_OPTIONAL_CONFIG + hass, mock_coordinator, SymbolDefinition(symbol), DEFAULT_OPTIONAL_CONFIG ) # Calling available in quick successions results in property updates once assert sensor.available assert sensor.available assert mock_data.call_count == 1 - - -async def test_setup_listener_registration(hass): - """Test entity listener registration.""" - symbol = "XYZ" - mock_coordinator = build_mock_coordinator(hass, True, symbol, 12) - - sensor = YahooFinanceSensor( - hass, mock_coordinator, {"symbol": symbol}, DEFAULT_OPTIONAL_CONFIG - ) - - await sensor.async_added_to_hass() - assert mock_coordinator.async_add_listener.call_count == 1 - - -async def test_setup_listener_unregistration(hass): - """Test entity listener unregistration.""" - symbol = "XYZ" - mock_coordinator = build_mock_coordinator(hass, True, symbol, 12) - - sensor = YahooFinanceSensor( - hass, mock_coordinator, {"symbol": symbol}, DEFAULT_OPTIONAL_CONFIG - ) - - await sensor.async_will_remove_from_hass() - assert mock_coordinator.async_remove_listener.call_count == 1 diff --git a/tests/yahoofinance.json b/tests/yahoofinance.json index 0095062..c2aa243 100644 --- a/tests/yahoofinance.json +++ b/tests/yahoofinance.json @@ -1,80 +1,80 @@ -{ - "quoteResponse": { - "result": [ - { - "language": "en-US", - "region": "US", - "quoteType": "EQUITY", - "quoteSourceName": "Delayed Quote", - "triggerable": true, - "currency": "USD", - "exchange": "NYQ", - "shortName": "Alibaba Group Holding Limited", - "longName": "Alibaba Group Holding Limited", - "messageBoardId": "finmb_42083601", - "exchangeTimezoneName": "America/New_York", - "exchangeTimezoneShortName": "EST", - "gmtOffSetMilliseconds": -18000000, - "market": "us_market", - "esgPopulated": false, - "firstTradeDateMilliseconds": 1411133400000, - "priceHint": 2, - "postMarketChangePercent": -0.36522618, - "postMarketTime": 1609462798, - "postMarketPrice": 231.88, - "postMarketChange": -0.84999084, - "regularMarketChange": -5.6600037, - "regularMarketChangePercent": -2.374262, - "regularMarketTime": 1609448402, - "regularMarketPrice": 232.73, - "regularMarketDayHigh": 238.92, - "regularMarketDayRange": "231.0267 - 238.92", - "regularMarketDayLow": 231.0267, - "regularMarketVolume": 23173483, - "regularMarketPreviousClose": 238.39, - "bid": 231.13, - "ask": 231.28, - "bidSize": 9, - "askSize": 8, - "fullExchangeName": "NYSE", - "financialCurrency": "CNY", - "regularMarketOpen": 237.46, - "averageDailyVolume3Month": 22479907, - "averageDailyVolume10Day": 53639257, - "fiftyTwoWeekLowChange": 62.78, - "fiftyTwoWeekLowChangePercent": 0.36940277, - "fiftyTwoWeekRange": "169.95 - 319.32", - "fiftyTwoWeekHighChange": -86.59001, - "fiftyTwoWeekHighChangePercent": -0.27117002, - "fiftyTwoWeekLow": 169.95, - "fiftyTwoWeekHigh": 319.32, - "earningsTimestamp": 1604558700, - "earningsTimestampStart": 1613050200, - "earningsTimestampEnd": 1613395800, - "trailingPE": 24.99785, - "epsTrailingTwelveMonths": 9.31, - "epsForward": 12.51, - "epsCurrentYear": 10.37, - "priceEpsCurrentYear": 22.442623, - "sharesOutstanding": 2705639936, - "bookValue": 24.798, - "fiftyDayAverage": 258.78766, - "fiftyDayAverageChange": -26.057663, - "fiftyDayAverageChangePercent": -0.10069129, - "twoHundredDayAverage": 266.3118, - "twoHundredDayAverageChange": -33.581802, - "twoHundredDayAverageChangePercent": -0.12609957, - "marketCap": 648318287872, - "forwardPE": 18.603516, - "priceToBook": 9.385031, - "sourceInterval": 15, - "exchangeDataDelayedBy": 0, - "tradeable": false, - "marketState": "CLOSED", - "displayName": "Alibaba", - "symbol": "BABA" - } - ], - "error": null - } +{ + "quoteResponse": { + "result": [ + { + "language": "en-US", + "region": "US", + "quoteType": "EQUITY", + "quoteSourceName": "Delayed Quote", + "triggerable": true, + "currency": "USD", + "exchange": "NYQ", + "shortName": "Alibaba Group Holding Limited", + "longName": "Alibaba Group Holding Limited", + "messageBoardId": "finmb_42083601", + "exchangeTimezoneName": "America/New_York", + "exchangeTimezoneShortName": "EST", + "gmtOffSetMilliseconds": -18000000, + "market": "us_market", + "esgPopulated": false, + "firstTradeDateMilliseconds": 1411133400000, + "priceHint": 2, + "postMarketChangePercent": -0.36522618, + "postMarketTime": 1609462798, + "postMarketPrice": 231.88, + "postMarketChange": -0.84999084, + "regularMarketChange": -5.6600037, + "regularMarketChangePercent": -2.374262, + "regularMarketTime": 1609448402, + "regularMarketPrice": 232.73, + "regularMarketDayHigh": 238.92, + "regularMarketDayRange": "231.0267 - 238.92", + "regularMarketDayLow": 231.0267, + "regularMarketVolume": 23173483, + "regularMarketPreviousClose": 238.39, + "bid": 231.13, + "ask": 231.28, + "bidSize": 9, + "askSize": 8, + "fullExchangeName": "NYSE", + "financialCurrency": "CNY", + "regularMarketOpen": 237.46, + "averageDailyVolume3Month": 22479907, + "averageDailyVolume10Day": 53639257, + "fiftyTwoWeekLowChange": 62.78, + "fiftyTwoWeekLowChangePercent": 0.36940277, + "fiftyTwoWeekRange": "169.95 - 319.32", + "fiftyTwoWeekHighChange": -86.59001, + "fiftyTwoWeekHighChangePercent": -0.27117002, + "fiftyTwoWeekLow": 169.95, + "fiftyTwoWeekHigh": 319.32, + "earningsTimestamp": 1604558700, + "earningsTimestampStart": 1613050200, + "earningsTimestampEnd": 1613395800, + "trailingPE": 24.99785, + "epsTrailingTwelveMonths": 9.31, + "epsForward": 12.51, + "epsCurrentYear": 10.37, + "priceEpsCurrentYear": 22.442623, + "sharesOutstanding": 2705639936, + "bookValue": 24.798, + "fiftyDayAverage": 258.78766, + "fiftyDayAverageChange": -26.057663, + "fiftyDayAverageChangePercent": -0.10069129, + "twoHundredDayAverage": 266.3118, + "twoHundredDayAverageChange": -33.581802, + "twoHundredDayAverageChangePercent": -0.12609957, + "marketCap": 648318287872, + "forwardPE": 18.603516, + "priceToBook": 9.385031, + "sourceInterval": 15, + "exchangeDataDelayedBy": 0, + "tradeable": false, + "marketState": "CLOSED", + "displayName": "Alibaba", + "symbol": "BABA" + } + ], + "error": null + } } \ No newline at end of file