diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 823c13a4d55..a2c84d9eebe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=alot,ba,bre,datas,dof,dur,ether,farenheit,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,lightsensor,mut,nd,pres,pullrequests,referer,rime,ser,serie,sur,te,technik,ue,uint,visability,wan,wanna,withing + - --ignore-words-list=alot,ba,bre,datas,dof,dur,ether,farenheit,fo,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,lightsensor,mut,nd,pres,pullrequests,referer,rime,ser,serie,sur,te,technik,ue,uint,visability,wan,wanna,withing - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 8771fb41247..7d1ea91b9aa 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -53,6 +53,7 @@ ERROR_LOG_FILENAME = "home-assistant.log" # hass.data key for logging information. DATA_LOGGING = "logging" +DATA_REGISTRIES_LOADED = "bootstrap_registries_loaded" LOG_SLOW_STARTUP_INTERVAL = 60 SLOW_STARTUP_CHECK_INTERVAL = 1 @@ -216,6 +217,32 @@ def open_hass_ui(hass: core.HomeAssistant) -> None: ) +async def load_registries(hass: core.HomeAssistant) -> None: + """Load the registries and cache the result of platform.uname().processor.""" + if DATA_REGISTRIES_LOADED in hass.data: + return + hass.data[DATA_REGISTRIES_LOADED] = None + + def _cache_uname_processor() -> None: + """Cache the result of platform.uname().processor in the executor. + + Multiple modules call this function at startup which + executes a blocking subprocess call. This is a problem for the + asyncio event loop. By primeing the cache of uname we can + avoid the blocking call in the event loop. + """ + platform.uname().processor # pylint: disable=expression-not-assigned + + # Load the registries and cache the result of platform.uname().processor + await asyncio.gather( + area_registry.async_load(hass), + device_registry.async_load(hass), + entity_registry.async_load(hass), + issue_registry.async_load(hass), + hass.async_add_executor_job(_cache_uname_processor), + ) + + async def async_from_config_dict( config: ConfigType, hass: core.HomeAssistant ) -> core.HomeAssistant | None: @@ -228,6 +255,7 @@ async def async_from_config_dict( hass.config_entries = config_entries.ConfigEntries(hass, config) await hass.config_entries.async_initialize() + await load_registries(hass) # Set up core. _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) @@ -530,25 +558,6 @@ async def _async_set_up_integrations( _LOGGER.info("Domains to be set up: %s", domains_to_setup) - def _cache_uname_processor() -> None: - """Cache the result of platform.uname().processor in the executor. - - Multiple modules call this function at startup which - executes a blocking subprocess call. This is a problem for the - asyncio event loop. By primeing the cache of uname we can - avoid the blocking call in the event loop. - """ - platform.uname().processor # pylint: disable=expression-not-assigned - - # Load the registries and cache the result of platform.uname().processor - await asyncio.gather( - area_registry.async_load(hass), - device_registry.async_load(hass), - entity_registry.async_load(hass), - issue_registry.async_load(hass), - hass.async_add_executor_job(_cache_uname_processor), - ) - # Initialize recorder if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index c748395e95f..975cfc590e8 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -49,6 +49,8 @@ class CheckConfigView(HomeAssistantView): vol.Optional("external_url"): vol.Any(cv.url_no_path, None), vol.Optional("internal_url"): vol.Any(cv.url_no_path, None), vol.Optional("currency"): cv.currency, + vol.Optional("country"): cv.country, + vol.Optional("language"): cv.language, } ) @websocket_api.async_response diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 018850f5960..82f169dc6c9 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -9,20 +9,47 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store DATA_STORAGE = "frontend_storage" STORAGE_VERSION_USER_DATA = 1 +@callback +def _initialize_frontend_storage(hass: HomeAssistant) -> None: + """Set up frontend storage.""" + if DATA_STORAGE in hass.data: + return + hass.data[DATA_STORAGE] = ({}, {}) + + async def async_setup_frontend_storage(hass: HomeAssistant) -> None: """Set up frontend storage.""" - hass.data[DATA_STORAGE] = ({}, {}) + _initialize_frontend_storage(hass) websocket_api.async_register_command(hass, websocket_set_user_data) websocket_api.async_register_command(hass, websocket_get_user_data) +async def async_user_store( + hass: HomeAssistant, user_id: str +) -> tuple[Store, dict[str, Any]]: + """Access a user store.""" + _initialize_frontend_storage(hass) + stores, data = hass.data[DATA_STORAGE] + if (store := stores.get(user_id)) is None: + store = stores[user_id] = Store( + hass, + STORAGE_VERSION_USER_DATA, + f"frontend.user_data_{user_id}", + ) + + if user_id not in data: + data[user_id] = await store.async_load() or {} + + return store, data[user_id] + + def with_store(orig_func: Callable) -> Callable: """Decorate function to provide data.""" @@ -31,20 +58,11 @@ def with_store(orig_func: Callable) -> Callable: hass: HomeAssistant, connection: ActiveConnection, msg: dict ) -> None: """Provide user specific data and store to function.""" - stores, data = hass.data[DATA_STORAGE] user_id = connection.user.id - if (store := stores.get(user_id)) is None: - store = stores[user_id] = Store( - hass, - STORAGE_VERSION_USER_DATA, - f"frontend.user_data_{connection.user.id}", - ) + store, user_data = await async_user_store(hass, user_id) - if user_id not in data: - data[user_id] = await store.async_load() or {} - - await orig_func(hass, connection, msg, store, data[user_id]) + await orig_func(hass, connection, msg, store, user_data) return with_store_func diff --git a/homeassistant/config.py b/homeassistant/config.py index a422cbea1d9..963f3ee9876 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -27,6 +27,7 @@ from .const import ( CONF_ALLOWLIST_EXTERNAL_URLS, CONF_AUTH_MFA_MODULES, CONF_AUTH_PROVIDERS, + CONF_COUNTRY, CONF_CURRENCY, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, @@ -35,6 +36,7 @@ from .const import ( CONF_EXTERNAL_URL, CONF_ID, CONF_INTERNAL_URL, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LEGACY_TEMPLATES, CONF_LONGITUDE, @@ -281,6 +283,8 @@ CORE_CONFIG_SCHEMA = vol.All( vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, vol.Optional(CONF_CURRENCY): _validate_currency, + vol.Optional(CONF_COUNTRY): cv.country, + vol.Optional(CONF_LANGUAGE): cv.language, } ), _filter_bad_internal_external_urls, @@ -560,6 +564,8 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non CONF_EXTERNAL_URL, CONF_INTERNAL_URL, CONF_CURRENCY, + CONF_COUNTRY, + CONF_LANGUAGE, ) ): hac.config_source = ConfigSource.YAML @@ -574,6 +580,8 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non (CONF_MEDIA_DIRS, "media_dirs"), (CONF_LEGACY_TEMPLATES, "legacy_templates"), (CONF_CURRENCY, "currency"), + (CONF_COUNTRY, "country"), + (CONF_LANGUAGE, "language"), ): if key in config: setattr(hac, attr, config[key]) diff --git a/homeassistant/const.py b/homeassistant/const.py index 22b7ea5ab50..ca69307a485 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -121,6 +121,7 @@ CONF_CONDITIONS: Final = "conditions" CONF_CONTINUE_ON_ERROR: Final = "continue_on_error" CONF_CONTINUE_ON_TIMEOUT: Final = "continue_on_timeout" CONF_COUNT: Final = "count" +CONF_COUNTRY: Final = "country" CONF_COVERS: Final = "covers" CONF_CURRENCY: Final = "currency" CONF_CUSTOMIZE: Final = "customize" @@ -175,6 +176,7 @@ CONF_IF: Final = "if" CONF_INCLUDE: Final = "include" CONF_INTERNAL_URL: Final = "internal_url" CONF_IP_ADDRESS: Final = "ip_address" +CONF_LANGUAGE: Final = "language" CONF_LATITUDE: Final = "latitude" CONF_LEGACY_TEMPLATES: Final = "legacy_templates" CONF_LIGHTS: Final = "lights" diff --git a/homeassistant/core.py b/homeassistant/core.py index 8f9287aedac..9172c1d60b4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -15,6 +15,7 @@ from collections.abc import ( Iterable, Mapping, ) +from contextlib import suppress from contextvars import ContextVar import datetime import enum @@ -113,7 +114,7 @@ CALLBACK_TYPE = Callable[[], None] # pylint: disable=invalid-name CORE_STORAGE_KEY = "core.config" CORE_STORAGE_VERSION = 1 -CORE_STORAGE_MINOR_VERSION = 2 +CORE_STORAGE_MINOR_VERSION = 3 DOMAIN = "homeassistant" @@ -1807,6 +1808,8 @@ class Config: self.internal_url: str | None = None self.external_url: str | None = None self.currency: str = "EUR" + self.country: str | None = None + self.language: str = "en" self.config_source: ConfigSource = ConfigSource.DEFAULT @@ -1913,6 +1916,8 @@ class Config: "external_url": self.external_url, "internal_url": self.internal_url, "currency": self.currency, + "country": self.country, + "language": self.language, } def set_time_zone(self, time_zone_str: str) -> None: @@ -1938,6 +1943,8 @@ class Config: external_url: str | dict[Any, Any] | None = _UNDEF, internal_url: str | dict[Any, Any] | None = _UNDEF, currency: str | None = None, + country: str | dict[Any, Any] | None = _UNDEF, + language: str | None = None, ) -> None: """Update the configuration from a dictionary.""" self.config_source = source @@ -1962,6 +1969,10 @@ class Config: self.internal_url = cast(Optional[str], internal_url) if currency is not None: self.currency = currency + if country is not _UNDEF: + self.country = cast(Optional[str], country) + if language is not None: + self.language = language async def async_update(self, **kwargs: Any) -> None: """Update the configuration from a dictionary.""" @@ -1999,6 +2010,8 @@ class Config: external_url=data.get("external_url", _UNDEF), internal_url=data.get("internal_url", _UNDEF), currency=data.get("currency"), + country=data.get("country"), + language=data.get("language"), ) async def _async_store(self) -> None: @@ -2015,6 +2028,8 @@ class Config: "external_url": self.external_url, "internal_url": self.internal_url, "currency": self.currency, + "country": self.country, + "language": self.language, } await self._store.async_save(data) @@ -2053,6 +2068,35 @@ class Config: data["unit_system_v2"] = self._original_unit_system if data["unit_system_v2"] == _CONF_UNIT_SYSTEM_IMPERIAL: data["unit_system_v2"] = _CONF_UNIT_SYSTEM_US_CUSTOMARY + if old_major_version == 1 and old_minor_version < 3: + # In 1.3, we add the key "language", initialize it from the owner account + data["language"] = "en" + try: + owner = await self.hass.auth.async_get_owner() + if owner is not None: + # pylint: disable-next=import-outside-toplevel + from .components.frontend import storage as frontend_store + + # pylint: disable-next=import-outside-toplevel + from .helpers import config_validation as cv + + _, owner_data = await frontend_store.async_user_store( + self.hass, owner.id + ) + + if ( + "language" in owner_data + and "language" in owner_data["language"] + ): + with suppress(vol.InInvalid): + # pylint: disable-next=protected-access + data["language"] = cv.language( + owner_data["language"]["language"] + ) + # pylint: disable-next=broad-except + except Exception: + _LOGGER.exception("Unexpected error during core config migration") + if old_major_version > 1: raise NotImplementedError return data diff --git a/homeassistant/generated/countries.py b/homeassistant/generated/countries.py new file mode 100644 index 00000000000..76482a524de --- /dev/null +++ b/homeassistant/generated/countries.py @@ -0,0 +1,260 @@ +"""This file is automatically generated. + +To update, run python3 -m script.countries + +The values are directly corresponding to the ISO 3166 standard. If you need changes +to the political situation in the world, please contact the ISO 3166 working group. + +""" + +COUNTRIES = { + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BL", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CU", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IR", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KP", + "KR", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MF", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SD", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "SS", + "ST", + "SV", + "SX", + "SY", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW", +} diff --git a/homeassistant/generated/languages.py b/homeassistant/generated/languages.py new file mode 100644 index 00000000000..879d4a4cd41 --- /dev/null +++ b/homeassistant/generated/languages.py @@ -0,0 +1,68 @@ +"""This file is automatically generated. + +To update, run python3 -m script.languages [frontend_tag] +""" + +LANGUAGES = { + "af", + "ar", + "bg", + "bn", + "bs", + "ca", + "cs", + "cy", + "da", + "de", + "el", + "en", + "en-GB", + "eo", + "es", + "es-419", + "et", + "eu", + "fa", + "fi", + "fr", + "fy", + "gl", + "gsw", + "he", + "hi", + "hr", + "hu", + "hy", + "id", + "is", + "it", + "ja", + "ka", + "ko", + "lb", + "lt", + "lv", + "ml", + "nb", + "nl", + "nn", + "pl", + "pt", + "pt-BR", + "ro", + "ru", + "sk", + "sl", + "sr", + "sr-Latn", + "sv", + "ta", + "te", + "th", + "tr", + "uk", + "ur", + "vi", + "zh-Hans", + "zh-Hant", +} diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 667aec9fccf..fc71a586aee 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -89,6 +89,8 @@ from homeassistant.const import ( from homeassistant.core import split_entity_id, valid_entity_id from homeassistant.exceptions import TemplateError from homeassistant.generated import currencies +from homeassistant.generated.countries import COUNTRIES +from homeassistant.generated.languages import LANGUAGES from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util @@ -1662,3 +1664,7 @@ currency = vol.In( historic_currency = vol.In( currencies.HISTORIC_CURRENCIES, msg="invalid ISO 4217 formatted historic currency" ) + +country = vol.In(COUNTRIES, msg="invalid ISO 3166 formatted country") + +language = vol.In(LANGUAGES, msg="invalid RFC 5646 formatted language") diff --git a/script/countries.py b/script/countries.py new file mode 100644 index 00000000000..0d776f0805d --- /dev/null +++ b/script/countries.py @@ -0,0 +1,27 @@ +"""Helper script to update country list. + +ISO does not publish a machine readable list free of charge, so the list is generated +with help of the pycountry package. +""" +from pathlib import Path + +import pycountry + +from .hassfest.serializer import format_python_namespace + +countries = {x.alpha_2 for x in pycountry.countries} + +generator_string = """script.countries + +The values are directly corresponding to the ISO 3166 standard. If you need changes +to the political situation in the world, please contact the ISO 3166 working group. +""" + +Path("homeassistant/generated/countries.py").write_text( + format_python_namespace( + { + "COUNTRIES": countries, + }, + generator=generator_string, + ) +) diff --git a/script/languages.py b/script/languages.py new file mode 100644 index 00000000000..ad88a31b0b6 --- /dev/null +++ b/script/languages.py @@ -0,0 +1,25 @@ +"""Helper script to update language list from the frontend source.""" +import json +from pathlib import Path +import sys + +import requests + +from .hassfest.serializer import format_python_namespace + +tag = sys.argv[1] if len(sys.argv) > 1 else "dev" + +req = requests.get( + f"https://raw.githubusercontent.com/home-assistant/frontend/{tag}/src/translations/translationMetadata.json" +) +data = json.loads(req.content) +languages = set(data.keys()) + +Path("homeassistant/generated/languages.py").write_text( + format_python_namespace( + { + "LANGUAGES": languages, + }, + generator="script.languages [frontend_tag]", + ) +) diff --git a/tests/common.py b/tests/common.py index 48e51303a0c..b7a26cb5f6d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -22,7 +22,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import voluptuous as vol -from homeassistant import auth, config_entries, core as ha, loader +from homeassistant import auth, bootstrap, config_entries, core as ha, loader from homeassistant.auth import ( auth_store, models as auth_models, @@ -306,6 +306,7 @@ async def async_test_home_assistant(loop, load_registries=True): issue_registry.async_load(hass), ) await hass.async_block_till_done() + hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None hass.state = ha.CoreState.running diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 33309f6b6c6..67977c96a2b 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -60,6 +60,8 @@ async def test_websocket_core_update(hass, client): assert hass.config.external_url != "https://www.example.com" assert hass.config.internal_url != "http://example.com" assert hass.config.currency == "EUR" + assert hass.config.country != "SE" + assert hass.config.language != "sv" with patch("homeassistant.util.dt.set_default_time_zone") as mock_set_tz: await client.send_json( @@ -75,6 +77,8 @@ async def test_websocket_core_update(hass, client): "external_url": "https://www.example.com", "internal_url": "http://example.local", "currency": "USD", + "country": "SE", + "language": "sv", } ) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index da9fa2cc68d..3b087b6a40b 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1344,3 +1344,27 @@ def test_historic_currency(): for value in ("DEM", "NLG"): assert schema(value) + + +def test_country(): + """Test country validator.""" + schema = vol.Schema(cv.country) + + for value in (None, "Candyland", "USA"): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ("NL", "SE"): + assert schema(value) + + +def test_language(): + """Test language validator.""" + schema = vol.Schema(cv.language) + + for value in (None, "Klingon", "english"): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ("en", "sv"): + assert schema(value) diff --git a/tests/test_config.py b/tests/test_config.py index ef364638725..17b3898eb2a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -40,7 +40,7 @@ from homeassistant.util.unit_system import ( ) from homeassistant.util.yaml import SECRET_YAML -from tests.common import get_test_config_dir, patch_yaml_files +from tests.common import MockUser, get_test_config_dir, patch_yaml_files CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -214,6 +214,8 @@ def test_core_config_schema(): {"customize": "bla"}, {"customize": {"light.sensor": 100}}, {"customize": {"entity_id": []}}, + {"country": "xx"}, + {"language": "xx"}, ): with pytest.raises(MultipleInvalid): config_util.CORE_CONFIG_SCHEMA(value) @@ -228,6 +230,8 @@ def test_core_config_schema(): CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, "currency": "USD", "customize": {"sensor.temperature": {"hidden": True}}, + "country": "SE", + "language": "sv", } ) @@ -393,9 +397,12 @@ async def test_loading_configuration_from_storage(hass, hass_storage): "external_url": "https://www.example.com", "internal_url": "http://example.local", "currency": "EUR", + "country": "SE", + "language": "sv", }, "key": "core.config", "version": 1, + "minor_version": 3, } await config_util.async_process_ha_core_config( hass, {"allowlist_external_dirs": "/etc"} @@ -410,6 +417,8 @@ async def test_loading_configuration_from_storage(hass, hass_storage): assert hass.config.external_url == "https://www.example.com" assert hass.config.internal_url == "http://example.local" assert hass.config.currency == "EUR" + assert hass.config.country == "SE" + assert hass.config.language == "sv" assert len(hass.config.allowlist_external_dirs) == 3 assert "/etc" in hass.config.allowlist_external_dirs assert hass.config.config_source is ConfigSource.STORAGE @@ -475,10 +484,15 @@ async def test_migration_and_updating_configuration(hass, hass_storage): expected_new_core_data["data"]["currency"] = "USD" # 1.1 -> 1.2 store migration with migrated unit system expected_new_core_data["data"]["unit_system_v2"] = "us_customary" - expected_new_core_data["minor_version"] = 2 + expected_new_core_data["minor_version"] = 3 + # defaults for country and language + expected_new_core_data["data"]["country"] = None + expected_new_core_data["data"]["language"] = "en" assert hass_storage["core.config"] == expected_new_core_data assert hass.config.latitude == 50 assert hass.config.currency == "USD" + assert hass.config.country is None + assert hass.config.language == "en" async def test_override_stored_configuration(hass, hass_storage): @@ -527,6 +541,8 @@ async def test_loading_configuration(hass): "media_dirs": {"mymedia": "/usr"}, "legacy_templates": True, "currency": "EUR", + "country": "SE", + "language": "sv", }, ) @@ -545,6 +561,74 @@ async def test_loading_configuration(hass): assert hass.config.config_source is ConfigSource.YAML assert hass.config.legacy_templates is True assert hass.config.currency == "EUR" + assert hass.config.country == "SE" + assert hass.config.language == "sv" + + +@pytest.mark.parametrize( + "minor_version, users, user_data, default_language", + ( + (2, (), {}, "en"), + (2, ({"is_owner": True},), {}, "en"), + ( + 2, + ({"id": "user1", "is_owner": True},), + {"user1": {"language": {"language": "sv"}}}, + "sv", + ), + ( + 2, + ({"id": "user1", "is_owner": False},), + {"user1": {"language": {"language": "sv"}}}, + "en", + ), + (3, (), {}, "en"), + (3, ({"is_owner": True},), {}, "en"), + ( + 3, + ({"id": "user1", "is_owner": True},), + {"user1": {"language": {"language": "sv"}}}, + "en", + ), + ( + 3, + ({"id": "user1", "is_owner": False},), + {"user1": {"language": {"language": "sv"}}}, + "en", + ), + ), +) +async def test_language_default( + hass, hass_storage, minor_version, users, user_data, default_language +): + """Test language config default to owner user's language during migration. + + This should only happen if the core store version < 1.3 + """ + core_data = { + "data": {}, + "key": "core.config", + "version": 1, + "minor_version": minor_version, + } + hass_storage["core.config"] = dict(core_data) + + for user_config in users: + user = MockUser(**user_config).add_to_hass(hass) + if user.id not in user_data: + continue + storage_key = f"frontend.user_data_{user.id}" + hass_storage[storage_key] = { + "key": storage_key, + "version": 1, + "data": user_data[user.id], + } + + await config_util.async_process_ha_core_config( + hass, + {}, + ) + assert hass.config.language == default_language async def test_loading_configuration_default_media_dirs_docker(hass): diff --git a/tests/test_core.py b/tests/test_core.py index 017c8b3b607..80f7c18d254 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -948,6 +948,8 @@ async def test_config_defaults(): assert config.safe_mode is False assert config.legacy_templates is False assert config.currency == "EUR" + assert config.country is None + assert config.language == "en" async def test_config_path_with_file(): @@ -989,6 +991,8 @@ async def test_config_as_dict(): "external_url": None, "internal_url": None, "currency": "EUR", + "country": None, + "language": "en", } assert expected == config.as_dict()