| # Copyright 2024 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. | |
| import json | |
| import typing | |
| from dataclasses import dataclass | |
| class InspectDataError(Exception): | |
| """Base class for errors reading inspect data""" | |
| class VersionMismatchError(InspectDataError): | |
| """Raised when we receive an unexpected schema version""" | |
| class InvalidDataTypeError(InspectDataError): | |
| """Raised when data of a type other than "Inspect" is read""" | |
| class MissingFieldError(InspectDataError): | |
| """Raised when the data is missing an expected field""" | |
| class InvalidFieldError(InspectDataError): | |
| """Raised when the data has a non-dictionary where a dictionary is expected""" | |
| @dataclass | |
| class InspectMetadataError: | |
| message: str | |
| def __str__(self) -> str: | |
| return self.message | |
| class Timestamp: | |
| def __init__(self, timestamp_nanos: int): | |
| self._nanos: int = int(timestamp_nanos) | |
| def seconds(self) -> float: | |
| """The number of seconds represented by this timestamp. | |
| Returns: | |
| float: Timestamp in seconds, as a float. | |
| """ | |
| return self._nanos / 1e9 | |
| def nanoseconds(self) -> int: | |
| """The number of nanoseconds represented by this timestamp. | |
| Returns: | |
| int: Timestamp in nanoseconds, as an int. | |
| """ | |
| return self._nanos | |
| @dataclass | |
| class InspectMetadata: | |
| timestamp: Timestamp | |
| file_name: str | None = None | |
| errors: list[str] | None = None | |
| component_url: str | None = None | |
| @staticmethod | |
| def from_dict(data: dict[str, typing.Any]) -> "InspectMetadata": | |
| """Process the dictionary as an InspectMetadata field. | |
| Args: | |
| data (dict[str, typing.Any]): Source dictionary | |
| Returns: | |
| InspectMetadata: Validated dictionary contents. | |
| Raises: | |
| InspectDataError: If data is missing required fields. | |
| """ | |
| timestamp = Timestamp(int(_extract_or_throw(data, "timestamp"))) | |
| return InspectMetadata( | |
| timestamp=timestamp, | |
| file_name=data.get("file_name"), | |
| errors=data.get("errors"), | |
| component_url=data.get("component_url"), | |
| ) | |
| @dataclass | |
| class InspectData: | |
| moniker: str | |
| metadata: InspectMetadata | |
| payload: dict[str, typing.Any] | None | |
| version: int | |
| @staticmethod | |
| def from_dict(data: dict[str, typing.Any]) -> "InspectData": | |
| """Process the dictionary as InspectData. | |
| Args: | |
| data (dict[str, typing.Any]): Source dictionary. | |
| Raises: | |
| VersionMismatchError: If the version is unexpected. | |
| InvalidDataTypeError: If the data fails validation. | |
| InspectDataError: If the data is missing a field. | |
| Returns: | |
| InspectData: The parsed and validated contents. | |
| """ | |
| version = int(_extract_or_throw(data, "version")) | |
| if version != 1: | |
| raise VersionMismatchError(f"Found version {version}, expected 1") | |
| data_source = str(_extract_or_throw(data, "data_source")) | |
| if data_source != "Inspect": | |
| raise InvalidDataTypeError( | |
| f"Expected Inspect data, found {data_source}" | |
| ) | |
| return InspectData( | |
| version=version, | |
| moniker=str(_extract_or_throw(data, "moniker")), | |
| metadata=InspectMetadata.from_dict( | |
| _extract_or_throw(data, "metadata") | |
| ), | |
| payload=_extract_or_throw(data, "payload"), | |
| ) | |
| @dataclass | |
| class InspectDataCollection: | |
| data: list[InspectData] | |
| @staticmethod | |
| def from_list(lst: list[dict[str, typing.Any]]) -> "InspectDataCollection": | |
| """Process a list into a collection. | |
| Args: | |
| lst (list[dict[str, typing.Any]]): Source list. | |
| Returns: | |
| InspectDataCollection: Validated and processed contents. | |
| """ | |
| return InspectDataCollection([InspectData.from_dict(d) for d in lst]) | |
| @staticmethod | |
| def from_json_list(json_str: str) -> "InspectDataCollection": | |
| """Process a string as JSON and turn into a collection. | |
| Args: | |
| json_str (str): Source JSON string. | |
| Returns: | |
| InspectDataCollection: Validated and processed contents. | |
| """ | |
| return InspectDataCollection.from_list(json.loads(json_str)) | |
| def _extract_or_throw( | |
| data: typing.Dict[typing.Any, typing.Any], *path: str | |
| ) -> typing.Any: | |
| if len(path) == 0: | |
| raise ValueError("BUG: Path cannot be empty") | |
| next: typing.Any = data | |
| for p in path: | |
| if isinstance(next, dict): | |
| if p not in next: | |
| raise MissingFieldError( | |
| f"While reading path {path}, expected to find {p} in {next}" | |
| ) | |
| next = next.get(p) | |
| else: | |
| raise InvalidFieldError( | |
| f"While reading path {path}, expected to find a dictionary at {p} but found {next}" | |
| ) | |
| return next |