| # Copyright (c) 2014 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Script for gathering stats from builder runs.""" |
| |
| from __future__ import division |
| from __future__ import print_function |
| |
| import datetime |
| import re |
| import sys |
| |
| from chromite.cbuildbot import cbuildbot_config |
| from chromite.cbuildbot import metadata_lib |
| from chromite.cbuildbot import constants |
| from chromite.lib import commandline |
| from chromite.lib import cros_build_lib |
| from chromite.lib import gdata_lib |
| from chromite.lib import gs |
| from chromite.lib import table |
| |
| |
| # Useful config targets. |
| CQ_MASTER = constants.CQ_MASTER |
| PFQ_MASTER = constants.PFQ_MASTER |
| CANARY_MASTER = constants.CANARY_MASTER |
| |
| # Bot types |
| CQ = constants.CQ |
| PRE_CQ = constants.PRE_CQ |
| PFQ = constants.PFQ_TYPE |
| CANARY = constants.CANARY_TYPE |
| |
| # Number of parallel processes used when uploading/downloading GS files. |
| MAX_PARALLEL = 40 |
| |
| # Formats we like for output. |
| NICE_DATE_FORMAT = metadata_lib.NICE_DATE_FORMAT |
| NICE_TIME_FORMAT = metadata_lib.NICE_TIME_FORMAT |
| NICE_DATETIME_FORMAT = metadata_lib.NICE_DATETIME_FORMAT |
| |
| # Spreadsheet keys |
| # CQ master and slaves both use the same spreadsheet |
| CQ_SS_KEY = '0AsXDKtaHikmcdElQWVFuT21aMlFXVTN5bVhfQ2ptVFE' |
| PFQ_SS_KEY = '0AhFPeDq6pmwxdDdrYXk3cnJJV05jN3Zja0s5VjFfNlE' |
| CANARY_SS_KEY = '0AhFPeDq6pmwxdDBWM0t3YnYyeVFoM3VaRXNianc1VVE' |
| |
| |
| class GatherStatsError(Exception): |
| """Base exception class for exceptions in this module.""" |
| |
| |
| class DataError(GatherStatsError): |
| """Any exception raised when an error occured while collectring data.""" |
| |
| |
| class SpreadsheetError(GatherStatsError): |
| """Raised when there is a problem with the stats spreadsheet.""" |
| |
| |
| # TODO(dgarrett): Discover list from Json. Will better track slave changes. |
| def _GetSlavesOfMaster(master_target): |
| """Returns list of slave config names for given master config name. |
| |
| Args: |
| master_target: Name of master config target. |
| |
| Returns: |
| List of names of slave config targets. |
| """ |
| master_config = cbuildbot_config.config[master_target] |
| slave_configs = cbuildbot_config.GetSlavesForMaster(master_config) |
| return sorted(slave_config.name for slave_config in slave_configs) |
| |
| |
| class StatsTable(table.Table): |
| """Stats table for any specific target on a waterfall.""" |
| |
| LINK_ROOT = ('https://uberchromegw.corp.google.com/i/%(waterfall)s/builders/' |
| '%(target)s/builds') |
| |
| @staticmethod |
| def _SSHyperlink(link, text): |
| return '=HYPERLINK("%s", "%s")' % (link, text) |
| |
| def __init__(self, target, waterfall, columns): |
| super(StatsTable, self).__init__(columns, target) |
| self.target = target |
| self.waterfall = waterfall |
| |
| def GetBuildLink(self, build_number): |
| target = self.target.replace(' ', '%20') |
| link = self.LINK_ROOT % {'waterfall': self.waterfall, 'target': target} |
| link += '/%s' % build_number |
| return link |
| |
| def GetBuildSSLink(self, build_number): |
| link = self.GetBuildLink(build_number) |
| return self._SSHyperlink(link, 'build %s' % build_number) |
| |
| |
| class SpreadsheetMasterTable(StatsTable): |
| """Stats table for master builder that puts results in a spreadsheet.""" |
| # Bump this number whenever this class adds new data columns, or changes |
| # the values of existing data columns. |
| SHEETS_VERSION = 2 |
| |
| # These must match up with the column names on the spreadsheet. |
| COL_BUILD_NUMBER = 'build number' |
| COL_BUILD_LINK = 'build link' |
| COL_STATUS = 'status' |
| COL_START_DATETIME = 'start datetime' |
| COL_RUNTIME_MINUTES = 'runtime minutes' |
| COL_WEEKDAY = 'weekday' |
| COL_CHROMEOS_VERSION = 'chromeos version' |
| COL_CHROME_VERSION = 'chrome version' |
| COL_FAILED_STAGES = 'failed stages' |
| COL_FAILURE_MESSAGE = 'failure message' |
| |
| # It is required that the ID_COL be an integer value. |
| ID_COL = COL_BUILD_NUMBER |
| |
| COLUMNS = ( |
| COL_BUILD_NUMBER, |
| COL_BUILD_LINK, |
| COL_STATUS, |
| COL_START_DATETIME, |
| COL_RUNTIME_MINUTES, |
| COL_WEEKDAY, |
| COL_CHROMEOS_VERSION, |
| COL_CHROME_VERSION, |
| COL_FAILED_STAGES, |
| COL_FAILURE_MESSAGE, |
| ) |
| |
| def __init__(self, target, waterfall, columns=None): |
| columns = columns or [] |
| columns = list(SpreadsheetMasterTable.COLUMNS) + columns |
| super(SpreadsheetMasterTable, self).__init__(target, |
| waterfall, |
| columns) |
| |
| self._slaves = [] |
| |
| def _CreateAbortedRowDict(self, build_number): |
| """Create a row dict to represent an aborted run of |build_number|.""" |
| return { |
| self.COL_BUILD_NUMBER: str(build_number), |
| self.COL_BUILD_LINK: self.GetBuildSSLink(build_number), |
| self.COL_STATUS: 'aborted', |
| } |
| |
| def AppendGapRow(self, build_number): |
| """Append a row to represent a missing run of |build_number|.""" |
| self.AppendRow(self._CreateAbortedRowDict(build_number)) |
| |
| def AppendBuildRow(self, build_data): |
| """Append a row from the given |build_data|. |
| |
| Args: |
| build_data: A BuildData object. |
| """ |
| # First see if any build number gaps are in the table, and if so fill |
| # them in. This happens when a CQ run is aborted and never writes metadata. |
| num_rows = self.GetNumRows() |
| if num_rows: |
| last_row = self.GetRowByIndex(num_rows - 1) |
| last_build_number = int(last_row[self.COL_BUILD_NUMBER]) |
| |
| for bn in range(build_data.build_number + 1, last_build_number): |
| self.AppendGapRow(bn) |
| |
| row = self._GetBuildRow(build_data) |
| |
| #Use a separate column for each slave. |
| slaves = build_data.slaves |
| for slave_name in slaves: |
| # This adds the slave to our local data, but doesn't add a missing |
| # column to the spreadsheet itself. |
| self._EnsureSlaveKnown(slave_name) |
| |
| # Now add the finished row to this table. |
| self.AppendRow(row) |
| |
| def _GetBuildRow(self, build_data): |
| """Fetch a row dictionary from |build_data| |
| |
| Returns: |
| A dictionary of the form {column_name: value} |
| """ |
| build_number = build_data.build_number |
| build_link = self.GetBuildSSLink(build_number) |
| |
| # For datetime.weekday(), 0==Monday and 6==Sunday. |
| is_weekday = build_data.start_datetime.weekday() in range(5) |
| |
| row = { |
| self.COL_BUILD_NUMBER: str(build_number), |
| self.COL_BUILD_LINK: build_link, |
| self.COL_STATUS: build_data.status, |
| self.COL_START_DATETIME: build_data.start_datetime_str, |
| self.COL_RUNTIME_MINUTES: str(build_data.runtime_minutes), |
| self.COL_WEEKDAY: str(is_weekday), |
| self.COL_CHROMEOS_VERSION: build_data.chromeos_version, |
| self.COL_CHROME_VERSION: build_data.chrome_version, |
| self.COL_FAILED_STAGES: ' '.join(build_data.GetFailedStages()), |
| self.COL_FAILURE_MESSAGE: build_data.failure_message, |
| } |
| |
| slaves = build_data.slaves |
| for slave_name in slaves: |
| slave = slaves[slave_name] |
| slave_url = slave.get('dashboard_url') |
| |
| # For some reason status in slaves uses pass/fail instead of |
| # passed/failed used elsewhere in metadata. |
| translate_dict = {'fail': 'failed', 'pass': 'passed'} |
| slave_status = translate_dict.get(slave['status'], slave['status']) |
| |
| # Bizarrely, dashboard_url is not always set for slaves that pass. |
| # Only sometimes. crbug.com/350939. |
| if slave_url: |
| row[slave_name] = self._SSHyperlink(slave_url, slave_status) |
| else: |
| row[slave_name] = slave_status |
| |
| return row |
| |
| def _EnsureSlaveKnown(self, slave_name): |
| """Ensure that a slave builder name is known. |
| |
| Args: |
| slave_name: The name of the slave builder (aka spreadsheet column name). |
| """ |
| if not self.HasColumn(slave_name): |
| self._slaves.append(slave_name) |
| self.AppendColumn(slave_name) |
| |
| def GetSlaves(self): |
| """Get the list of slave builders which has been discovered so far. |
| |
| This list is only fully populated when all row data has been fully |
| populated. |
| |
| Returns: |
| List of column names for slave builders. |
| """ |
| return self._slaves[:] |
| |
| |
| class PFQMasterTable(SpreadsheetMasterTable): |
| """Stats table for the PFQ Master.""" |
| SS_KEY = PFQ_SS_KEY |
| |
| WATERFALL = 'chromeos' |
| # Must match up with name in waterfall. |
| TARGET = 'x86-generic nightly chromium PFQ' |
| |
| WORKSHEET_NAME = 'PFQMasterData' |
| |
| # Bump this number whenever this class adds new data columns, or changes |
| # the values of existing data columns. |
| SHEETS_VERSION = SpreadsheetMasterTable.SHEETS_VERSION + 1 |
| |
| # These columns are in addition to those inherited from |
| # SpreadsheetMasterTable |
| COLUMNS = () |
| |
| def __init__(self): |
| super(PFQMasterTable, self).__init__(PFQMasterTable.TARGET, |
| PFQMasterTable.WATERFALL, |
| list(PFQMasterTable.COLUMNS)) |
| |
| |
| class CanaryMasterTable(SpreadsheetMasterTable): |
| """Stats table for the Canary Master.""" |
| SS_KEY = PFQ_SS_KEY |
| |
| WATERFALL = 'chromeos' |
| # Must match up with name in waterfall. |
| TARGET = 'Canary master' |
| |
| WORKSHEET_NAME = 'CanaryMasterData' |
| |
| # Bump this number whenever this class adds new data columns, or changes |
| # the values of existing data columns. |
| SHEETS_VERSION = SpreadsheetMasterTable.SHEETS_VERSION |
| |
| # These columns are in addition to those inherited from |
| # SpreadsheetMasterTable |
| COLUMNS = () |
| |
| def __init__(self): |
| super(CanaryMasterTable, self).__init__(CanaryMasterTable.TARGET, |
| CanaryMasterTable.WATERFALL, |
| list(CanaryMasterTable.COLUMNS)) |
| |
| |
| class CQMasterTable(SpreadsheetMasterTable): |
| """Stats table for the CQ Master.""" |
| WATERFALL = 'chromeos' |
| TARGET = 'CQ master' # Must match up with name in waterfall. |
| |
| WORKSHEET_NAME = 'CQMasterData' |
| |
| # Bump this number whenever this class adds new data columns, or changes |
| # the values of existing data columns. |
| SHEETS_VERSION = SpreadsheetMasterTable.SHEETS_VERSION + 2 |
| |
| COL_CL_COUNT = 'cl count' |
| COL_CL_SUBMITTED_COUNT = 'cls submitted' |
| COL_CL_REJECTED_COUNT = 'cls rejected' |
| |
| # These columns are in addition to those inherited from |
| # SpreadsheetMasterTable |
| COLUMNS = ( |
| COL_CL_COUNT, |
| COL_CL_SUBMITTED_COUNT, |
| COL_CL_REJECTED_COUNT, |
| ) |
| |
| def __init__(self): |
| super(CQMasterTable, self).__init__(CQMasterTable.TARGET, |
| CQMasterTable.WATERFALL, |
| list(CQMasterTable.COLUMNS)) |
| |
| def _GetBuildRow(self, build_data): |
| """Fetch a row dictionary from |build_data| |
| |
| Returns: |
| A dictionary of the form {column_name: value} |
| """ |
| row = super(CQMasterTable, self)._GetBuildRow(build_data) |
| row[self.COL_CL_COUNT] = str(build_data.count_changes) |
| |
| cl_actions = [metadata_lib.CLActionTuple(*x) |
| for x in build_data['cl_actions']] |
| submitted_cl_count = len([x for x in cl_actions if |
| x.action == constants.CL_ACTION_SUBMITTED]) |
| rejected_cl_count = len([x for x in cl_actions |
| if x.action == constants.CL_ACTION_KICKED_OUT]) |
| row[self.COL_CL_SUBMITTED_COUNT] = str(submitted_cl_count) |
| row[self.COL_CL_REJECTED_COUNT] = str(rejected_cl_count) |
| return row |
| |
| |
| class SSUploader(object): |
| """Uploads data from table object to Google spreadsheet.""" |
| |
| __slots__ = ( |
| '_creds', # gdata_lib.Creds object |
| '_scomm', # gdata_lib.SpreadsheetComm object |
| 'ss_key', # Spreadsheet key string |
| ) |
| |
| SOURCE = 'Gathered from builder metadata' |
| HYPERLINK_RE = re.compile(r'=HYPERLINK\("[^"]+", "([^"]+)"\)') |
| DATETIME_FORMATS = ('%m/%d/%Y %H:%M:%S', NICE_DATETIME_FORMAT) |
| |
| def __init__(self, creds, ss_key): |
| self._creds = creds |
| self.ss_key = ss_key |
| self._scomm = None |
| |
| @classmethod |
| def _ValsEqual(cls, val1, val2): |
| """Compare two spreadsheet values and return True if they are the same. |
| |
| This is non-trivial because of the automatic changes that Google Sheets |
| does to values. |
| |
| Args: |
| val1: New or old spreadsheet value to compare. |
| val2: New or old spreadsheet value to compare. |
| |
| Returns: |
| True if val1 and val2 are effectively the same, False otherwise. |
| """ |
| # An empty string sent to spreadsheet comes back as None. In any case, |
| # treat two false equivalents as equal. |
| if not (val1 or val2): |
| return True |
| |
| # If only one of the values is set to anything then they are not the same. |
| if bool(val1) != bool(val2): |
| return False |
| |
| # If values are equal then we are done. |
| if val1 == val2: |
| return True |
| |
| # Ignore case differences. This is because, for example, the |
| # spreadsheet automatically changes "True" to "TRUE". |
| if val1 and val2 and val1.lower() == val2.lower(): |
| return True |
| |
| # If either value is a HYPERLINK, then extract just the text for comparison |
| # because that is all the spreadsheet says the value is. |
| match = cls.HYPERLINK_RE.search(val1) |
| if match: |
| return match.group(1) == val2 |
| match = cls.HYPERLINK_RE.search(val2) |
| if match: |
| return match.group(2) == val1 |
| |
| # See if the strings are two different representations of the same datetime. |
| dt1, dt2 = None, None |
| for dt_format in cls.DATETIME_FORMATS: |
| try: |
| dt1 = datetime.datetime.strptime(val1, dt_format) |
| except ValueError: |
| pass |
| try: |
| dt2 = datetime.datetime.strptime(val2, dt_format) |
| except ValueError: |
| pass |
| if dt1 and dt2 and dt1 == dt2: |
| return True |
| |
| # If we get this far then the values are just different. |
| return False |
| |
| def _Connect(self, ws_name): |
| """Establish connection to specific worksheet. |
| |
| Args: |
| ws_name: Worksheet name. |
| """ |
| if self._scomm: |
| self._scomm.SetCurrentWorksheet(ws_name) |
| else: |
| self._scomm = gdata_lib.SpreadsheetComm() |
| self._scomm.Connect(self._creds, self.ss_key, ws_name, source=self.SOURCE) |
| |
| def GetRowCacheByCol(self, ws_name, key): |
| """Fetch the row cache with id=|key|.""" |
| self._Connect(ws_name) |
| ss_key = gdata_lib.PrepColNameForSS(key) |
| return self._scomm.GetRowCacheByCol(ss_key) |
| |
| def _EnsureColumnsExist(self, data_columns): |
| """Ensures that |data_columns| exist in current spreadsheet worksheet. |
| |
| Assumes spreadsheet worksheet is already connected. |
| |
| Raises: |
| SpreadsheetError if any column in |data_columns| is missing from |
| the spreadsheet's current worksheet. |
| """ |
| ss_cols = self._scomm.GetColumns() |
| |
| # Make sure all columns in data_table are supported in spreadsheet. |
| missing_cols = [c for c in data_columns |
| if gdata_lib.PrepColNameForSS(c) not in ss_cols] |
| if missing_cols: |
| raise SpreadsheetError('Spreadsheet missing column(s): %s' % |
| ', '.join(missing_cols)) |
| |
| def UploadColumnToWorksheet(self, ws_name, colIx, data): |
| """Upload list |data| to column number |colIx| in worksheet |ws_name|. |
| |
| This will overwrite any existing data in that column. |
| """ |
| self._Connect(ws_name) |
| self._scomm.WriteColumnToWorksheet(colIx, data) |
| |
| def UploadSequentialRows(self, ws_name, data_table): |
| """Upload |data_table| to the |ws_name| worksheet of sheet at self.ss_key. |
| |
| Data will be uploaded row-by-row in ascending ID_COL order. Missing |
| values of ID_COL will be filled in by filler rows. |
| |
| Args: |
| ws_name: Worksheet name for identifying worksheet within spreadsheet. |
| data_table: table.Table object with rows to upload to worksheet. |
| """ |
| self._Connect(ws_name) |
| cros_build_lib.Info('Uploading stats rows to worksheet "%s" of spreadsheet' |
| ' "%s" now.', self._scomm.ws_name, self._scomm.ss_key) |
| |
| cros_build_lib.Debug('Getting cache of current spreadsheet contents.') |
| id_col = data_table.ID_COL |
| ss_id_col = gdata_lib.PrepColNameForSS(id_col) |
| ss_row_cache = self._scomm.GetRowCacheByCol(ss_id_col) |
| |
| self._EnsureColumnsExist(data_table.GetColumns()) |
| |
| # First see if a build_number is being skipped. Allow the data_table to |
| # add default (filler) rows if it wants to. These rows may represent |
| # aborted runs, for example. ID_COL is assumed to hold integers. |
| first_id_val = int(data_table[-1][id_col]) |
| prev_id_val = first_id_val - 1 |
| while str(prev_id_val) not in ss_row_cache: |
| data_table.AppendGapRow(prev_id_val) |
| prev_id_val -= 1 |
| # Sanity check that we have not created an infinite loop. |
| assert prev_id_val >= 0 |
| |
| # Spreadsheet is organized with oldest runs at the top and newest runs |
| # at the bottom (because there is no interface for inserting new rows at |
| # the top). This is the reverse of data_table, so start at the end. |
| for row in data_table[::-1]: |
| row_dict = dict((gdata_lib.PrepColNameForSS(key), row[key]) |
| for key in row) |
| |
| # See if row with the same id_col value already exists. |
| id_val = row[id_col] |
| ss_row = ss_row_cache.get(id_val) |
| |
| try: |
| if ss_row: |
| # Row already exists in spreadsheet. See if contents any different. |
| # Create dict representing values in row_dict different from ss_row. |
| row_delta = dict((k, v) for k, v in row_dict.iteritems() |
| if not self._ValsEqual(v, ss_row[k])) |
| if row_delta: |
| cros_build_lib.Debug('Updating existing spreadsheet row for %s %s.', |
| id_col, id_val) |
| self._scomm.UpdateRowCellByCell(ss_row.ss_row_num, row_delta) |
| else: |
| cros_build_lib.Debug('Unchanged existing spreadsheet row for' |
| ' %s %s.', id_col, id_val) |
| else: |
| cros_build_lib.Debug('Adding spreadsheet row for %s %s.', |
| id_col, id_val) |
| self._scomm.InsertRow(row_dict) |
| except gdata_lib.SpreadsheetError as e: |
| cros_build_lib.Error('Failure while uploading spreadsheet row for' |
| ' %s %s with data %s. Error: %s.', id_col, id_val, |
| row_dict, e) |
| |
| |
| class StatsManager(object): |
| """Abstract class for managing stats for one config target. |
| |
| Subclasses should specify the config target by passing them in to __init__. |
| |
| This class handles the following duties: |
| 1) Read a bunch of metadata.json URLs for the config target that are |
| are no older than the given start date. |
| 2) Upload data to a Google Sheet, if specified by the subclass. |
| """ |
| # Subclasses can overwrite any of these. |
| TABLE_CLASS = None |
| UPLOAD_ROW_PER_BUILD = False |
| # To be overridden by subclass. A dictionary mapping a |key| from |
| # self.summary to (ws_name, colIx) tuples from the spreadsheet which |
| # should be overwritten with the data from the self.summary[key] |
| SUMMARY_SPREADSHEET_COLUMNS = {} |
| BOT_TYPE = None |
| |
| # Whether to grab a count of what data has been written to sheets before. |
| # This is needed if you are writing data to the Google Sheets spreadsheet. |
| GET_SHEETS_VERSION = True |
| |
| def __init__(self, config_target, ss_key=None, |
| no_sheets_version_filter=False): |
| self.builds = [] |
| self.gs_ctx = gs.GSContext() |
| self.config_target = config_target |
| self.ss_key = ss_key |
| self.no_sheets_version_filter = no_sheets_version_filter |
| self.summary = {} |
| self.actions = None |
| |
| |
| # pylint: disable=W0613 |
| def Gather(self, start_date, end_date, sort_by_build_number=True, |
| starting_build_number=0, creds=None): |
| """Fetches build data into self.builds. |
| |
| Args: |
| start_date: A datetime.date instance for the earliest build to |
| examine. |
| end_date: A datetime.date instance for the latest build to |
| examine. |
| sort_by_build_number: Optional boolean. If True, builds will be |
| sorted by build number. |
| starting_build_number: The lowest build number to include in |
| self.builds. |
| creds: Login credentials as returned by PrepareCreds. (optional) |
| """ |
| self.builds = self._FetchBuildData(start_date, end_date, self.config_target, |
| self.gs_ctx) |
| |
| if sort_by_build_number: |
| # Sort runs by build_number, from newest to oldest. |
| cros_build_lib.Info('Sorting by build number now.') |
| self.builds = sorted(self.builds, key=lambda b: b.build_number, |
| reverse=True) |
| if starting_build_number: |
| cros_build_lib.Info('Filtering to include builds after %s (inclusive).', |
| starting_build_number) |
| self.builds = [b for b in self.builds |
| if b.build_number >= starting_build_number] |
| |
| @classmethod |
| def _FetchBuildData(cls, start_date, end_date, config_target, gs_ctx): |
| """Fetches BuildData for builds of |config_target| since |start_date|. |
| |
| Args: |
| start_date: A datetime.date instance. |
| end_date: A datetime.date instance for the latest build to |
| examine. |
| config_target: String config name to fetch metadata for. |
| gs_ctx: A gs.GSContext instance. |
| |
| Returns: |
| A list of of metadata_lib.BuildData objects that were fetched. |
| """ |
| cros_build_lib.Info('Gathering data for %s from %s until %s', |
| config_target, start_date, end_date) |
| urls = metadata_lib.GetMetadataURLsSince(config_target, |
| start_date, |
| end_date) |
| cros_build_lib.Info('Found %d metadata.json URLs to process.\n' |
| ' From: %s\n To : %s', len(urls), urls[0], urls[-1]) |
| |
| builds = metadata_lib.BuildData.ReadMetadataURLs( |
| urls, gs_ctx, get_sheets_version=cls.GET_SHEETS_VERSION) |
| cros_build_lib.Info('Read %d total metadata files.', len(builds)) |
| return builds |
| |
| # TODO(akeshet): Return statistics in dictionary rather than just printing |
| # them. |
| def Summarize(self): |
| """Process and print a summary of statistics. |
| |
| Returns: |
| An empty dictionary. Note: subclasses can extend this method and return |
| non-empty dictionaries, with summarized statistics. |
| """ |
| if self.builds: |
| cros_build_lib.Info('%d total runs included, from build %d to %d.', |
| len(self.builds), self.builds[-1].build_number, |
| self.builds[0].build_number) |
| total_passed = len([b for b in self.builds if b.Passed()]) |
| cros_build_lib.Info('%d of %d runs passed.', total_passed, |
| len(self.builds)) |
| else: |
| cros_build_lib.Info('No runs included.') |
| return {} |
| |
| @property |
| def sheets_version(self): |
| if self.TABLE_CLASS: |
| return self.TABLE_CLASS.SHEETS_VERSION |
| |
| return -1 |
| |
| def UploadToSheet(self, creds): |
| assert creds |
| |
| if self.UPLOAD_ROW_PER_BUILD: |
| self._UploadBuildsToSheet(creds) |
| |
| if self.SUMMARY_SPREADSHEET_COLUMNS: |
| self._UploadSummaryColumns(creds) |
| |
| def _UploadBuildsToSheet(self, creds): |
| """Upload row-per-build data to adsheet.""" |
| if not self.TABLE_CLASS: |
| cros_build_lib.Debug('No Spreadsheet uploading configured for %s.', |
| self.config_target) |
| return |
| |
| # Filter for builds that need to send data to Sheets (unless overridden |
| # by command line flag. |
| if self.no_sheets_version_filter: |
| builds = self.builds |
| else: |
| version = self.sheets_version |
| builds = [b for b in self.builds if b.sheets_version < version] |
| cros_build_lib.Info('Found %d builds that need to send Sheets v%d data.', |
| len(builds), version) |
| |
| if builds: |
| # Fill a data table of type table_class from self.builds. |
| # pylint: disable=E1102 |
| data_table = self.TABLE_CLASS() |
| for build in builds: |
| try: |
| data_table.AppendBuildRow(build) |
| except Exception: |
| cros_build_lib.Error('Failed to add row for builder_number %s to' |
| ' table. It came from %s.', build.build_number, |
| build.metadata_url) |
| raise |
| |
| # Upload data table to sheet. |
| uploader = SSUploader(creds, self.ss_key) |
| uploader.UploadSequentialRows(data_table.WORKSHEET_NAME, data_table) |
| |
| def _UploadSummaryColumns(self, creds): |
| """Overwrite summary columns in spreadsheet with appropriate data.""" |
| # Upload data table to sheet. |
| uploader = SSUploader(creds, self.ss_key) |
| for key, (ws_name, colIx) in self.SUMMARY_SPREADSHEET_COLUMNS.iteritems(): |
| uploader.UploadColumnToWorksheet(ws_name, colIx, self.summary[key]) |
| |
| def MarkGathered(self): |
| """Mark each metadata.json in self.builds as processed. |
| |
| Applies only to StatsManager subclasses that have UPLOAD_ROW_PER_BUILD |
| True, as these do not want data from a given build to be re-uploaded. |
| """ |
| if self.UPLOAD_ROW_PER_BUILD: |
| metadata_lib.BuildData.MarkBuildsGathered(self.builds, |
| self.sheets_version, |
| gs_ctx=self.gs_ctx) |
| |
| |
| # TODO(mtennant): This class is an untested placeholder. |
| class CQSlaveStats(StatsManager): |
| """Stats manager for all CQ slaves.""" |
| # TODO(mtennant): Add Sheets support for each CQ slave. |
| TABLE_CLASS = None |
| GET_SHEETS_VERSION = True |
| |
| def __init__(self, slave_target, **kwargs): |
| super(CQSlaveStats, self).__init__(slave_target, **kwargs) |
| |
| |
| class CQMasterStats(StatsManager): |
| """Manager stats gathering for the Commit Queue Master.""" |
| TABLE_CLASS = CQMasterTable |
| UPLOAD_ROW_PER_BUILD = True |
| BOT_TYPE = CQ |
| GET_SHEETS_VERSION = True |
| |
| def __init__(self, **kwargs): |
| super(CQMasterStats, self).__init__(CQ_MASTER, **kwargs) |
| |
| |
| class PFQMasterStats(StatsManager): |
| """Manager stats gathering for the PFQ Master.""" |
| TABLE_CLASS = PFQMasterTable |
| UPLOAD_ROW_PER_BUILD = True |
| BOT_TYPE = PFQ |
| GET_SHEETS_VERSION = True |
| |
| def __init__(self, **kwargs): |
| super(PFQMasterStats, self).__init__(PFQ_MASTER, **kwargs) |
| |
| |
| class CanaryMasterStats(StatsManager): |
| """Manager stats gathering for the Canary Master.""" |
| TABLE_CLASS = CanaryMasterTable |
| UPLOAD_ROW_PER_BUILD = True |
| BOT_TYPE = CANARY |
| GET_SHEETS_VERSION = True |
| |
| def __init__(self, **kwargs): |
| super(CanaryMasterStats, self).__init__(CANARY_MASTER, **kwargs) |
| |
| |
| # TODO(mtennant): Add token file support. See upload_package_status.py. |
| def PrepareCreds(email, password=None): |
| """Return a gdata_lib.Creds object from given credentials. |
| |
| Args: |
| email: Email address. |
| password: Password string. If not specified then a password |
| prompt will be used. |
| |
| Returns: |
| A gdata_lib.Creds object. |
| """ |
| creds = gdata_lib.Creds() |
| creds.SetCreds(email, password) |
| return creds |
| |
| |
| def _CheckOptions(options): |
| # Ensure that specified start date is in the past. |
| now = datetime.datetime.now() |
| if options.start_date and now.date() < options.start_date: |
| cros_build_lib.Error('Specified start date is in the future: %s', |
| options.start_date) |
| return False |
| |
| # The --save option requires --email. |
| if options.save and not options.email: |
| cros_build_lib.Error('You must specify --email with --save.') |
| return False |
| |
| return True |
| |
| |
| def GetParser(): |
| """Creates the argparse parser.""" |
| parser = commandline.ArgumentParser(description=__doc__) |
| |
| # Put options that control the mode of script into mutually exclusive group. |
| mode = parser.add_mutually_exclusive_group(required=True) |
| mode.add_argument('--cq-master', action='store_true', default=False, |
| help='Gather stats for the CQ master.') |
| mode.add_argument('--pfq-master', action='store_true', default=False, |
| help='Gather stats for the PFQ master.') |
| mode.add_argument('--canary-master', action='store_true', default=False, |
| help='Gather stats for the Canary master.') |
| mode.add_argument('--cq-slaves', action='store_true', default=False, |
| help='Gather stats for all CQ slaves.') |
| # TODO(mtennant): Other modes as they make sense, like maybe --release. |
| |
| mode = parser.add_mutually_exclusive_group(required=True) |
| mode.add_argument('--start-date', action='store', type='date', default=None, |
| help='Limit scope to a start date in the past.') |
| mode.add_argument('--past-month', action='store_true', default=False, |
| help='Limit scope to the past 30 days up to now.') |
| mode.add_argument('--past-week', action='store_true', default=False, |
| help='Limit scope to the past week up to now.') |
| mode.add_argument('--past-day', action='store_true', default=False, |
| help='Limit scope to the past day up to now.') |
| |
| parser.add_argument('--starting-build', action='store', type=int, default=0, |
| help='Filter to builds after given number (inclusive).') |
| parser.add_argument('--end-date', action='store', type='date', default=None, |
| help='Limit scope to an end date in the past.') |
| parser.add_argument('--save', action='store_true', default=False, |
| help='Save results to DB, if applicable.') |
| parser.add_argument('--email', action='store', type=str, default=None, |
| help='Specify email for Google Sheets account to use.') |
| |
| mode = parser.add_argument_group('Advanced (use at own risk)') |
| mode.add_argument('--no-upload', action='store_false', default=True, |
| dest='upload', |
| help='Skip uploading results to spreadsheet') |
| mode.add_argument('--no-mark-gathered', action='store_false', default=True, |
| dest='mark_gathered', |
| help='Skip marking results as gathered.') |
| mode.add_argument('--no-sheets-version-filter', action='store_true', |
| default=False, |
| help='Upload all parsed metadata to spreasheet regardless ' |
| 'of sheets version.') |
| mode.add_argument('--override-ss-key', action='store', default=None, |
| dest='ss_key', |
| help='Override spreadsheet key.') |
| |
| return parser |
| |
| |
| def main(argv): |
| parser = GetParser() |
| options = parser.parse_args(argv) |
| |
| if not _CheckOptions(options): |
| sys.exit(1) |
| |
| if options.end_date: |
| end_date = options.end_date |
| else: |
| end_date = datetime.datetime.now().date() |
| |
| # Determine the start date to use, which is required. |
| if options.start_date: |
| start_date = options.start_date |
| else: |
| assert options.past_month or options.past_week or options.past_day |
| if options.past_month: |
| start_date = end_date - datetime.timedelta(days=30) |
| elif options.past_week: |
| start_date = end_date - datetime.timedelta(days=7) |
| else: |
| start_date = end_date - datetime.timedelta(days=1) |
| |
| # Prepare the rounds of stats gathering to do. |
| stats_managers = [] |
| |
| if options.cq_master: |
| stats_managers.append( |
| CQMasterStats( |
| ss_key=options.ss_key or CQ_SS_KEY, |
| no_sheets_version_filter=options.no_sheets_version_filter)) |
| |
| if options.pfq_master: |
| stats_managers.append( |
| PFQMasterStats( |
| ss_key=options.ss_key or PFQ_SS_KEY, |
| no_sheets_version_filter=options.no_sheets_version_filter)) |
| |
| if options.canary_master: |
| stats_managers.append( |
| CanaryMasterStats( |
| ss_key=options.ss_key or CANARY_SS_KEY, |
| no_sheets_version_filter=options.no_sheets_version_filter)) |
| |
| if options.cq_slaves: |
| targets = _GetSlavesOfMaster(CQ_MASTER) |
| for target in targets: |
| # TODO(mtennant): Add spreadsheet support for cq-slaves. |
| stats_managers.append(CQSlaveStats(target)) |
| |
| # If options.save is set and any of the instructions include a table class, |
| # or specify summary columns for upload, prepare spreadsheet creds object |
| # early. |
| creds = None |
| if options.save and any((stats.UPLOAD_ROW_PER_BUILD or |
| stats.SUMMARY_SPREADSHEET_COLUMNS) |
| for stats in stats_managers): |
| # TODO(mtennant): See if this can work with two-factor authentication. |
| # TODO(mtennant): Eventually, we probably want to use 90-day certs to |
| # run this as a cronjob on a ganeti instance. |
| creds = PrepareCreds(options.email) |
| |
| # Now run through all the stats gathering that is requested. |
| for stats_mgr in stats_managers: |
| stats_mgr.Gather(start_date, end_date, |
| starting_build_number=options.starting_build, |
| creds=creds) |
| stats_mgr.Summarize() |
| |
| if options.save: |
| # Send data to spreadsheet, if applicable. |
| if options.upload: |
| stats_mgr.UploadToSheet(creds) |
| |
| # Mark these metadata.json files as processed. |
| if options.mark_gathered: |
| stats_mgr.MarkGathered() |
| |
| cros_build_lib.Info('Finished with %s.\n\n', stats_mgr.config_target) |