diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 9ad1f060dad..bd75852a2a5 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -18,6 +18,8 @@ JsonValueType = ( dict[str, "JsonValueType"] | list["JsonValueType"] | str | int | float | bool | None ) """Any data that can be returned by the standard JSON deserializing process.""" +JsonArrayType = list[JsonValueType] +"""List that can be returned by the standard JSON deserializing process.""" JsonObjectType = dict[str, JsonValueType] """Dictionary that can be returned by the standard JSON deserializing process.""" @@ -34,6 +36,15 @@ json_loads = orjson.loads """Parse JSON data.""" +def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType: + """Parse JSON data and ensure result is a list.""" + value: JsonValueType = json_loads(__obj) + # Avoid isinstance overhead as we are not interested in list subclasses + if type(value) is list: # pylint: disable=unidiomatic-typecheck + return value + raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}") + + def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObjectType: """Parse JSON data and ensure result is a dictionary.""" value: JsonValueType = json_loads(__obj) diff --git a/tests/common.py b/tests/common.py index 0424a5707e2..575c2e08c93 100644 --- a/tests/common.py +++ b/tests/common.py @@ -67,6 +67,14 @@ from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as date_util +from homeassistant.util.json import ( + JsonArrayType, + JsonObjectType, + JsonValueType, + json_loads, + json_loads_array, + json_loads_object, +) from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.uuid as uuid_util import homeassistant.util.yaml.loader as yaml_loader @@ -428,6 +436,27 @@ def load_fixture(filename: str, integration: str | None = None) -> str: return get_fixture_path(filename, integration).read_text() +def load_json_value_fixture( + filename: str, integration: str | None = None +) -> JsonValueType: + """Load a JSON value from a fixture.""" + return json_loads(load_fixture(filename, integration)) + + +def load_json_array_fixture( + filename: str, integration: str | None = None +) -> JsonArrayType: + """Load a JSON array from a fixture.""" + return json_loads_array(load_fixture(filename, integration)) + + +def load_json_object_fixture( + filename: str, integration: str | None = None +) -> JsonObjectType: + """Load a JSON object from a fixture.""" + return json_loads_object(load_fixture(filename, integration)) + + def mock_state_change_event( hass: HomeAssistant, new_state: State, old_state: State | None = None ) -> None: diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index 3e0c6c2b875..fd6441b4346 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -1,10 +1,13 @@ """Tests for AccuWeather.""" -import json from unittest.mock import PropertyMock, patch from homeassistant.components.accuweather.const import DOMAIN -from tests.common import MockConfigEntry, load_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) async def init_integration( @@ -28,8 +31,8 @@ async def init_integration( options=options, ) - current = json.loads(load_fixture("accuweather/current_conditions_data.json")) - forecast = json.loads(load_fixture("accuweather/forecast_data.json")) + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") if unsupported_icon: current["WeatherIcon"] = 999 diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index cd391297ebf..a6a9f9c04fc 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -1,5 +1,4 @@ """Define tests for the AccuWeather config flow.""" -import json from unittest.mock import PropertyMock, patch from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError @@ -10,7 +9,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture VALID_CONFIG = { CONF_NAME: "abcd", @@ -99,7 +98,7 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" with patch( "homeassistant.components.accuweather.AccuWeather._async_get_data", - return_value=json.loads(load_fixture("accuweather/location_data.json")), + return_value=load_json_object_fixture("accuweather/location_data.json"), ): MockConfigEntry( domain=DOMAIN, @@ -121,7 +120,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: """Test that the user step works.""" with patch( "homeassistant.components.accuweather.AccuWeather._async_get_data", - return_value=json.loads(load_fixture("accuweather/location_data.json")), + return_value=load_json_object_fixture("accuweather/location_data.json"), ), patch( "homeassistant.components.accuweather.async_setup_entry", return_value=True ): @@ -150,11 +149,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: with patch( "homeassistant.components.accuweather.AccuWeather._async_get_data", - return_value=json.loads(load_fixture("accuweather/location_data.json")), + return_value=load_json_object_fixture("accuweather/location_data.json"), ), patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=json.loads( - load_fixture("accuweather/current_conditions_data.json") + return_value=load_json_object_fixture( + "accuweather/current_conditions_data.json" ), ), patch( "homeassistant.components.accuweather.AccuWeather.async_get_forecast" diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index 5e9ede3237f..767bbd9953a 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -1,11 +1,10 @@ """Test AccuWeather diagnostics.""" -import json from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture +from tests.common import load_json_object_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -16,9 +15,10 @@ async def test_entry_diagnostics( """Test config entry diagnostics.""" entry = await init_integration(hass) - coordinator_data = json.loads( - load_fixture("current_conditions_data.json", "accuweather") + coordinator_data = load_json_object_fixture( + "current_conditions_data.json", "accuweather" ) + coordinator_data["forecast"] = {} result = await get_diagnostics_for_config_entry(hass, hass_client, entry) diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index 9d4d8144ad3..2b6f5132745 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -1,6 +1,5 @@ """Test init of AccuWeather integration.""" from datetime import timedelta -import json from unittest.mock import patch from accuweather import ApiError @@ -13,7 +12,12 @@ from homeassistant.util.dt import utcnow from . import init_integration -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_array_fixture, + load_json_object_fixture, +) async def test_async_setup_entry(hass: HomeAssistant) -> None: @@ -69,7 +73,7 @@ async def test_update_interval(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED - current = json.loads(load_fixture("accuweather/current_conditions_data.json")) + current = load_json_object_fixture("accuweather/current_conditions_data.json") future = utcnow() + timedelta(minutes=40) with patch( @@ -90,8 +94,8 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED - current = json.loads(load_fixture("accuweather/current_conditions_data.json")) - forecast = json.loads(load_fixture("accuweather/forecast_data.json")) + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") future = utcnow() + timedelta(minutes=80) with patch( diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 11db97255c8..e4f564f1335 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -1,6 +1,5 @@ """Test sensor of AccuWeather integration.""" from datetime import timedelta -import json from unittest.mock import PropertyMock, patch from homeassistant.components.accuweather.const import ATTRIBUTION, DOMAIN @@ -35,7 +34,11 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import init_integration -from tests.common import async_fire_time_changed, load_fixture +from tests.common import ( + async_fire_time_changed, + load_json_array_fixture, + load_json_object_fixture, +) async def test_sensor_without_forecast(hass: HomeAssistant) -> None: @@ -684,8 +687,8 @@ async def test_availability(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=120) with patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=json.loads( - load_fixture("accuweather/current_conditions_data.json") + return_value=load_json_object_fixture( + "accuweather/current_conditions_data.json" ), ), patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", @@ -707,8 +710,8 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: await async_setup_component(hass, "homeassistant", {}) - current = json.loads(load_fixture("accuweather/current_conditions_data.json")) - forecast = json.loads(load_fixture("accuweather/forecast_data.json")) + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") with patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", @@ -755,8 +758,8 @@ async def test_state_update(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=60) - current_condition = json.loads( - load_fixture("accuweather/current_conditions_data.json") + current_condition = load_json_object_fixture( + "accuweather/current_conditions_data.json" ) current_condition["Ceiling"]["Metric"]["Value"] = 3300 diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 2b368fcc457..66826f82d17 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -1,6 +1,5 @@ """Test weather of AccuWeather integration.""" from datetime import timedelta -import json from unittest.mock import PropertyMock, patch from homeassistant.components.accuweather.const import ATTRIBUTION @@ -30,7 +29,11 @@ from homeassistant.util.dt import utcnow from . import init_integration -from tests.common import async_fire_time_changed, load_fixture +from tests.common import ( + async_fire_time_changed, + load_json_array_fixture, + load_json_object_fixture, +) async def test_weather_without_forecast(hass: HomeAssistant) -> None: @@ -111,8 +114,8 @@ async def test_availability(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=120) with patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=json.loads( - load_fixture("accuweather/current_conditions_data.json") + return_value=load_json_object_fixture( + "accuweather/current_conditions_data.json" ), ), patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", @@ -134,8 +137,8 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: await async_setup_component(hass, "homeassistant", {}) - current = json.loads(load_fixture("accuweather/current_conditions_data.json")) - forecast = json.loads(load_fixture("accuweather/forecast_data.json")) + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") with patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 91df3ae0d68..67ead0915ab 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -15,6 +15,7 @@ from homeassistant.helpers.json import JSONEncoder as DefaultHASSJSONEncoder from homeassistant.util.json import ( SerializationError, find_paths_unserializable_data, + json_loads_array, json_loads_object, load_json, save_json, @@ -194,6 +195,23 @@ def test_find_unserializable_data() -> None: ) == {"$(BadData).bla": bad_data} +def test_json_loads_array() -> None: + """Test json_loads_array validates result.""" + assert json_loads_array('[{"c":1.2}]') == [{"c": 1.2}] + with pytest.raises( + ValueError, match="Expected JSON to be parsed as a list got " + ): + json_loads_array("{}") + with pytest.raises( + ValueError, match="Expected JSON to be parsed as a list got " + ): + json_loads_array("true") + with pytest.raises( + ValueError, match="Expected JSON to be parsed as a list got " + ): + json_loads_array("null") + + def test_json_loads_object() -> None: """Test json_loads_object validates result.""" assert json_loads_object('{"c":1.2}') == {"c": 1.2}