| # Copyright 2022 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from __future__ import annotations |
| |
| import abc |
| import logging |
| from typing import TYPE_CHECKING, Iterable, Optional, TypeVar, cast, overload |
| |
| from immutabledict import immutabledict |
| from ordered_set import OrderedSet |
| from typing_extensions import override |
| |
| from crossbench import path as pth |
| from crossbench.parse import ObjectParser |
| from crossbench.probes.helper import INTERNAL_NAME_PREFIX |
| |
| if TYPE_CHECKING: |
| from crossbench.probes.probe_result_key import ProbeResultKey |
| from crossbench.runner.probe_result_origin import ProbeResultOrigin |
| from crossbench.types import JsonDict |
| |
| |
| class DuplicateProbeResult(ValueError): |
| pass |
| |
| |
| class ProbeResult(abc.ABC): |
| """ |
| Collection of result files for a given Probe. These can be URLs or any file. |
| |
| We distinguish between two types of files, files that can be fed to Perfetto |
| TraceProcessor (trace) and any other file (file). Trace files will be fed to |
| the trace_processor probe if present. |
| """ |
| |
| def __init__(self, |
| url: Optional[Iterable[str]] = None, |
| file: Optional[Iterable[pth.LocalPath]] = None, |
| perfetto: Optional[Iterable[pth.LocalPath]] = None, |
| **kwargs: Iterable[pth.LocalPath]) -> None: |
| self._url_list: tuple[str, ...] = () |
| if url: |
| self._url_list = ObjectParser.unique_sequence( |
| tuple(url), "urls", DuplicateProbeResult) |
| self._perfetto_list: tuple[pth.LocalPath, ...] = () |
| if perfetto: |
| self._perfetto_list = ObjectParser.unique_sequence( |
| tuple(perfetto), "traces", DuplicateProbeResult) |
| tmp_files: dict[str, OrderedSet[pth.LocalPath]] = {} |
| if file: |
| self._extend(tmp_files, file, suffix=None, allow_duplicates=False) |
| for suffix, files in kwargs.items(): |
| self._extend(tmp_files, files, suffix=suffix, allow_duplicates=False) |
| |
| # Do last and allow duplicated |
| self._extend( |
| tmp_files, self._perfetto_list, suffix=None, allow_duplicates=True) |
| self._files: immutabledict[str, tuple[pth.LocalPath, ...]] = immutabledict({ |
| suffix: tuple(files) for suffix, files in tmp_files.items() |
| }) |
| # TODO: Add Metric object for keeping metrics in-memory instead of reloading |
| # them from serialized JSON files for merging. |
| self._values = None |
| self._validate() |
| |
| def _append(self, |
| tmp_files: dict[str, OrderedSet[pth.LocalPath]], |
| file: pth.LocalPath, |
| suffix: Optional[str] = None, |
| allow_duplicates: bool = False) -> None: |
| file_suffix_name = file.suffix[1:] |
| if not suffix: |
| suffix = file_suffix_name |
| elif file_suffix_name != suffix: |
| raise ValueError( |
| f"Expected '.{suffix}' suffix, but got {repr(file.suffix)} " |
| f"for {file}") |
| if files_with_suffix := tmp_files.get(suffix): |
| if file not in files_with_suffix: |
| files_with_suffix.add(file) |
| elif not allow_duplicates: |
| raise DuplicateProbeResult( |
| f"Cannot append file twice to ProbeResult: {file}") |
| else: |
| tmp_files[suffix] = OrderedSet((file,)) |
| |
| def _extend(self, |
| tmp_files: dict[str, OrderedSet[pth.LocalPath]], |
| files: Iterable[pth.LocalPath], |
| suffix: Optional[str] = None, |
| allow_duplicates: bool = False) -> None: |
| for file in files: |
| self._append( |
| tmp_files, file, suffix=suffix, allow_duplicates=allow_duplicates) |
| |
| def get(self, suffix: str) -> pth.LocalPath: |
| if files_with_suffix := self._files.get(suffix): |
| if len(files_with_suffix) != 1: |
| raise ValueError(f"Expected exactly one file with suffix {suffix}, " |
| f"but got {files_with_suffix}") |
| return files_with_suffix[0] |
| choices: str = f"Options are {tuple(self._files.keys())}." |
| if self.is_empty: |
| choices = "Empty ProbeResult." |
| raise ValueError(f"No files with suffix '.{suffix}'. {choices}") |
| |
| def get_all(self, suffix: str) -> list[pth.LocalPath]: |
| if files_with_suffix := self._files.get(suffix): |
| return list(files_with_suffix) |
| return [] |
| |
| @property |
| def is_empty(self) -> bool: |
| return not self._url_list and not self._files |
| |
| @property |
| def is_remote(self) -> bool: |
| return False |
| |
| @abc.abstractmethod |
| def __bool__(self) -> bool: |
| pass |
| |
| def __hash__(self) -> int: |
| return hash((self._files, self._url_list)) |
| |
| def __eq__(self, other: object) -> bool: |
| if not isinstance(other, ProbeResult): |
| return False |
| if self is other: |
| return True |
| if self._files != other._files: |
| return False |
| return self._url_list == other._url_list |
| |
| def merge(self, other: ProbeResult) -> ProbeResult: |
| if self.is_empty: |
| return other |
| if other.is_empty: |
| return self |
| return LocalProbeResult( |
| url=self.url_list + other.url_list, |
| file=self.file_list + other.file_list, |
| perfetto=self.perfetto_list + other.perfetto_list) |
| |
| def _validate(self) -> None: |
| for path in self.all_files(): |
| if not path.exists(): |
| raise ValueError(f"ProbeResult path does not exist: {path}") |
| |
| def to_json(self) -> JsonDict: |
| result: JsonDict = {} |
| if self._url_list: |
| result["url"] = self._url_list |
| for suffix, files in self._files.items(): |
| result[suffix] = list(map(str, files)) |
| return result |
| |
| @property |
| def has_files(self) -> bool: |
| return bool(self._files) |
| |
| def all_files(self) -> Iterable[pth.LocalPath]: |
| for files in self._files.values(): |
| yield from files |
| |
| @property |
| def url(self) -> str: |
| if len(self._url_list) != 1: |
| raise ValueError("ProbeResult has multiple URLs.") |
| return self._url_list[0] |
| |
| @property |
| def url_list(self) -> list[str]: |
| return list(self._url_list) |
| |
| @property |
| def file(self) -> pth.LocalPath: |
| if sum(len(files) for files in self._files.values()) > 1: |
| raise ValueError("ProbeResult has more than one file.") |
| for files in self._files.values(): |
| return files[0] |
| raise ValueError("ProbeResult has no files.") |
| |
| @property |
| def file_list(self) -> list[pth.LocalPath]: |
| return list(self.all_files()) |
| |
| @property |
| def perfetto(self) -> pth.LocalPath: |
| if len(self._perfetto_list) != 1: |
| raise ValueError("ProbeResult has multiple traces.") |
| return self._perfetto_list[0] |
| |
| @property |
| def perfetto_list(self) -> list[pth.LocalPath]: |
| return list(self._perfetto_list) |
| |
| @property |
| def json(self) -> pth.LocalPath: |
| return self.get("json") |
| |
| @property |
| def json_list(self) -> list[pth.LocalPath]: |
| return self.get_all("json") |
| |
| @property |
| def csv(self) -> pth.LocalPath: |
| return self.get("csv") |
| |
| @property |
| def csv_list(self) -> list[pth.LocalPath]: |
| return self.get_all("csv") |
| |
| |
| class EmptyProbeResult(ProbeResult): |
| |
| def __init__(self) -> None: |
| super().__init__() |
| |
| @override |
| def __bool__(self) -> bool: |
| return False |
| |
| |
| class LocalProbeResult(ProbeResult): |
| """LocalProbeResult can be used for files that are always available on the |
| runner/local machine.""" |
| |
| @override |
| def __bool__(self) -> bool: |
| return not self.is_empty |
| |
| |
| class BrowserProbeResult(ProbeResult): |
| """BrowserProbeResult are stored on the device where the browser runs. |
| Result files will be automatically transferred to the local run's results |
| folder. |
| """ |
| |
| def __init__(self, |
| result_origin: ProbeResultOrigin, |
| url: Optional[Iterable[str]] = None, |
| file: Optional[Iterable[pth.AnyPath]] = None, |
| perfetto: Optional[Iterable[pth.AnyPath]] = None, |
| **kwargs: Iterable[pth.AnyPath]) -> None: |
| self._browser_file = file |
| local_file: Iterable[pth.LocalPath] | None = None |
| local_perfetto: Iterable[pth.LocalPath] | None = None |
| local_kwargs: dict[str, Iterable[pth.LocalPath]] = {} |
| self._is_remote = result_origin.is_remote |
| if self._is_remote: |
| if file: |
| local_file = self._copy_files(result_origin, file) |
| if perfetto: |
| local_perfetto = self._copy_files(result_origin, perfetto) |
| for suffix_name, files in kwargs.items(): |
| local_kwargs[suffix_name] = self._copy_files(result_origin, files) |
| else: |
| # Keep local files as is. |
| local_file = cast(Iterable[pth.LocalPath], file) |
| local_perfetto = cast(Iterable[pth.LocalPath], perfetto) |
| local_kwargs = cast(dict[str, Iterable[pth.LocalPath]], kwargs) |
| |
| super().__init__( |
| url=url, file=local_file, perfetto=local_perfetto, **local_kwargs) |
| |
| @override |
| def __bool__(self) -> bool: |
| return not self.is_empty |
| |
| @property |
| @override |
| def is_remote(self) -> bool: |
| return self._is_remote |
| |
| def _copy_files(self, result_origin: ProbeResultOrigin, |
| paths: Iterable[pth.AnyPath]) -> Iterable[pth.LocalPath]: |
| assert paths, "Got no remote paths to copy." |
| # Copy result files from remote tmp dir to local results dir |
| browser_platform = result_origin.browser_platform |
| remote_tmp_dir = result_origin.browser_tmp_dir |
| out_dir = result_origin.out_dir |
| local_result_paths: list[pth.LocalPath] = [] |
| for remote_path in paths: |
| try: |
| relative_path = remote_path.relative_to(remote_tmp_dir) |
| except ValueError: |
| logging.debug( |
| "Browser result is not in browser tmp dir: " |
| "only using the name of '%s'", remote_path) |
| relative_path = result_origin.host_platform.local_path(remote_path.name) |
| local_result_path = out_dir / relative_path |
| browser_platform.pull(remote_path, local_result_path) |
| assert local_result_path.exists(), "Failed to copy result file." |
| local_result_paths.append(local_result_path) |
| return local_result_paths |
| |
| |
| DefaultT = TypeVar("DefaultT") |
| |
| |
| class ProbeResultDict: |
| """ |
| Maps Probes to their result files Paths. |
| """ |
| |
| def __init__(self, path: pth.AnyPath) -> None: |
| self._path = path |
| self._dict: dict[str, ProbeResult] = {} |
| |
| def __setitem__(self, probe: ProbeResultKey, result: ProbeResult) -> None: |
| assert isinstance(result, ProbeResult) |
| self._dict[probe.name] = result |
| |
| def __getitem__(self, probe: ProbeResultKey) -> ProbeResult: |
| name = probe.name |
| if name not in self._dict: |
| raise KeyError(f"No results for probe='{name}'") |
| return self._dict[name] |
| |
| def __contains__(self, probe: ProbeResultKey) -> bool: |
| return probe.name in self._dict |
| |
| def __bool__(self) -> bool: |
| return bool(self._dict) |
| |
| def __len__(self) -> int: |
| return len(self._dict) |
| |
| @overload |
| def get(self, probe: ProbeResultKey, /) -> ProbeResult | None: |
| pass |
| |
| @overload |
| def get(self, probe: ProbeResultKey, default: DefaultT, |
| /) -> ProbeResult | DefaultT: |
| pass |
| |
| def get(self, |
| probe: ProbeResultKey, |
| default: Optional[DefaultT] = None, |
| /) -> ProbeResult | DefaultT | None: |
| return self._dict.get(probe.name, default) |
| |
| def get_by_name(self, name: str) -> ProbeResult | None: |
| # Debug helper only. |
| # Use bracket `results[probe]` or `results.get(probe)` instead. |
| return self._dict.get(name) |
| |
| def to_json(self) -> JsonDict: |
| data: JsonDict = {} |
| for probe_name, results in self._dict.items(): |
| if isinstance(results, (pth.AnyPath, str)): |
| data[probe_name] = str(results) |
| elif results.is_empty: |
| if not probe_name.startswith(INTERNAL_NAME_PREFIX): |
| logging.debug("probe=%s did not produce any data.", probe_name) |
| data[probe_name] = None |
| else: |
| data[probe_name] = results.to_json() |
| return data |
| |
| def all_traces(self) -> Iterable[pth.LocalPath]: |
| for probe_result in self._dict.values(): |
| yield from probe_result.perfetto_list |