mirror of https://github.com/home-assistant/core
400 lines
12 KiB
Python
400 lines
12 KiB
Python
"""Test Home Assistant remote methods and classes."""
|
|
|
|
import datetime
|
|
from functools import partial
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import time
|
|
from typing import Any, NamedTuple
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from homeassistant.core import Event, HomeAssistant, State
|
|
from homeassistant.helpers import json as json_helper
|
|
from homeassistant.helpers.json import (
|
|
ExtendedJSONEncoder,
|
|
JSONEncoder as DefaultHASSJSONEncoder,
|
|
find_paths_unserializable_data,
|
|
json_bytes_sorted,
|
|
json_bytes_strip_null,
|
|
json_dumps,
|
|
json_dumps_sorted,
|
|
json_fragment,
|
|
save_json,
|
|
)
|
|
from homeassistant.util import dt as dt_util
|
|
from homeassistant.util.color import RGBColor
|
|
from homeassistant.util.json import (
|
|
JSON_DECODE_EXCEPTIONS,
|
|
JSON_ENCODE_EXCEPTIONS,
|
|
SerializationError,
|
|
load_json,
|
|
)
|
|
|
|
from tests.common import import_and_test_deprecated_constant, json_round_trip
|
|
|
|
# Test data that can be saved as JSON
|
|
TEST_JSON_A = {"a": 1, "B": "two"}
|
|
TEST_JSON_B = {"a": "one", "B": 2}
|
|
|
|
|
|
@pytest.mark.parametrize("encoder", [DefaultHASSJSONEncoder, ExtendedJSONEncoder])
|
|
def test_json_encoder(hass: HomeAssistant, encoder: type[json.JSONEncoder]) -> None:
|
|
"""Test the JSON encoders."""
|
|
ha_json_enc = encoder()
|
|
state = State("test.test", "hello")
|
|
|
|
# Test serializing a datetime
|
|
now = dt_util.utcnow()
|
|
assert ha_json_enc.default(now) == now.isoformat()
|
|
|
|
# Test serializing a set()
|
|
data = {"milk", "beer"}
|
|
assert sorted(ha_json_enc.default(data)) == sorted(data)
|
|
|
|
# Test serializing an object which implements as_dict
|
|
default = ha_json_enc.default(state)
|
|
assert json_round_trip(default) == json_round_trip(state.as_dict())
|
|
|
|
|
|
def test_json_encoder_raises(hass: HomeAssistant) -> None:
|
|
"""Test the JSON encoder raises on unsupported types."""
|
|
ha_json_enc = DefaultHASSJSONEncoder()
|
|
|
|
# Default method raises TypeError if non HA object
|
|
with pytest.raises(TypeError):
|
|
ha_json_enc.default(1)
|
|
|
|
|
|
def test_extended_json_encoder(hass: HomeAssistant) -> None:
|
|
"""Test the extended JSON encoder."""
|
|
ha_json_enc = ExtendedJSONEncoder()
|
|
# Test serializing a timedelta
|
|
data = datetime.timedelta(
|
|
days=50,
|
|
seconds=27,
|
|
microseconds=10,
|
|
milliseconds=29000,
|
|
minutes=5,
|
|
hours=8,
|
|
weeks=2,
|
|
)
|
|
assert ha_json_enc.default(data) == {
|
|
"__type": str(type(data)),
|
|
"total_seconds": data.total_seconds(),
|
|
}
|
|
|
|
# Test serializing a time
|
|
o = datetime.time(7, 20)
|
|
assert ha_json_enc.default(o) == {"__type": str(type(o)), "isoformat": "07:20:00"}
|
|
|
|
# Test serializing a date
|
|
o = datetime.date(2021, 12, 24)
|
|
assert ha_json_enc.default(o) == {"__type": str(type(o)), "isoformat": "2021-12-24"}
|
|
|
|
# Default method falls back to repr(o)
|
|
o = object()
|
|
assert ha_json_enc.default(o) == {"__type": str(type(o)), "repr": repr(o)}
|
|
|
|
|
|
def test_json_dumps_sorted() -> None:
|
|
"""Test the json dumps sorted function."""
|
|
data = {"c": 3, "a": 1, "b": 2}
|
|
assert json_dumps_sorted(data) == json.dumps(
|
|
data, sort_keys=True, separators=(",", ":")
|
|
)
|
|
|
|
|
|
def test_json_bytes_sorted() -> None:
|
|
"""Test the json bytes sorted function."""
|
|
data = {"c": 3, "a": 1, "b": 2}
|
|
assert json_bytes_sorted(data) == json.dumps(
|
|
data, sort_keys=True, separators=(",", ":")
|
|
).encode("utf-8")
|
|
|
|
|
|
def test_json_dumps_float_subclass() -> None:
|
|
"""Test the json dumps a float subclass."""
|
|
|
|
class FloatSubclass(float):
|
|
"""A float subclass."""
|
|
|
|
assert json_dumps({"c": FloatSubclass(1.2)}) == '{"c":1.2}'
|
|
|
|
|
|
def test_json_dumps_tuple_subclass() -> None:
|
|
"""Test the json dumps a tuple subclass."""
|
|
|
|
tt = time.struct_time((1999, 3, 17, 32, 44, 55, 2, 76, 0))
|
|
|
|
assert json_dumps(tt) == "[1999,3,17,32,44,55,2,76,0]"
|
|
|
|
|
|
def test_json_dumps_named_tuple_subclass() -> None:
|
|
"""Test the json dumps a tuple subclass."""
|
|
|
|
class NamedTupleSubclass(NamedTuple):
|
|
"""A NamedTuple subclass."""
|
|
|
|
name: str
|
|
|
|
nts = NamedTupleSubclass("a")
|
|
|
|
assert json_dumps(nts) == '["a"]'
|
|
|
|
|
|
def test_json_dumps_rgb_color_subclass() -> None:
|
|
"""Test the json dumps of RGBColor."""
|
|
rgb = RGBColor(4, 2, 1)
|
|
|
|
assert json_dumps(rgb) == "[4,2,1]"
|
|
|
|
|
|
def test_json_fragments() -> None:
|
|
"""Test the json dumps with a fragment."""
|
|
|
|
assert (
|
|
json_dumps(
|
|
[
|
|
json_fragment('{"inner":"fragment2"}'),
|
|
json_fragment('{"inner":"fragment2"}'),
|
|
]
|
|
)
|
|
== '[{"inner":"fragment2"},{"inner":"fragment2"}]'
|
|
)
|
|
|
|
class Fragment1:
|
|
@property
|
|
def json_fragment(self):
|
|
return json_fragment('{"inner":"fragment1"}')
|
|
|
|
class Fragment2:
|
|
@property
|
|
def json_fragment(self):
|
|
return json_fragment('{"inner":"fragment2"}')
|
|
|
|
assert (
|
|
json_dumps([Fragment1(), Fragment2()])
|
|
== '[{"inner":"fragment1"},{"inner":"fragment2"}]'
|
|
)
|
|
|
|
|
|
def test_json_bytes_strip_null() -> None:
|
|
"""Test stripping nul from strings."""
|
|
|
|
assert json_bytes_strip_null("\0") == b'""'
|
|
assert json_bytes_strip_null("silly\0stuff") == b'"silly"'
|
|
assert json_bytes_strip_null(["one", "two\0", "three"]) == b'["one","two","three"]'
|
|
assert (
|
|
json_bytes_strip_null({"k1": "one", "k2": "two\0", "k3": "three"})
|
|
== b'{"k1":"one","k2":"two","k3":"three"}'
|
|
)
|
|
assert (
|
|
json_bytes_strip_null([[{"k1": {"k2": ["silly\0stuff"]}}]])
|
|
== b'[[{"k1":{"k2":["silly"]}}]]'
|
|
)
|
|
|
|
|
|
def test_save_and_load(tmp_path: Path) -> None:
|
|
"""Test saving and loading back."""
|
|
fname = tmp_path / "test1.json"
|
|
save_json(fname, TEST_JSON_A)
|
|
data = load_json(fname)
|
|
assert data == TEST_JSON_A
|
|
|
|
|
|
def test_save_and_load_int_keys(tmp_path: Path) -> None:
|
|
"""Test saving and loading back stringifies the keys."""
|
|
fname = tmp_path / "test1.json"
|
|
save_json(fname, {1: "a", 2: "b"})
|
|
data = load_json(fname)
|
|
assert data == {"1": "a", "2": "b"}
|
|
|
|
|
|
def test_save_and_load_private(tmp_path: Path) -> None:
|
|
"""Test we can load private files and that they are protected."""
|
|
fname = tmp_path / "test2.json"
|
|
save_json(fname, TEST_JSON_A, private=True)
|
|
data = load_json(fname)
|
|
assert data == TEST_JSON_A
|
|
stats = os.stat(fname)
|
|
assert stats.st_mode & 0o77 == 0
|
|
|
|
|
|
@pytest.mark.parametrize("atomic_writes", [True, False])
|
|
def test_overwrite_and_reload(atomic_writes: bool, tmp_path: Path) -> None:
|
|
"""Test that we can overwrite an existing file and read back."""
|
|
fname = tmp_path / "test3.json"
|
|
save_json(fname, TEST_JSON_A, atomic_writes=atomic_writes)
|
|
save_json(fname, TEST_JSON_B, atomic_writes=atomic_writes)
|
|
data = load_json(fname)
|
|
assert data == TEST_JSON_B
|
|
|
|
|
|
def test_save_bad_data() -> None:
|
|
"""Test error from trying to save unserializable data."""
|
|
|
|
class CannotSerializeMe:
|
|
"""Cannot serialize this."""
|
|
|
|
with pytest.raises(SerializationError) as excinfo:
|
|
save_json("test4", {"hello": CannotSerializeMe()})
|
|
|
|
assert "Failed to serialize to JSON: test4. Bad data at $.hello=" in str(
|
|
excinfo.value
|
|
)
|
|
|
|
|
|
def test_custom_encoder(tmp_path: Path) -> None:
|
|
"""Test serializing with a custom encoder."""
|
|
|
|
class MockJSONEncoder(json.JSONEncoder):
|
|
"""Mock JSON encoder."""
|
|
|
|
def default(self, o):
|
|
"""Mock JSON encode method."""
|
|
return "9"
|
|
|
|
fname = tmp_path / "test6.json"
|
|
save_json(fname, Mock(), encoder=MockJSONEncoder)
|
|
data = load_json(fname)
|
|
assert data == "9"
|
|
|
|
|
|
def test_saving_subclassed_datetime(tmp_path: Path) -> None:
|
|
"""Test saving subclassed datetime objects."""
|
|
|
|
class SubClassDateTime(datetime.datetime):
|
|
"""Subclass datetime."""
|
|
|
|
time = SubClassDateTime.fromtimestamp(0)
|
|
|
|
fname = tmp_path / "test6.json"
|
|
save_json(fname, {"time": time})
|
|
data = load_json(fname)
|
|
assert data == {"time": time.isoformat()}
|
|
|
|
|
|
def test_default_encoder_is_passed(tmp_path: Path) -> None:
|
|
"""Test we use orjson if they pass in the default encoder."""
|
|
fname = tmp_path / "test6.json"
|
|
with patch(
|
|
"homeassistant.helpers.json.orjson.dumps", return_value=b"{}"
|
|
) as mock_orjson_dumps:
|
|
save_json(fname, {"any": 1}, encoder=DefaultHASSJSONEncoder)
|
|
assert len(mock_orjson_dumps.mock_calls) == 1
|
|
# Patch json.dumps to make sure we are using the orjson path
|
|
with patch("homeassistant.helpers.json.json.dumps", side_effect=Exception):
|
|
save_json(fname, {"any": {1}}, encoder=DefaultHASSJSONEncoder)
|
|
data = load_json(fname)
|
|
assert data == {"any": [1]}
|
|
|
|
|
|
def test_find_unserializable_data() -> None:
|
|
"""Find unserializeable data."""
|
|
assert find_paths_unserializable_data(1) == {}
|
|
assert find_paths_unserializable_data([1, 2]) == {}
|
|
assert find_paths_unserializable_data({"something": "yo"}) == {}
|
|
|
|
assert find_paths_unserializable_data({"something": set()}) == {
|
|
"$.something": set()
|
|
}
|
|
assert find_paths_unserializable_data({"something": [1, set()]}) == {
|
|
"$.something[1]": set()
|
|
}
|
|
assert find_paths_unserializable_data([1, {"bla": set(), "blub": set()}]) == {
|
|
"$[1].bla": set(),
|
|
"$[1].blub": set(),
|
|
}
|
|
assert find_paths_unserializable_data({("A",): 1}) == {"$<key: ('A',)>": ("A",)}
|
|
assert math.isnan(
|
|
find_paths_unserializable_data(
|
|
float("nan"), dump=partial(json.dumps, allow_nan=False)
|
|
)["$"]
|
|
)
|
|
|
|
# Test custom encoder + State support.
|
|
|
|
class MockJSONEncoder(json.JSONEncoder):
|
|
"""Mock JSON encoder."""
|
|
|
|
def default(self, o):
|
|
"""Mock JSON encode method."""
|
|
if isinstance(o, datetime.datetime):
|
|
return o.isoformat()
|
|
return super().default(o)
|
|
|
|
bad_data = object()
|
|
|
|
assert find_paths_unserializable_data(
|
|
[State("mock_domain.mock_entity", "on", {"bad": bad_data})],
|
|
dump=partial(json.dumps, cls=MockJSONEncoder),
|
|
) == {"$[0](State: mock_domain.mock_entity).attributes.bad": bad_data}
|
|
|
|
assert find_paths_unserializable_data(
|
|
[Event("bad_event", {"bad_attribute": bad_data})],
|
|
dump=partial(json.dumps, cls=MockJSONEncoder),
|
|
) == {"$[0](Event: bad_event).data.bad_attribute": bad_data}
|
|
|
|
class BadData:
|
|
def __init__(self) -> None:
|
|
self.bla = bad_data
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {"bla": self.bla}
|
|
|
|
assert find_paths_unserializable_data(
|
|
BadData(),
|
|
dump=partial(json.dumps, cls=MockJSONEncoder),
|
|
) == {"$(BadData).bla": bad_data}
|
|
|
|
|
|
def test_deprecated_json_loads(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""Test deprecated json_loads function.
|
|
|
|
It was moved from helpers to util in #88099
|
|
"""
|
|
json_helper.json_loads("{}")
|
|
assert (
|
|
"json_loads is a deprecated function which will be removed in "
|
|
"HA Core 2025.8. Use homeassistant.util.json.json_loads instead"
|
|
) in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("constant_name", "replacement_name", "replacement"),
|
|
[
|
|
(
|
|
"JSON_DECODE_EXCEPTIONS",
|
|
"homeassistant.util.json.JSON_DECODE_EXCEPTIONS",
|
|
JSON_DECODE_EXCEPTIONS,
|
|
),
|
|
(
|
|
"JSON_ENCODE_EXCEPTIONS",
|
|
"homeassistant.util.json.JSON_ENCODE_EXCEPTIONS",
|
|
JSON_ENCODE_EXCEPTIONS,
|
|
),
|
|
],
|
|
)
|
|
def test_deprecated_aliases(
|
|
caplog: pytest.LogCaptureFixture,
|
|
constant_name: str,
|
|
replacement_name: str,
|
|
replacement: Any,
|
|
) -> None:
|
|
"""Test deprecated JSON_DECODE_EXCEPTIONS and JSON_ENCODE_EXCEPTIONS constants.
|
|
|
|
They were moved from helpers to util in #88099
|
|
"""
|
|
import_and_test_deprecated_constant(
|
|
caplog,
|
|
json_helper,
|
|
constant_name,
|
|
replacement_name,
|
|
replacement,
|
|
"2025.8",
|
|
)
|