PageRenderTime 115ms CodeModel.GetById 27ms RepoModel.GetById 0ms app.codeStats 0ms

/lib/gdata_lib.py

https://gitlab.com/github-cloud-corporation/chromite
Python | 680 lines | 661 code | 9 blank | 10 comment | 3 complexity | 3df56eb2ac240c347647008115e73265 MD5 | raw file
  1. #!/usr/bin/python
  2. # Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """Library for interacting with gdata (i.e. Google Docs, Tracker, etc)."""
  6. import functools
  7. import getpass
  8. import os
  9. import pickle
  10. import re
  11. import urllib
  12. import xml.dom.minidom
  13. # pylint: disable=W0404
  14. import gdata.projecthosting.client
  15. import gdata.service
  16. import gdata.spreadsheet.service
  17. from chromite.lib import operation
  18. # pylint: disable=W0201,E0203
  19. TOKEN_FILE = os.path.join(os.environ['HOME'], '.gdata_token')
  20. CRED_FILE = os.path.join(os.environ['HOME'], '.gdata_cred.txt')
  21. oper = operation.Operation('gdata_lib')
  22. _BAD_COL_CHARS_REGEX = re.compile(r'[ /]')
  23. def PrepColNameForSS(col):
  24. """Translate a column name for spreadsheet interface."""
  25. # Spreadsheet interface requires column names to be
  26. # all lowercase and with no spaces or other special characters.
  27. return _BAD_COL_CHARS_REGEX.sub('', col.lower())
  28. # TODO(mtennant): Rename PrepRowValuesForSS
  29. def PrepRowForSS(row):
  30. """Make sure spreadsheet handles all values in row as strings."""
  31. return dict((key, PrepValForSS(val)) for key, val in row.items())
  32. # Regex to detect values that the spreadsheet will auto-format as numbers.
  33. _NUM_REGEX = re.compile(r'^[\d\.]+$')
  34. def PrepValForSS(val):
  35. """Make sure spreadsheet handles this value as a string."""
  36. if val and _NUM_REGEX.match(val):
  37. return "'" + val
  38. return val
  39. def ScrubValFromSS(val):
  40. """Remove string indicator prefix if found."""
  41. if val and val[0] == "'":
  42. return val[1:]
  43. return val
  44. class Creds(object):
  45. """Class to manage user/password credentials."""
  46. __slots__ = (
  47. 'docs_auth_token', # Docs Client auth token string
  48. 'creds_dirty', # True if user/password set and not, yet, saved
  49. 'password', # User password
  50. 'token_dirty', # True if auth token(s) set and not, yet, saved
  51. 'tracker_auth_token', # Tracker Client auth token string
  52. 'user', # User account (foo@chromium.org)
  53. )
  54. SAVED_TOKEN_ATTRS = ('docs_auth_token', 'tracker_auth_token', 'user')
  55. def __init__(self):
  56. self.user = None
  57. self.password = None
  58. self.docs_auth_token = None
  59. self.tracker_auth_token = None
  60. self.token_dirty = False
  61. self.creds_dirty = False
  62. def SetDocsAuthToken(self, auth_token):
  63. """Set the Docs auth_token string."""
  64. self.docs_auth_token = auth_token
  65. self.token_dirty = True
  66. def SetTrackerAuthToken(self, auth_token):
  67. """Set the Tracker auth_token string."""
  68. self.tracker_auth_token = auth_token
  69. self.token_dirty = True
  70. def LoadAuthToken(self, filepath):
  71. """Load previously saved auth token(s) from |filepath|.
  72. This first clears both docs_auth_token and tracker_auth_token.
  73. """
  74. self.docs_auth_token = None
  75. self.tracker_auth_token = None
  76. try:
  77. f = open(filepath, 'r')
  78. obj = pickle.load(f)
  79. f.close()
  80. if obj.has_key('auth_token'):
  81. # Backwards compatability. Default 'auth_token' is what
  82. # docs_auth_token used to be saved as.
  83. self.docs_auth_token = obj['auth_token']
  84. self.token_dirty = True
  85. for attr in self.SAVED_TOKEN_ATTRS:
  86. if obj.has_key(attr):
  87. setattr(self, attr, obj[attr])
  88. oper.Notice('Loaded Docs/Tracker auth token(s) from "%s"' % filepath)
  89. except IOError:
  90. oper.Error('Unable to load auth token file at "%s"' % filepath)
  91. def StoreAuthTokenIfNeeded(self, filepath):
  92. """Store auth token(s) to |filepath| if anything changed."""
  93. if self.token_dirty:
  94. self.StoreAuthToken(filepath)
  95. def StoreAuthToken(self, filepath):
  96. """Store auth token(s) to |filepath|."""
  97. obj = {}
  98. for attr in self.SAVED_TOKEN_ATTRS:
  99. val = getattr(self, attr)
  100. if val:
  101. obj[attr] = val
  102. try:
  103. oper.Notice('Storing Docs and/or Tracker auth token to "%s"' % filepath)
  104. f = open(filepath, 'w')
  105. pickle.dump(obj, f)
  106. f.close()
  107. self.token_dirty = False
  108. except IOError:
  109. oper.Error('Unable to store auth token to file at "%s"' % filepath)
  110. def SetCreds(self, user, password=None):
  111. if not user.endswith('@chromium.org'):
  112. user = '%s@chromium.org' % user
  113. if not password:
  114. password = getpass.getpass('Tracker password for %s:' % user)
  115. self.user = user
  116. self.password = password
  117. self.creds_dirty = True
  118. def LoadCreds(self, filepath):
  119. """Load email/password credentials from |filepath|."""
  120. # Read email from first line and password from second.
  121. with open(filepath, 'r') as f:
  122. (self.user, self.password) = (l.strip() for l in f.readlines())
  123. oper.Notice('Loaded Docs/Tracker login credentials from "%s"' % filepath)
  124. def StoreCredsIfNeeded(self, filepath):
  125. """Store email/password credentials to |filepath| if anything changed."""
  126. if self.creds_dirty:
  127. self.StoreCreds(filepath)
  128. def StoreCreds(self, filepath):
  129. """Store email/password credentials to |filepath|."""
  130. oper.Notice('Storing Docs/Tracker login credentials to "%s"' % filepath)
  131. # Simply write email on first line and password on second.
  132. with open(filepath, 'w') as f:
  133. f.write(self.user + '\n')
  134. f.write(self.password + '\n')
  135. self.creds_dirty = False
  136. class IssueComment(object):
  137. """Represent a Tracker issue comment."""
  138. __slots__ = ['title', 'text']
  139. def __init__(self, title, text):
  140. self.title = title
  141. self.text = text
  142. def __str__(self):
  143. text = '<no comment>'
  144. if self.text:
  145. text = '\n '.join(self.text.split('\n'))
  146. return '%s:\n %s' % (self.title, text)
  147. class Issue(object):
  148. """Represents one Tracker Issue."""
  149. SlotDefaults = {
  150. 'comments': [], # List of IssueComment objects
  151. 'id': 0, # Issue id number (int)
  152. 'labels': [], # List of text labels
  153. 'owner': None, # Current owner (text, chromium.org account)
  154. 'status': None, # Current issue status (text) (e.g. Assigned)
  155. 'summary': None,# Issue summary (first comment)
  156. 'title': None, # Title text
  157. }
  158. __slots__ = SlotDefaults.keys()
  159. def __init__(self, **kwargs):
  160. """Init for one Issue object.
  161. |kwargs| - key/value arguments to give initial values to
  162. any additional attributes on |self|.
  163. """
  164. # Use SlotDefaults overwritten by kwargs for starting slot values.
  165. slotvals = self.SlotDefaults.copy()
  166. slotvals.update(kwargs)
  167. for slot in self.__slots__:
  168. setattr(self, slot, slotvals.pop(slot))
  169. if slotvals:
  170. raise ValueError('I do not know what to do with %r' % slotvals)
  171. def __str__(self):
  172. """Pretty print of issue."""
  173. lines = ['Issue %d - %s' % (self.id, self.title),
  174. 'Status: %s, Owner: %s' % (self.status, self.owner),
  175. 'Labels: %s' % ', '.join(self.labels),
  176. ]
  177. if self.summary:
  178. lines.append('Summary: %s' % self.summary)
  179. if self.comments:
  180. lines.extend(self.comments)
  181. return '\n'.join(lines)
  182. def InitFromTracker(self, t_issue, project_name):
  183. """Initialize |self| from tracker issue |t_issue|"""
  184. self.id = int(t_issue.id.text.split('/')[-1])
  185. self.labels = [label.text for label in t_issue.label]
  186. if t_issue.owner:
  187. self.owner = t_issue.owner.username.text
  188. self.status = t_issue.status.text
  189. self.summary = t_issue.content.text
  190. self.title = t_issue.title.text
  191. self.comments = self.GetTrackerIssueComments(self.id, project_name)
  192. def GetTrackerIssueComments(self, issue_id, project_name):
  193. """Retrieve comments for |issue_id| from comments URL"""
  194. comments = []
  195. feeds = 'http://code.google.com/feeds'
  196. url = '%s/issues/p/%s/issues/%d/comments/full' % (feeds, project_name,
  197. issue_id)
  198. doc = xml.dom.minidom.parse(urllib.urlopen(url))
  199. entries = doc.getElementsByTagName('entry')
  200. for entry in entries:
  201. title_text_list = []
  202. for key in ('title', 'content'):
  203. child = entry.getElementsByTagName(key)[0].firstChild
  204. title_text_list.append(child.nodeValue if child else None)
  205. comments.append(IssueComment(*title_text_list))
  206. return comments
  207. def __eq__(self, other):
  208. return (self.id == other.id and self.labels == other.labels and
  209. self.owner == other.owner and self.status == other.status and
  210. self.summary == other.summary and self.title == other.title)
  211. def __ne__(self, other):
  212. return not self == other
  213. class TrackerError(RuntimeError):
  214. """Error class for tracker communication errors."""
  215. class TrackerInvalidUserError(TrackerError):
  216. """Error class for when user not recognized by Tracker."""
  217. class TrackerComm(object):
  218. """Class to manage communication with Tracker."""
  219. __slots__ = (
  220. 'author', # Author when creating/editing Tracker issues
  221. 'it_client', # Issue Tracker client
  222. 'project_name', # Tracker project name
  223. )
  224. def __init__(self):
  225. self.author = None
  226. self.it_client = None
  227. self.project_name = None
  228. def Connect(self, creds, project_name, source='chromiumos'):
  229. self.project_name = project_name
  230. it_client = gdata.projecthosting.client.ProjectHostingClient()
  231. it_client.source = source
  232. if creds.tracker_auth_token:
  233. oper.Notice('Logging into Tracker using previous auth token.')
  234. it_client.auth_token = gdata.gauth.ClientLoginToken(
  235. creds.tracker_auth_token)
  236. else:
  237. oper.Notice('Logging into Tracker as "%s".' % creds.user)
  238. it_client.ClientLogin(creds.user, creds.password,
  239. source=source, service='code',
  240. account_type='GOOGLE')
  241. creds.SetTrackerAuthToken(it_client.auth_token.token_string)
  242. self.author = creds.user
  243. self.it_client = it_client
  244. def _QueryTracker(self, query):
  245. """Query the tracker for a list of issues. Return |None| on failure."""
  246. try:
  247. return self.it_client.get_issues(self.project_name, query=query)
  248. except gdata.client.RequestError:
  249. return None
  250. def _CreateIssue(self, t_issue):
  251. """Create an Issue from a Tracker Issue."""
  252. issue = Issue()
  253. issue.InitFromTracker(t_issue, self.project_name)
  254. return issue
  255. # TODO(mtennant): This method works today, but is not being actively used.
  256. # Leaving it in, because a logical use of the method is for to verify
  257. # that a Tracker issue in the package spreadsheet is open, and to add
  258. # comments to it when new upstream versions become available.
  259. def GetTrackerIssueById(self, tid):
  260. """Get tracker issue given |tid| number. Return Issue object if found."""
  261. query = gdata.projecthosting.client.Query(issue_id=str(tid))
  262. feed = self._QueryTracker(query)
  263. if feed.entry:
  264. return self._CreateIssue(feed.entry[0])
  265. return None
  266. def GetTrackerIssuesByText(self, search_text, full_text=True,
  267. only_open=True):
  268. """Find all Tracker Issues that contain the text search_text."""
  269. if not full_text:
  270. search_text = 'summary:"%s"' % search_text
  271. if only_open:
  272. search_text += ' is:open'
  273. query = gdata.projecthosting.client.Query(text_query=search_text)
  274. feed = self._QueryTracker(query)
  275. if feed:
  276. return [self._CreateIssue(tissue) for tissue in feed.entry]
  277. else:
  278. return []
  279. def CreateTrackerIssue(self, issue):
  280. """Create a new issue in Tracker according to |issue|."""
  281. try:
  282. created = self.it_client.add_issue(project_name=self.project_name,
  283. title=issue.title,
  284. content=issue.summary,
  285. author=self.author,
  286. status=issue.status,
  287. owner=issue.owner,
  288. labels=issue.labels)
  289. issue.id = int(created.id.text.split('/')[-1])
  290. return issue.id
  291. except gdata.client.RequestError as ex:
  292. if ex.body and ex.body.lower() == 'user not found':
  293. raise TrackerInvalidUserError('Tracker user %s not found' % issue.owner)
  294. raise
  295. def AppendTrackerIssueById(self, issue_id, comment):
  296. """Append |comment| to issue |issue_id| in Tracker"""
  297. self.it_client.update_issue(project_name=self.project_name,
  298. issue_id=issue_id,
  299. author=self.author,
  300. comment=comment)
  301. return issue_id
  302. class SpreadsheetRow(dict):
  303. """Minor semi-immutable extension of dict to keep the original spreadsheet
  304. row object and spreadsheet row number as attributes.
  305. No changes are made to equality checking or anything else, so client code
  306. that wishes to handle this as a pure dict can.
  307. """
  308. def __init__(self, ss_row_obj, ss_row_num, mapping=None):
  309. if mapping:
  310. dict.__init__(self, mapping)
  311. self.ss_row_obj = ss_row_obj
  312. self.ss_row_num = ss_row_num
  313. def __setitem__(self, key, val):
  314. raise TypeError('setting item in SpreadsheetRow not supported')
  315. def __delitem__(self, key):
  316. raise TypeError('deleting item in SpreadsheetRow not supported')
  317. class SpreadsheetError(RuntimeError):
  318. """Error class for spreadsheet communication errors."""
  319. def ReadWriteDecorator(func):
  320. """Raise SpreadsheetError if appropriate."""
  321. def f(self, *args, **kwargs):
  322. try:
  323. return func(self, *args, **kwargs)
  324. except gdata.service.RequestError as ex:
  325. raise SpreadsheetError(str(ex))
  326. f.__name__ = func.__name__
  327. return f
  328. class SpreadsheetComm(object):
  329. """Class to manage communication with one Google Spreadsheet worksheet."""
  330. # Row numbering in spreadsheets effectively starts at 2 because row 1
  331. # has the column headers.
  332. ROW_NUMBER_OFFSET = 2
  333. # Spreadsheet column numbers start at 1.
  334. COLUMN_NUMBER_OFFSET = 1
  335. __slots__ = (
  336. '_columns', # Tuple of translated column names, filled in as needed
  337. '_rows', # Tuple of Row dicts in order, filled in as needed
  338. 'gd_client', # Google Data client
  339. 'ss_key', # Spreadsheet key
  340. 'ws_name', # Worksheet name
  341. 'ws_key', # Worksheet key
  342. )
  343. @property
  344. def columns(self):
  345. """The columns property is filled in on demand.
  346. It is a tuple of column names, each run through PrepColNameForSS.
  347. """
  348. if self._columns is None:
  349. query = gdata.spreadsheet.service.CellQuery()
  350. query['max-row'] = '1'
  351. feed = self.gd_client.GetCellsFeed(self.ss_key, self.ws_key, query=query)
  352. # The use of PrepColNameForSS here looks weird, but the values
  353. # in row 1 are the unaltered column names, rather than the restricted
  354. # column names used for interface purposes. In other words, if the
  355. # spreadsheet looks like it has a column called "Foo Bar", then the
  356. # first row will have a value "Foo Bar" but all interaction with that
  357. # column for other rows will use column key "foobar". Translate to
  358. # restricted names now with PrepColNameForSS.
  359. cols = [PrepColNameForSS(entry.content.text) for entry in feed.entry]
  360. self._columns = tuple(cols)
  361. return self._columns
  362. @property
  363. def rows(self):
  364. """The rows property is filled in on demand.
  365. It is a tuple of SpreadsheetRow objects.
  366. """
  367. if self._rows is None:
  368. rows = []
  369. feed = self.gd_client.GetListFeed(self.ss_key, self.ws_key)
  370. for rowIx, rowObj in enumerate(feed.entry, start=self.ROW_NUMBER_OFFSET):
  371. row_dict = dict((key, ScrubValFromSS(val.text))
  372. for key, val in rowObj.custom.iteritems())
  373. rows.append(SpreadsheetRow(rowObj, rowIx, row_dict))
  374. self._rows = tuple(rows)
  375. return self._rows
  376. def __init__(self):
  377. for slot in self.__slots__:
  378. setattr(self, slot, None)
  379. def Connect(self, creds, ss_key, ws_name, source='chromiumos'):
  380. """Login to spreadsheet service and set current worksheet.
  381. |creds| Credentials object for Google Docs
  382. |ss_key| Spreadsheet key
  383. |ws_name| Worksheet name
  384. |source| Name to associate with connecting service
  385. """
  386. self._Login(creds, source)
  387. self.SetCurrentWorksheet(ws_name, ss_key=ss_key)
  388. def SetCurrentWorksheet(self, ws_name, ss_key=None):
  389. """Change the current worksheet. This clears all caches."""
  390. if ss_key and ss_key != self.ss_key:
  391. self.ss_key = ss_key
  392. self._ClearCache()
  393. self.ws_name = ws_name
  394. ws_key = self._GetWorksheetKey(self.ss_key, self.ws_name)
  395. if ws_key != self.ws_key:
  396. self.ws_key = ws_key
  397. self._ClearCache()
  398. def _ClearCache(self, keep_columns=False):
  399. """Called whenever column/row data might be stale."""
  400. self._rows = None
  401. if not keep_columns:
  402. self._columns = None
  403. def _Login(self, creds, source):
  404. """Login to Google doc client using given |creds|."""
  405. gd_client = RetrySpreadsheetsService()
  406. gd_client.source = source
  407. # Login using previous auth token if available, otherwise
  408. # use email/password from creds.
  409. if creds.docs_auth_token:
  410. oper.Notice('Logging into Docs using previous auth token.')
  411. gd_client.SetClientLoginToken(creds.docs_auth_token)
  412. else:
  413. oper.Notice('Logging into Docs as "%s".' % creds.user)
  414. gd_client.email = creds.user
  415. gd_client.password = creds.password
  416. gd_client.ProgrammaticLogin()
  417. creds.SetDocsAuthToken(gd_client.GetClientLoginToken())
  418. self.gd_client = gd_client
  419. def _GetWorksheetKey(self, ss_key, ws_name):
  420. """Get the worksheet key with name |ws_name| in spreadsheet |ss_key|."""
  421. feed = self.gd_client.GetWorksheetsFeed(ss_key)
  422. # The worksheet key is the last component in the URL (after last '/')
  423. for entry in feed.entry:
  424. if ws_name == entry.title.text:
  425. return entry.id.text.split('/')[-1]
  426. oper.Die('Unable to find worksheet "%s" in spreadsheet "%s"' %
  427. (ws_name, ss_key))
  428. @ReadWriteDecorator
  429. def GetColumns(self):
  430. """Return tuple of column names in worksheet.
  431. Note that each returned name has been run through PrepColNameForSS.
  432. """
  433. return self.columns
  434. @ReadWriteDecorator
  435. def GetColumnIndex(self, colName):
  436. """Get the column index (starting at 1) for column |colName|"""
  437. try:
  438. # Spreadsheet column indices start at 1, so +1.
  439. return self.columns.index(colName) + self.COLUMN_NUMBER_OFFSET
  440. except ValueError:
  441. return None
  442. @ReadWriteDecorator
  443. def GetRows(self):
  444. """Return tuple of SpreadsheetRow objects in order."""
  445. return self.rows
  446. @ReadWriteDecorator
  447. def GetRowCacheByCol(self, column):
  448. """Return a dict for looking up rows by value in |column|.
  449. Each row value is a SpreadsheetRow object.
  450. If more than one row has the same value for |column|, then the
  451. row objects will be in a list in the returned dict.
  452. """
  453. row_cache = {}
  454. for row in self.GetRows():
  455. col_val = row[column]
  456. current_entry = row_cache.get(col_val, None)
  457. if current_entry and type(current_entry) is list:
  458. current_entry.append(row)
  459. elif current_entry:
  460. current_entry = [current_entry, row]
  461. else:
  462. current_entry = row
  463. row_cache[col_val] = current_entry
  464. return row_cache
  465. @ReadWriteDecorator
  466. def InsertRow(self, row):
  467. """Insert |row| at end of spreadsheet."""
  468. self.gd_client.InsertRow(row, self.ss_key, self.ws_key)
  469. self._ClearCache(keep_columns=True)
  470. @ReadWriteDecorator
  471. def UpdateRowCellByCell(self, rowIx, row):
  472. """Replace cell values in row at |rowIx| with those in |row| dict."""
  473. for colName in row:
  474. colIx = self.GetColumnIndex(colName)
  475. if colIx is not None:
  476. self.ReplaceCellValue(rowIx, colIx, row[colName])
  477. self._ClearCache(keep_columns=True)
  478. @ReadWriteDecorator
  479. def DeleteRow(self, ss_row):
  480. """Delete the given |ss_row| (must be original spreadsheet row object."""
  481. self.gd_client.DeleteRow(ss_row)
  482. self._ClearCache(keep_columns=True)
  483. @ReadWriteDecorator
  484. def ReplaceCellValue(self, rowIx, colIx, val):
  485. """Replace cell value at |rowIx| and |colIx| with |val|"""
  486. self.gd_client.UpdateCell(rowIx, colIx, val, self.ss_key, self.ws_key)
  487. self._ClearCache(keep_columns=True)
  488. @ReadWriteDecorator
  489. def ClearCellValue(self, rowIx, colIx):
  490. """Clear cell value at |rowIx| and |colIx|"""
  491. self.ReplaceCellValue(rowIx, colIx, None)
  492. class RetrySpreadsheetsService(gdata.spreadsheet.service.SpreadsheetsService):
  493. """Extend SpreadsheetsService to put retry logic around http request method.
  494. The entire purpose of this class is to remove some flakiness from
  495. interactions with Google Docs spreadsheet service, in the form of
  496. certain 40* http error responses to http requests. This is documented in
  497. http://code.google.com/p/chromium-os/issues/detail?id=23819.
  498. There are two "request" methods that need to be wrapped in retry logic.
  499. 1) The request method on self. Original implementation is in
  500. base class atom.service.AtomService.
  501. 2) The request method on self.http_client. The class of self.http_client
  502. can actually vary, so the original implementation of the request
  503. method can also vary.
  504. """
  505. # pylint: disable=R0904
  506. TRY_MAX = 5
  507. RETRYABLE_STATUSES = (403,)
  508. def __init__(self, *args, **kwargs):
  509. gdata.spreadsheet.service.SpreadsheetsService.__init__(self, *args,
  510. **kwargs)
  511. # Wrap self.http_client.request with retry wrapper. This request method
  512. # is used by ProgrammaticLogin(), at least.
  513. if hasattr(self, 'http_client'):
  514. self.http_client.request = functools.partial(self._RetryRequest,
  515. self.http_client.request)
  516. self.request = functools.partial(self._RetryRequest, self.request)
  517. def _RetryRequest(self, func, *args, **kwargs):
  518. """Retry wrapper for bound |func|, passing |args| and |kwargs|.
  519. This retry wrapper can be used for any http request |func| that provides
  520. an http status code via the .status attribute of the returned value.
  521. Retry when the status value on the return object is in RETRYABLE_STATUSES,
  522. and run up to TRY_MAX times. If successful (whether or not retries
  523. were necessary) return the last return value returned from base method.
  524. If unsuccessful return the first return value returned from base method.
  525. """
  526. first_retval = None
  527. for try_ix in xrange(1, self.TRY_MAX + 1):
  528. retval = func(*args, **kwargs)
  529. if retval.status not in self.RETRYABLE_STATUSES:
  530. return retval
  531. else:
  532. oper.Warning('Retry-able HTTP request failure (status=%d), try %d/%d' %
  533. (retval.status, try_ix, self.TRY_MAX))
  534. if not first_retval:
  535. first_retval = retval
  536. oper.Warning('Giving up on HTTP request after %d tries' % self.TRY_MAX)
  537. return first_retval