| # Copyright 2023 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from dataclasses import dataclass |
| import enum |
| import typing |
| import unittest |
| |
| from dataparse import dataparse |
| from dataparse import DataParseError |
| |
| """The following classes are for test purposes. |
| |
| They describe a weather API reporting weather status and forecasts |
| for a specific city. |
| """ |
| |
| |
| @dataparse |
| @dataclass(frozen=True, order=True) |
| class City: |
| name: str |
| |
| |
| @dataparse |
| @dataclass |
| class TemperatureDataPoint: |
| temperature_celsius: float |
| hour: int |
| |
| |
| @dataparse |
| @dataclass |
| class Weather: |
| temperature_celsius: float |
| chance_of_rain: float |
| description: str |
| city: City |
| temperature_by_hour: list[TemperatureDataPoint] | None = None |
| weather_station_ids: typing.Set[int] | None = None |
| suggested_cities: typing.Set[City] | None = None |
| |
| |
| class TestDataParse(unittest.TestCase): |
| def test_from_dict(self) -> None: |
| """Test basic from_dict. |
| |
| This test instantiates a Weather object from a dictionary and asserts that |
| the fields were correctly set. |
| """ |
| |
| weather: Weather = Weather.from_dict( # type:ignore |
| { |
| "temperature_celsius": 12.0, |
| "chance_of_rain": 33.0, |
| "description": "scattered showers", |
| "city": {"name": "San Francisco"}, |
| } |
| ) |
| self.assertAlmostEqual(weather.temperature_celsius, 12) |
| self.assertAlmostEqual(weather.chance_of_rain, 33) |
| self.assertEqual(weather.description, "scattered showers") |
| self.assertEqual(weather.city.name, "San Francisco") |
| |
| def test_to_dict(self) -> None: |
| """Test basic to_dict. |
| |
| This test instantiates a Weather object and asserts that it can be |
| converted to a dictionary. |
| """ |
| |
| weather = Weather( |
| temperature_celsius=14, |
| chance_of_rain=5, |
| description="sunny", |
| city=City(name="San Diego"), |
| ) |
| d: dict[str, typing.Any] = weather.to_dict() # type:ignore |
| self.assertDictEqual( |
| d, |
| { |
| "temperature_celsius": 14, |
| "chance_of_rain": 5, |
| "description": "sunny", |
| "city": {"name": "San Diego"}, |
| }, |
| ) |
| |
| def test_with_collections(self) -> None: |
| """Test advanced collection to/from dict. |
| |
| This test instantiates a Weather object from a dictionary containing |
| all of the different types supported by dataparse, including sets, lists, |
| and sets/lists of @dataparse objects. It then converts to and from dicts |
| to ensure that the data remains consistent and correct. |
| """ |
| |
| weather_dict = { |
| "temperature_celsius": 12.0, |
| "chance_of_rain": 33.25, |
| "description": "scattered showers", |
| "city": {"name": "San Francisco"}, |
| "temperature_by_hour": [ |
| { |
| "temperature_celsius": 11, |
| "hour": 13, |
| }, |
| { |
| "temperature_celsius": 11.5, |
| "hour": 15, |
| }, |
| { |
| "temperature_celsius": 12, |
| "hour": 16, |
| }, |
| { |
| "temperature_celsius": 12.25, |
| "hour": 17, |
| }, |
| { |
| "temperature_celsius": 13, |
| "hour": 18, |
| }, |
| { |
| "temperature_celsius": 12, |
| "hour": 19, |
| }, |
| ], |
| "weather_station_ids": [1000, 1050, 2000], |
| "suggested_cities": [{"name": "Oakland"}, {"name": "San Jose"}], |
| } |
| weather: Weather = Weather.from_dict(weather_dict) # type:ignore |
| hourly = weather.temperature_by_hour or [] |
| self.assertEqual(len(hourly), 6) |
| self.assertIsInstance(hourly[0], TemperatureDataPoint) |
| self.assertIsInstance(weather.suggested_cities, set) |
| for v in weather.suggested_cities or set(): |
| self.assertIsInstance(v, City) |
| self.assertSetEqual( |
| weather.weather_station_ids or set(), {1000, 2000, 1050} |
| ) |
| self.assertSetEqual( |
| weather.suggested_cities or set(), |
| set([City("Oakland"), City("San Jose")]), |
| ) |
| self.assertDictEqual(weather_dict, weather.to_dict()) # type:ignore |
| |
| def test_unsortable_set(self) -> None: |
| """Test dataparse with a set of unsortable values. |
| |
| Dataparse stores sets as lists in the output for better JSON |
| serialization, and it attempts to sort the list before output. |
| This test ensures that dataparse does not fail when the set's |
| contents cannot be sorted. |
| """ |
| |
| @dataparse |
| @dataclass(frozen=True) |
| class Name: |
| name: str |
| |
| @dataparse |
| @dataclass |
| class WithSet: |
| set_of_ids: typing.Set[Name] |
| |
| val = WithSet(set_of_ids=set([Name("A"), Name("B")])) |
| new_val: WithSet = WithSet.from_dict(val.to_dict()) # type:ignore |
| self.assertSetEqual( |
| set([v.name for v in val.set_of_ids]), |
| set([v.name for v in new_val.set_of_ids]), |
| ) |
| |
| def test_renames(self) -> None: |
| """Test that field renames are respected by to_dict and from_dict.""" |
| |
| @dataparse |
| @dataclass |
| class KV: |
| the_key: str |
| the_value: str |
| |
| @classmethod |
| def dataparse_renames(cls) -> dict[str, str]: |
| return {"the_key": "The Key", "the_value": "The Value"} |
| |
| input = {"The Key": "foo", "The Value": "bar"} |
| kv: KV = KV.from_dict(input) # type:ignore |
| self.assertEqual(kv.the_key, "foo") |
| self.assertEqual(kv.the_value, "bar") |
| |
| out: dict[str, typing.Any] = kv.to_dict() # type:ignore |
| self.assertDictEqual(input, out) |
| |
| def test_nulls(self) -> None: |
| """Test null handling for dataparse. |
| |
| None (JSON: null) values in the input are passed verbatim as |
| values in from_dict, but they are omitted in to_dict. |
| """ |
| |
| @dataparse |
| @dataclass |
| class HasNull: |
| value: int | None = None |
| |
| has_null: HasNull = HasNull.from_dict({"value": None}) # type:ignore |
| self.assertEqual(has_null, HasNull()) |
| out_dict: dict[str, typing.Any] = has_null.to_dict() # type:ignore |
| # Omit nulls in output. |
| self.assertFalse(hasattr(out_dict, "value")) |
| |
| |
| class TestDataParseErrors(unittest.TestCase): |
| def test_unknown_union(self) -> None: |
| """Test for failure when we do not expect the union type. |
| |
| dataparse only supports optional (Union[Any, None]) unions. |
| Ensure that using a different union type raises a DataParseError. |
| """ |
| |
| @dataparse |
| @dataclass |
| class BadUnion: |
| val: typing.Union[int, float] |
| |
| self.assertRaises( |
| DataParseError, |
| lambda: BadUnion.from_dict( # type:ignore[attr-defined] |
| {"val": 30} |
| ), |
| ) |
| |
| def test_invalid_class(self) -> None: |
| """Test that we cannot wrap a non-dataclass with dataparse.""" |
| |
| def wrap_a_non_dataclass() -> None: |
| @dataparse |
| class Bad: |
| pass |
| |
| self.assertRaises(DataParseError, wrap_a_non_dataclass) |
| |
| |
| class StatusEnum(enum.Enum): |
| OK = "OK" |
| Failure = "FAILURE" |
| |
| |
| @dataparse |
| @dataclass |
| class ContainsEnum: |
| status: StatusEnum |
| |
| |
| class TestDataParseEnums(unittest.TestCase): |
| def test_valid_enum_type(self) -> None: |
| """Test that we can serialize and deserialize enum values.""" |
| |
| value = ContainsEnum(status=StatusEnum.Failure) |
| value2 = ContainsEnum(status=StatusEnum.OK) |
| |
| value_dict = value.to_dict() # type:ignore[attr-defined] |
| self.assertEqual(value_dict["status"], "FAILURE") |
| value2_dict = value2.to_dict() # type:ignore[attr-defined] |
| self.assertEqual(value2_dict["status"], "OK") |
| |
| self.assertEqual( |
| value, |
| ContainsEnum.from_dict(value_dict), # type:ignore[attr-defined] |
| ) |
| self.assertEqual( |
| value2, |
| ContainsEnum.from_dict(value2_dict), # type:ignore[attr-defined] |
| ) |
| |
| def test_invalid_enum_parsing(self) -> None: |
| """Test that we throw an error when we encounter an unexpected enum value.""" |
| value_to_parse = {"status": "NOT VALID"} |
| self.assertRaises( |
| DataParseError, |
| lambda: ContainsEnum.from_dict(value_to_parse), # type:ignore |
| ) |