diff --git a/grizzly/common/status.py b/grizzly/common/status.py index 20cefa26..91f09347 100644 --- a/grizzly/common/status.py +++ b/grizzly/common/status.py @@ -2,16 +2,21 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. """Manage Grizzly status reports.""" +from abc import ABC from collections import defaultdict, namedtuple from contextlib import closing, contextmanager from copy import deepcopy +from dataclasses import dataclass from json import dumps, loads from logging import getLogger from os import getpid -from sqlite3 import OperationalError, connect +from pathlib import Path +from sqlite3 import Connection, OperationalError, connect from time import perf_counter, time +from typing import Callable, Dict, Generator, List, Optional, Set, Tuple, Union, cast -from ..common.utils import grz_tmp +from .reporter import FuzzManagerReporter +from .utils import grz_tmp __all__ = ("ReadOnlyStatus", "ReductionStatus", "Status", "SimpleStatus") __author__ = "Tyson Smith" @@ -33,20 +38,34 @@ LOG = getLogger(__name__) -ProfileEntry = namedtuple("ProfileEntry", "count max min name total") -ResultEntry = namedtuple("ResultEntry", "rid count desc") +# ProfileEntry = namedtuple("ProfileEntry", "count max min name total") +@dataclass(eq=False, frozen=True) +class ProfileEntry: + count: int + max: float + min: float + name: str + total: float -def _db_version_check(con, expected=DB_VERSION): +# ResultEntry = namedtuple("ResultEntry", "rid count desc") +@dataclass(eq=False, frozen=True) +class ResultEntry: + rid: str + count: int + desc: Optional[str] + + +def _db_version_check(con: Connection, expected: int = DB_VERSION) -> bool: """Perform version check and remove obsolete tables if required. Args: - con (sqlite3.Connection): An open database connection. - expected (int): The latest database version. + con: An open database connection. + expected: The latest database version. Returns: - bool: True if database was reset otherwise False. + True if database was reset otherwise False. """ assert expected > 0 cur = con.cursor() @@ -57,7 +76,7 @@ def _db_version_check(con, expected=DB_VERSION): cur.execute("BEGIN EXCLUSIVE;") # check db version again while locked to avoid race cur.execute("PRAGMA user_version;") - version = cur.fetchone()[0] + version = cast(int, cur.fetchone()[0]) if version < expected: LOG.debug("db version %d < %d", version, expected) # remove ALL tables from the database @@ -73,18 +92,17 @@ def _db_version_check(con, expected=DB_VERSION): return False -class BaseStatus: +class BaseStatus(ABC): """Record and manage status information. Attributes: - _profiles (dict): Profiling data. - ignored (int): Ignored result count. - iteration (int): Iteration count. - log_size (int): Log size in bytes. - pid (int): Python process ID. - results (None): Placeholder for result data. - start_time (float): Start time of session. - test_name (str): Current test name. + _profiles: Profiling data. + ignored: Ignored result count. + iteration: Iteration count. + log_size: Log size in bytes. + pid: Python process ID. + start_time: Start time of session. + test_name: Current test name. """ __slots__ = ( @@ -93,28 +111,33 @@ class BaseStatus: "iteration", "log_size", "pid", - "results", "start_time", "test_name", ) - def __init__(self, pid, start_time, ignored=0, iteration=0, log_size=0): + def __init__( + self, + pid: int, + start_time: float, + ignored: int = 0, + iteration: int = 0, + log_size: int = 0, + ) -> None: assert pid >= 0 assert ignored >= 0 assert iteration >= 0 assert log_size >= 0 assert isinstance(start_time, float) assert start_time >= 0 - self._profiles = {} + self._profiles: Dict[str, Dict[str, Union[float, int]]] = {} self.ignored = ignored self.iteration = iteration self.log_size = log_size self.pid = pid - self.results = None self.start_time = start_time self.test_name = None - def profile_entries(self): + def profile_entries(self) -> Generator[ProfileEntry, None, None]: """Used to retrieve profiling data. Args: @@ -125,30 +148,34 @@ def profile_entries(self): """ for name, entry in self._profiles.items(): yield ProfileEntry( - entry["count"], entry["max"], entry["min"], name, entry["total"] + cast(int, entry["count"]), + entry["max"], + entry["min"], + name, + entry["total"], ) @property - def rate(self): + def rate(self) -> float: """Calculate the average iteration rate in seconds. Args: None Returns: - float: Number of iterations performed per second. + Number of iterations performed per second. """ return self.iteration / self.runtime if self.runtime else 0 @property - def runtime(self): + def runtime(self) -> float: """Calculate the number of seconds since start() was called. Args: None Returns: - int: Total runtime in seconds. + Total runtime in seconds. """ return max(time() - self.start_time, 0) @@ -157,34 +184,45 @@ class ReadOnlyStatus(BaseStatus): """Store status information. Attributes: - _profiles (dict): Profiling data. - ignored (int): Ignored result count. - iteration (int): Iteration count. - log_size (int): Log size in bytes. - pid (int): Python process ID. + _profiles: Profiling data. + ignored: Ignored result count. + iteration: Iteration count. + log_size: Log size in bytes. + pid: Python process ID. results (None): Placeholder for result data. start_time (float): Start time of session. test_name (str): Test name. timestamp (float): Last time data was saved to database. """ - __slots__ = ("timestamp",) + __slots__ = ("results", "timestamp") - def __init__(self, pid, start_time, timestamp, ignored=0, iteration=0, log_size=0): + def __init__( + self, + pid: int, + start_time: float, + timestamp: float, + ignored: int = 0, + iteration: int = 0, + log_size: int = 0, + ) -> None: super().__init__( pid, start_time, ignored=ignored, iteration=iteration, log_size=log_size ) assert isinstance(timestamp, float) assert timestamp >= start_time + self.results: Optional[ReadOnlyResultCounter] = None self.timestamp = timestamp @classmethod - def load_all(cls, db_file, time_limit=300): + def load_all( + cls, db_file: Path, time_limit: float = 300 + ) -> Generator["ReadOnlyStatus", None, None]: """Load all status reports found in `db_file`. Args: - db_file (Path): Database containing status data. - time_limit (int): Filter entries by age. Use zero for no limit. + db_file: Database containing status data. + time_limit: Filter entries by age. Use zero for no limit. Yields: ReadOnlyStatus: Successfully loaded objects. @@ -210,7 +248,7 @@ def load_all(cls, db_file, time_limit=300): except OperationalError as exc: if not str(exc).startswith("no such table:"): raise # pragma: no cover - entries = () + entries = [] # Load all results results = ReadOnlyResultCounter.load(db_file, time_limit=0) @@ -234,14 +272,14 @@ def load_all(cls, db_file, time_limit=300): yield status @property - def runtime(self): + def runtime(self) -> float: """Calculate total runtime in seconds relative to 'timestamp'. Args: None Returns: - int: Total runtime in seconds. + Total runtime in seconds. """ return self.timestamp - self.start_time @@ -250,29 +288,31 @@ class SimpleStatus(BaseStatus): """Record and manage status information. Attributes: - _profiles (dict): Profiling data. - ignored (int): Ignored result count. - iteration (int): Iteration count. - log_size (int): Log size in bytes. - pid (int): Python process ID. - results (None): Placeholder for result data. - start_time (float): Start time of session. - test_name (str): Current test name. + _profiles: Profiling data. + ignored: Ignored result count. + iteration: Iteration count. + log_size: Log size in bytes. + pid: Python process ID. + results: + start_time: Start time of session. + test_name: Current test name. """ - def __init__(self, pid, start_time): + __slots__ = ("results",) + + def __init__(self, pid: int, start_time: float) -> None: super().__init__(pid, start_time) self.results = SimpleResultCounter(pid) @classmethod - def start(cls): + def start(cls) -> "SimpleStatus": """Create a unique SimpleStatus object. Args: None Returns: - SimpleStatus: Active status report. + Active status report. """ return cls(getpid(), time()) @@ -281,30 +321,30 @@ class Status(BaseStatus): """Status records status information and stores it in a database. Attributes: - _db_file (Path): Database file containing data. - _enable_profiling (bool): Profiling support status. - _profiles (dict): Profiling data. - ignored (int): Ignored result count. - iteration (int): Iteration count. - log_size (int): Log size in bytes. - pid (int): Python process ID. - results (ResultCounter): Results data. Used to count occurrences of results. - start_time (float): Start time of session. - test_name (str): Current test name. - timestamp (float): Last time data was saved to database. + _db_file: Database file containing data. + _enable_profiling: Profiling support status. + _profiles: Profiling data. + ignored: Ignored result count. + iteration: Iteration count. + log_size: Log size in bytes. + pid: Python process ID. + results: Results data. Used to count occurrences of results. + start_time: Start time of session. + test_name: Current test name. + timestamp: Last time data was saved to database. """ - __slots__ = ("_db_file", "_enable_profiling", "timestamp") + __slots__ = ("_db_file", "_enable_profiling", "results", "timestamp") def __init__( self, - pid, - start_time, - db_file, - enable_profiling=False, - life_time=REPORTS_EXPIRE, - report_limit=0, - ): + pid: int, + start_time: float, + db_file: Path, + enable_profiling: bool = False, + life_time: float = REPORTS_EXPIRE, + report_limit: int = 0, + ) -> None: super().__init__(pid, start_time) assert life_time >= 0 assert report_limit >= 0 @@ -315,7 +355,7 @@ def __init__( self.timestamp = start_time @staticmethod - def _init_db(db_file, pid, life_time): + def _init_db(db_file: Path, pid: int, life_time: float) -> None: # prepare database LOG.debug("status using db %s", db_file) with closing(connect(db_file, timeout=DB_TIMEOUT)) as con: @@ -343,11 +383,11 @@ def _init_db(db_file, pid, life_time): cur.execute("""DELETE FROM status WHERE pid = ?;""", (pid,)) @contextmanager - def measure(self, name): + def measure(self, name: str) -> Generator[None, None, None]: """Used to simplify collecting profiling data. Args: - name (str): Used to group the entries. + name: Used to group the entries. Yields: None @@ -359,13 +399,13 @@ def measure(self, name): else: yield - def record(self, name, duration): + def record(self, name: str, duration: float) -> None: """Used to add profiling data. This is intended to be used to make rough calculations to identify major configuration issues. Args: - name (str): Used to group the entries. - duration (int, float): Stored to be later used for measurements. + name: Used to group the entries. + duration: Stored to be later used for measurements. Returns: None @@ -388,22 +428,23 @@ def record(self, name, duration): "total": duration, } - def report(self, force=False, report_rate=REPORT_RATE): + def report(self, force: bool = False, report_rate: int = REPORT_RATE) -> bool: """Write status report to database. Reports are only written periodically. It is limited by `report_rate`. The specified number of seconds must elapse before another write will be performed unless `force` is True. Args: - force (bool): Ignore report frequency limiting. - report_rate (int): Minimum number of seconds between writes to database. + force: Ignore report frequency limiting. + report_rate: Minimum number of seconds between writes to database. Returns: - bool: True if the report was successful otherwise False. + True if the report was successful otherwise False. """ now = time() if self.results.last_found > self.timestamp: LOG.debug("results have been found since last report, force update") force = True + assert report_rate >= 0 if not force and now < (self.timestamp + report_rate): return False assert self.start_time <= now @@ -456,13 +497,15 @@ def report(self, force=False, report_rate=REPORT_RATE): return True @classmethod - def start(cls, db_file, enable_profiling=False, report_limit=0): + def start( + cls, db_file: Path, enable_profiling: bool = False, report_limit: int = 0 + ) -> "Status": """Create a unique Status object. Args: - db_file (Path): Database containing status data. - enable_profiling (bool): Record profiling data. - report_limit (int): Number of times a unique result will be reported. + db_file: Database containing status data. + enable_profiling: Record profiling data. + report_limit: Number of times a unique result will be reported. Returns: Status: Active status report. @@ -481,33 +524,35 @@ def start(cls, db_file, enable_profiling=False, report_limit=0): class SimpleResultCounter: __slots__ = ("_count", "_desc", "pid") - def __init__(self, pid): + def __init__(self, pid: int) -> None: assert pid >= 0 - self._count = defaultdict(int) - self._desc = {} + self._count: Dict[str, int] = defaultdict(int) + self._desc: Dict[str, str] = {} self.pid = pid - def __iter__(self): + def __iter__(self) -> Generator[ResultEntry, None, None]: """Yield all result data. Args: None Yields: - ResultEntry: Contains ID, count and description for each result entry. + Contains ID, count and description for each result entry. """ for result_id, count in self._count.items(): if count > 0: yield ResultEntry(result_id, count, self._desc.get(result_id, None)) - def blockers(self, iterations, iters_per_result=100): + def blockers( + self, iterations: int, iters_per_result: int = 100 + ) -> Generator[ResultEntry, None, None]: """Any result with an iterations-per-result ratio of less than or equal the given limit are considered 'blockers'. Results with a count <= 1 are not included. Args: - iterations (int): Total iterations. - iters_per_result (int): Iterations-per-result threshold. + iterations: Total iterations. + iters_per_result: Iterations-per-result threshold. Yields: ResultEntry: ID, count and description of blocking result. @@ -518,15 +563,15 @@ def blockers(self, iterations, iters_per_result=100): if entry.count > 1 and iterations / entry.count <= iters_per_result: yield entry - def count(self, result_id, desc): + def count(self, result_id: str, desc: str) -> int: """ Args: - result_id (str): Result ID. - desc (str): User friendly description. + result_id: Result ID. + desc: User friendly description. Returns: - int: Current count for given result_id. + Current count for given result_id. """ assert isinstance(result_id, str) self._count[result_id] += 1 @@ -534,7 +579,7 @@ def count(self, result_id, desc): self._desc[result_id] = desc return self._count[result_id] - def get(self, result_id): + def get(self, result_id: str) -> ResultEntry: """Get count and description for given result id. Args: @@ -549,14 +594,14 @@ def get(self, result_id): ) @property - def total(self): + def total(self) -> int: """Get total count of all results. Args: None Returns: - int: Total result count. + Total result count. """ return sum(self._count.values()) @@ -566,15 +611,15 @@ def count(self, result_id, desc): raise NotImplementedError("Read only!") # pragma: no cover @classmethod - def load(cls, db_file, time_limit=0): + def load(cls, db_file: Path, time_limit: float = 0) -> List: """Load existing entries for database and populate a ReadOnlyResultCounter. Args: - db_file (Path): Database file. - time_limit (int): Used to filter older entries. + db_file: Database file. + time_limit: Used to filter older entries. Returns: - list: Loaded ReadOnlyResultCounter objects. + Loaded ReadOnlyResultCounter objects. """ assert time_limit >= 0 with closing(connect(db_file, timeout=DB_TIMEOUT)) as con: @@ -599,7 +644,7 @@ def load(cls, db_file, time_limit=0): except OperationalError as exc: if not str(exc).startswith("no such table:"): raise # pragma: no cover - entries = () + entries = [] loaded = {} for pid, result_id, desc, count in entries: @@ -614,19 +659,25 @@ def load(cls, db_file, time_limit=0): class ResultCounter(SimpleResultCounter): __slots__ = ("_db_file", "_frequent", "_limit", "last_found") - def __init__(self, pid, db_file, life_time=RESULTS_EXPIRE, report_limit=0): + def __init__( + self, + pid: int, + db_file: Path, + life_time: int = RESULTS_EXPIRE, + report_limit: int = 0, + ) -> None: super().__init__(pid) assert db_file assert report_limit >= 0 self._db_file = db_file - self._frequent = set() + self._frequent: Set[str] = set() # use zero to disable report limit self._limit = report_limit - self.last_found = 0 + self.last_found = 0.0 self._init_db(db_file, pid, life_time) @staticmethod - def _init_db(db_file, pid, life_time): + def _init_db(db_file: Path, pid: int, life_time: float) -> None: # prepare database LOG.debug("resultcounter using db %s", db_file) with closing(connect(db_file, timeout=DB_TIMEOUT)) as con: @@ -661,16 +712,16 @@ def _init_db(db_file, pid, life_time): if not str(exc).startswith("no such table:"): raise # pragma: no cover - def count(self, result_id, desc): + def count(self, result_id: str, desc: str) -> Tuple[int, bool]: """Count results and write results to the database. Args: - result_id (str): Result ID. - desc (str): User friendly description. + result_id: Result ID. + desc: User friendly description. Returns: - tuple (int, bool): Local count and initial report (includes - parallel instances) for given result_id. + Local count and initial report flag (includes parallel instances) + for given result_id. """ super().count(result_id, desc) timestamp = time() @@ -705,16 +756,16 @@ def count(self, result_id, desc): self.last_found = timestamp return self._count[result_id], initial - def is_frequent(self, result_id): + def is_frequent(self, result_id: str) -> bool: """Scan all results including results from other running instances to determine if the limit has been exceeded. Local count must be >1 before limit is checked. Args: - result_id (str): Result ID. + result_id: Result ID. Returns: - bool: True if limit has been exceeded otherwise False. + True if limit has been exceeded otherwise False. """ assert isinstance(result_id, str) if self._limit < 1: @@ -743,7 +794,7 @@ def is_frequent(self, result_id): return True return False - def mark_frequent(self, result_id): + def mark_frequent(self, result_id: str) -> None: """Mark given results ID as frequent locally. Args: @@ -767,22 +818,23 @@ class ReductionStatus: def __init__( self, - strategies=None, - testcase_size_cb=None, - crash_id=None, - db_file=None, - pid=None, - tool=None, - life_time=REPORTS_EXPIRE, - ): + strategies: Optional[List[str]] = None, + testcase_size_cb: Optional[Callable[[], int]] = None, + crash_id: Optional[int] = None, + db_file: Optional[Path] = None, + pid: Optional[int] = None, + tool: Optional[str] = None, + life_time: float = REPORTS_EXPIRE, + ) -> None: """Initialize a ReductionStatus instance. Arguments: - strategies (list(str)): List of strategies to be run. - testcase_size_cb (callable): Callback to get testcase size - crash_id (int): CrashManager ID of original testcase - db_file (Path): Database file containing data. - tool (str): The tool name used for reporting to FuzzManager. + strategies: List of strategies to be run. + testcase_size_cb: Callback to get testcase size. + crash_id: CrashManager ID of original testcase. + db_file: Database file containing data. + tool: The tool name used for reporting to FuzzManager. + life_time: """ self.analysis = {} self.attempts = 0 @@ -842,23 +894,23 @@ def __init__( @classmethod def start( cls, - db_file, - strategies=None, - testcase_size_cb=None, - crash_id=None, - tool=None, - ): + db_file: Path, + strategies: Optional[List[str]] = None, + testcase_size_cb: Optional[Callable[[], int]] = None, + crash_id: Optional[int] = None, + tool: Optional[str] = None, + ) -> "ReductionStatus": """Create a unique ReductionStatus object. Args: - db_file (Path): Database containing status data. - strategies (list(str)): List of strategies to be run. - testcase_size_cb (callable): Callback to get testcase size - crash_id (int): CrashManager ID of original testcase - tool (str): The tool name used for reporting to FuzzManager. + db_file: Database containing status data. + strategies: List of strategies to be run. + testcase_size_cb: Callback to get testcase size. + crash_id: CrashManager ID of original testcase. + tool: The tool name used for reporting to FuzzManager. Returns: - ReductionStatus: Active status report. + Active status report. """ status = cls( crash_id=crash_id, @@ -871,17 +923,17 @@ def start( status.report(force=True) return status - def report(self, force=False, report_rate=REPORT_RATE): + def report(self, force: bool = False, report_rate: float = REPORT_RATE) -> bool: """Write status report to database. Reports are only written periodically. It is limited by `report_rate`. The specified number of seconds must elapse before another write will be performed unless `force` is True. Args: - force (bool): Ignore report frequently limiting. - report_rate (int): Minimum number of seconds between writes. + force: Ignore report frequently limiting. + report_rate: Minimum number of seconds between writes. Returns: - bool: Returns true if the report was successful otherwise false. + True if the report was successful otherwise false. """ now = time() if not force and now < (self.timestamp + report_rate): @@ -980,16 +1032,18 @@ def report(self, force=False, report_rate=REPORT_RATE): return True @classmethod - def load_all(cls, db_file, time_limit=300): + def load_all( + cls, db_file: Path, time_limit: float = 300 + ) -> Generator["ReductionStatus", None, None]: """Load all reduction status reports found in `db_file`. Args: - db_file (Path): Database containing status data. - time_limit (int): Only include entries with a timestamp that is within the - given number of seconds. Use zero for no limit. + db_file: Database containing status data. + time_limit: Only include entries with a timestamp that is within the + given number of seconds. Use zero for no limit. Yields: - Status: Successfully loaded read-only status objects. + Successfully loaded read-only status objects. """ assert time_limit >= 0 with closing(connect(db_file, timeout=DB_TIMEOUT)) as con: @@ -1022,7 +1076,7 @@ def load_all(cls, db_file, time_limit=300): except OperationalError as exc: if not str(exc).startswith("no such table:"): raise # pragma: no cover - entries = () + entries = [] for entry in entries: pid = entry[0] @@ -1051,7 +1105,7 @@ def load_all(cls, db_file, time_limit=300): status.last_reports = loads(entry[15]) yield status - def _testcase_size(self): + def _testcase_size(self) -> int: if self._db_file is None: return self._current_size return self._testcase_size_cb() @@ -1112,13 +1166,13 @@ def original(self): def record( self, - name, - duration=None, - iterations=None, - attempts=None, - successes=None, - report=True, - ): + name: str, + duration: Optional[float] = None, + iterations: Optional[int] = None, + attempts: Optional[int] = None, + successes: Optional[int] = None, + report: bool = True, + ) -> None: """Record reduction status for a given point in time: - name of the milestone (eg. init, strategy name completed) @@ -1195,13 +1249,13 @@ def serialize(sub): return _MilestoneTimer() @contextmanager - def measure(self, name, report=True): + def measure(self, name: str, report: bool = True) -> Generator[None, None, None]: """Time and record the period leading up to a reduction milestone. eg. a strategy being run. Arguments: - name (str): name of milestone - report (bool): Automatically force a report. + name: name of milestone + report: Automatically force a report. Yields: None @@ -1222,23 +1276,25 @@ def measure(self, name, report=True): report=report, ) - def copy(self): + def copy(self) -> "ReductionStatus": """Create a deep copy of this instance. Arguments: None Returns: - ReductionStatus: Clone of self + Clone of self """ return deepcopy(self) - def add_to_reporter(self, reporter, expected=True): + def add_to_reporter( + self, reporter: FuzzManagerReporter, expected: bool = True + ) -> None: """Add the reducer status to reported metadata for the given reporter. Arguments: - reporter (FuzzManagerReporter): Reporter to update. - expected (bool): Add detailed stats. + reporter: Reporter to update. + expected: Add detailed stats. Returns: None diff --git a/grizzly/common/status_reporter.py b/grizzly/common/status_reporter.py index 0fe20264..4be96702 100644 --- a/grizzly/common/status_reporter.py +++ b/grizzly/common/status_reporter.py @@ -20,6 +20,7 @@ from re import match from re import sub as re_sub from time import gmtime, localtime, strftime +from typing import List from psutil import cpu_count, cpu_percent, disk_usage, virtual_memory @@ -50,26 +51,28 @@ class StatusReporter: SUMMARY_LIMIT = 4095 # summary output must be no more than 4KB TIME_LIMIT = 120 # ignore older reports - def __init__(self, reports, tracebacks=None): + def __init__(self, reports: List[ReadOnlyStatus], tracebacks=None) -> None: self.reports = reports self.tracebacks = tracebacks @property - def has_results(self): + def has_results(self) -> bool: return any(x.results.total for x in self.reports) @classmethod - def load(cls, db_file, tb_path=None, time_limit=TIME_LIMIT): + def load( + cls, db_file: Path, tb_path: Path = None, time_limit: float = TIME_LIMIT + ) -> "StatusReporter": """Read Grizzly status reports and create a StatusReporter object. Args: - db_file (str): Status data file to load. - tb_path (Path): Directory to scan for files containing Python tracebacks. - time_limit (int): Only include entries with a timestamp that is within the - given number of seconds. Use zero for no limit. + db_file: Status data file to load. + tb_path: Directory to scan for files containing Python tracebacks. + time_limit: Only include entries with a timestamp that is within the + given number of seconds. Use zero for no limit. Returns: - StatusReporter: Contains available status reports and traceback reports. + Available status reports and traceback reports. """ return cls( list(ReadOnlyStatus.load_all(db_file, time_limit=time_limit)), @@ -388,7 +391,9 @@ def _sys_info(): return entries @staticmethod - def _tracebacks(path, ignore_kbi=True, max_preceding=5): + def _tracebacks( + path: Path, ignore_kbi: bool = True, max_preceding: int = 5 + ) -> List[TracebackReport]: """Search screen logs for tracebacks. Args: diff --git a/grizzly/common/test_status.py b/grizzly/common/test_status.py index 6ef65c62..94ef60fc 100644 --- a/grizzly/common/test_status.py +++ b/grizzly/common/test_status.py @@ -38,7 +38,6 @@ def test_basic_status_01(): assert status.ignored == 0 assert status.iteration == 0 assert status.log_size == 0 - assert status.results is None assert not status._profiles assert status.runtime > 0 assert status.rate == 0