Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style |
| 3 | # license that can be found in the LICENSE file or at |
| 4 | # https://developers.google.com/open-source/licenses/bsd |
| 5 | |
| 6 | """Helper functions and classes used throughout Monorail.""" |
| 7 | |
| 8 | from __future__ import division |
| 9 | from __future__ import print_function |
| 10 | from __future__ import absolute_import |
| 11 | |
| 12 | import collections |
| 13 | import logging |
| 14 | import random |
| 15 | import string |
| 16 | import textwrap |
| 17 | import threading |
| 18 | import time |
| 19 | import traceback |
Adrià Vilanova MartÃnez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 20 | from six.moves.urllib.parse import urlparse, quote, urlunparse |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 21 | |
| 22 | from google.appengine.api import app_identity |
| 23 | |
| 24 | import ezt |
| 25 | import six |
| 26 | |
| 27 | import settings |
| 28 | from framework import framework_bizobj |
| 29 | from framework import framework_constants |
| 30 | from framework import template_helpers |
| 31 | from framework import timestr |
| 32 | from framework import urls |
| 33 | from proto import user_pb2 |
| 34 | from services import client_config_svc |
| 35 | |
| 36 | # AttachmentUpload holds the information of an incoming uploaded |
| 37 | # attachment before it gets saved as a gcs file and saved to the DB. |
| 38 | AttachmentUpload = collections.namedtuple( |
| 39 | 'AttachmentUpload', ['filename', 'contents', 'mimetype']) |
| 40 | # type: (str, str, str) -> None |
| 41 | |
| 42 | # For random key generation |
| 43 | RANDOM_KEY_LENGTH = 128 |
| 44 | RANDOM_KEY_CHARACTERS = string.ascii_letters + string.digits |
| 45 | |
| 46 | # params recognized by FormatURL, in the order they will appear in the url |
| 47 | RECOGNIZED_PARAMS = ['can', 'start', 'num', 'q', 'colspec', 'groupby', 'sort', |
| 48 | 'show', 'format', 'me', 'table_title', 'projects', |
| 49 | 'hotlist_id'] |
| 50 | |
| 51 | |
| 52 | def retry(tries, delay=1, backoff=2): |
| 53 | """A retry decorator with exponential backoff. |
| 54 | |
| 55 | Functions are retried when Exceptions occur. |
| 56 | |
| 57 | Args: |
| 58 | tries: int Number of times to retry, set to 0 to disable retry. |
| 59 | delay: float Initial sleep time in seconds. |
| 60 | backoff: float Must be greater than 1, further failures would sleep |
| 61 | delay*=backoff seconds. |
| 62 | """ |
| 63 | if backoff <= 1: |
| 64 | raise ValueError("backoff must be greater than 1") |
| 65 | if tries < 0: |
| 66 | raise ValueError("tries must be 0 or greater") |
| 67 | if delay <= 0: |
| 68 | raise ValueError("delay must be greater than 0") |
| 69 | |
| 70 | def decorator(func): |
| 71 | def wrapper(*args, **kwargs): |
| 72 | _tries, _delay = tries, delay |
| 73 | _tries += 1 # Ensure we call func at least once. |
| 74 | while _tries > 0: |
| 75 | try: |
| 76 | ret = func(*args, **kwargs) |
| 77 | return ret |
| 78 | except Exception: |
| 79 | _tries -= 1 |
| 80 | if _tries == 0: |
| 81 | logging.error('Exceeded maximum number of retries for %s.', |
| 82 | func.__name__) |
| 83 | raise |
| 84 | trace_str = traceback.format_exc() |
| 85 | logging.warning('Retrying %s due to Exception: %s', |
| 86 | func.__name__, trace_str) |
| 87 | time.sleep(_delay) |
| 88 | _delay *= backoff # Wait longer the next time we fail. |
| 89 | return wrapper |
| 90 | return decorator |
| 91 | |
| 92 | |
| 93 | class PromiseCallback(object): |
| 94 | """Executes the work of a Promise and then dereferences everything.""" |
| 95 | |
| 96 | def __init__(self, promise, callback, *args, **kwargs): |
| 97 | self.promise = promise |
| 98 | self.callback = callback |
| 99 | self.args = args |
| 100 | self.kwargs = kwargs |
| 101 | |
| 102 | def __call__(self): |
| 103 | try: |
| 104 | self.promise._WorkOnPromise(self.callback, *self.args, **self.kwargs) |
| 105 | finally: |
| 106 | # Make sure we no longer hold onto references to anything. |
| 107 | self.promise = self.callback = self.args = self.kwargs = None |
| 108 | |
| 109 | |
| 110 | class Promise(object): |
| 111 | """Class for promises to deliver a value in the future. |
| 112 | |
| 113 | A thread is started to run callback(args), that thread |
| 114 | should return the value that it generates, or raise an expception. |
| 115 | p.WaitAndGetValue() will block until a value is available. |
| 116 | If an exception was raised, p.WaitAndGetValue() will re-raise the |
| 117 | same exception. |
| 118 | """ |
| 119 | |
| 120 | def __init__(self, callback, *args, **kwargs): |
| 121 | """Initialize the promise and immediately call the supplied function. |
| 122 | |
| 123 | Args: |
| 124 | callback: Function that takes the args and returns the promise value. |
| 125 | *args: Any arguments to the target function. |
| 126 | **kwargs: Any keyword args for the target function. |
| 127 | """ |
| 128 | |
| 129 | self.has_value = False |
| 130 | self.value = None |
| 131 | self.event = threading.Event() |
| 132 | self.exception = None |
| 133 | |
| 134 | promise_callback = PromiseCallback(self, callback, *args, **kwargs) |
| 135 | |
| 136 | # Execute the callback in another thread. |
| 137 | promise_thread = threading.Thread(target=promise_callback) |
| 138 | promise_thread.start() |
| 139 | |
| 140 | def _WorkOnPromise(self, callback, *args, **kwargs): |
| 141 | """Run callback to compute the promised value. Save any exceptions.""" |
| 142 | try: |
| 143 | self.value = callback(*args, **kwargs) |
| 144 | except Exception as e: |
| 145 | trace_str = traceback.format_exc() |
| 146 | logging.info('Exception while working on promise: %s\n', trace_str) |
| 147 | # Add the stack trace at this point to the exception. That way, in the |
| 148 | # logs, we can see what happened further up in the call stack |
| 149 | # than WaitAndGetValue(), which re-raises exceptions. |
| 150 | e.pre_promise_trace = trace_str |
| 151 | self.exception = e |
| 152 | finally: |
| 153 | self.has_value = True |
| 154 | self.event.set() |
| 155 | |
| 156 | def WaitAndGetValue(self): |
| 157 | """Block until my value is available, then return it or raise exception.""" |
| 158 | self.event.wait() |
| 159 | if self.exception: |
| 160 | raise self.exception # pylint: disable=raising-bad-type |
| 161 | return self.value |
| 162 | |
| 163 | |
| 164 | def FormatAbsoluteURLForDomain( |
| 165 | host, project_name, servlet_name, scheme='https', **kwargs): |
| 166 | """A variant of FormatAbsoluteURL for when request objects are not available. |
| 167 | |
| 168 | Args: |
| 169 | host: string with hostname and optional port, e.g. 'localhost:8080'. |
| 170 | project_name: the destination project name, if any. |
| 171 | servlet_name: site or project-local url fragement of dest page. |
| 172 | scheme: url scheme, e.g., 'http' or 'https'. |
| 173 | **kwargs: additional query string parameters may be specified as named |
| 174 | arguments to this function. |
| 175 | |
| 176 | Returns: |
| 177 | A full url beginning with 'http[s]://'. |
| 178 | """ |
| 179 | path_and_args = FormatURL(None, servlet_name, **kwargs) |
| 180 | |
| 181 | if host: |
| 182 | domain_port = host.split(':') |
| 183 | domain_port[0] = GetPreferredDomain(domain_port[0]) |
| 184 | host = ':'.join(domain_port) |
| 185 | |
| 186 | absolute_domain_url = '%s://%s' % (scheme, host) |
| 187 | if project_name: |
| 188 | return '%s/p/%s%s' % (absolute_domain_url, project_name, path_and_args) |
| 189 | return absolute_domain_url + path_and_args |
| 190 | |
| 191 | |
| 192 | def FormatAbsoluteURL( |
| 193 | mr, servlet_name, include_project=True, project_name=None, |
| 194 | scheme=None, copy_params=True, **kwargs): |
| 195 | """Return an absolute URL to a servlet with old and new params. |
| 196 | |
| 197 | Args: |
| 198 | mr: info parsed from the current request. |
| 199 | servlet_name: site or project-local url fragement of dest page. |
| 200 | include_project: if True, include the project home url as part of the |
| 201 | destination URL (as long as it is specified either in mr |
| 202 | or as the project_name param.) |
| 203 | project_name: the destination project name, to override |
| 204 | mr.project_name if include_project is True. |
| 205 | scheme: either 'http' or 'https', to override mr.request.scheme. |
| 206 | copy_params: if True, copy well-known parameters from the existing request. |
| 207 | **kwargs: additional query string parameters may be specified as named |
| 208 | arguments to this function. |
| 209 | |
| 210 | Returns: |
| 211 | A full url beginning with 'http[s]://'. The destination URL will be in |
| 212 | the same domain as the current request. |
| 213 | """ |
| 214 | path_and_args = FormatURL( |
| 215 | [(name, mr.GetParam(name)) for name in RECOGNIZED_PARAMS] |
| 216 | if copy_params else None, |
| 217 | servlet_name, **kwargs) |
| 218 | scheme = scheme or mr.request.scheme |
| 219 | |
| 220 | project_base = '' |
| 221 | if include_project: |
| 222 | project_base = '/p/%s' % (project_name or mr.project_name) |
| 223 | |
| 224 | return '%s://%s%s%s' % (scheme, mr.request.host, project_base, path_and_args) |
| 225 | |
| 226 | |
| 227 | def FormatMovedProjectURL(mr, moved_to): |
| 228 | """Return a transformation of the given url into the given project. |
| 229 | |
| 230 | Args: |
| 231 | mr: common information parsed from the HTTP request. |
| 232 | moved_to: A string from a project's moved_to field that matches |
| 233 | project_constants.RE_PROJECT_NAME. |
| 234 | |
| 235 | Returns: |
| 236 | The url transposed into the given destination project. |
| 237 | """ |
| 238 | project_name = moved_to |
Adrià Vilanova MartÃnez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 239 | _, _, path, parameters, query, fragment_identifier = urlparse( |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 240 | mr.current_page_url) |
| 241 | # Strip off leading "/p/<moved from project>" |
| 242 | path = '/' + path.split('/', 3)[3] |
Adrià Vilanova MartÃnez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 243 | rest_of_url = urlunparse( |
| 244 | ('', '', path, parameters, query, fragment_identifier)) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 245 | return '/p/%s%s' % (project_name, rest_of_url) |
| 246 | |
| 247 | |
| 248 | def GetNeededDomain(project_name, current_domain): |
| 249 | """Return the branded domain for the project iff not on current_domain.""" |
| 250 | if (not current_domain or |
| 251 | '.appspot.com' in current_domain or |
| 252 | ':' in current_domain): |
| 253 | return None |
| 254 | desired_domain = settings.branded_domains.get( |
| 255 | project_name, settings.branded_domains.get('*')) |
| 256 | if desired_domain == current_domain: |
| 257 | return None |
| 258 | return desired_domain |
| 259 | |
| 260 | |
| 261 | def FormatURL(recognized_params, url, **kwargs): |
| 262 | # type: (Sequence[Tuple(str, str)], str, **Any) -> str |
| 263 | """Return a project relative URL to a servlet with old and new params. |
| 264 | |
| 265 | Args: |
| 266 | recognized_params: Default query parameters to include. |
| 267 | url: Base URL. Could be a relative path for an EZT Servlet or an |
| 268 | absolute path for a separate service (ie: besearch). |
| 269 | **kwargs: Additional query parameters to add. |
| 270 | |
| 271 | Returns: |
| 272 | A URL with the specified query parameters. |
| 273 | """ |
| 274 | # Standard params not overridden in **kwargs come first, followed by kwargs. |
| 275 | # The exception is the 'id' param. If present then the 'id' param always comes |
| 276 | # first. See bugs.chromium.org/p/monorail/issues/detail?id=374 |
| 277 | all_params = [] |
| 278 | if kwargs.get('id'): |
| 279 | all_params.append(('id', kwargs['id'])) |
| 280 | # TODO(jojwang): update all calls to FormatURL to only include non-None |
| 281 | # recognized_params |
| 282 | if recognized_params: |
| 283 | all_params.extend( |
| 284 | param for param in recognized_params if param[0] not in kwargs) |
| 285 | |
| 286 | all_params.extend( |
| 287 | # Ignore the 'id' param since we already added it above. |
| 288 | sorted([kwarg for kwarg in kwargs.items() if kwarg[0] != 'id'])) |
| 289 | return _FormatQueryString(url, all_params) |
| 290 | |
| 291 | |
| 292 | def _FormatQueryString(url, params): |
| 293 | # type: (str, Sequence[Tuple(str, str)]) -> str |
| 294 | """URLencode a list of parameters and attach them to the end of a URL. |
| 295 | |
| 296 | Args: |
| 297 | url: URL to append the querystring to. |
| 298 | params: List of query parameters to append. |
| 299 | |
| 300 | Returns: |
| 301 | A URL with the specified query parameters. |
| 302 | """ |
| 303 | param_string = '&'.join( |
Adrià Vilanova MartÃnez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame^] | 304 | '%s=%s' % (name, quote(six.text_type(value).encode('utf-8'))) |
| 305 | for name, value in params |
| 306 | if value is not None) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 307 | if not param_string: |
| 308 | qs_start_char = '' |
| 309 | elif '?' in url: |
| 310 | qs_start_char = '&' |
| 311 | else: |
| 312 | qs_start_char = '?' |
| 313 | return '%s%s%s' % (url, qs_start_char, param_string) |
| 314 | |
| 315 | |
| 316 | def WordWrapSuperLongLines(s, max_cols=100): |
| 317 | """Reformat input that was not word-wrapped by the browser. |
| 318 | |
| 319 | Args: |
| 320 | s: the string to be word-wrapped, it may have embedded newlines. |
| 321 | max_cols: int maximum line length. |
| 322 | |
| 323 | Returns: |
| 324 | Wrapped text string. |
| 325 | |
| 326 | Rather than wrap the whole thing, we only wrap super-long lines and keep |
| 327 | all the reasonable lines formated as-is. |
| 328 | """ |
| 329 | lines = [textwrap.fill(line, max_cols) for line in s.splitlines()] |
| 330 | wrapped_text = '\n'.join(lines) |
| 331 | |
| 332 | # The split/join logic above can lose one final blank line. |
| 333 | if s.endswith('\n') or s.endswith('\r'): |
| 334 | wrapped_text += '\n' |
| 335 | |
| 336 | return wrapped_text |
| 337 | |
| 338 | |
| 339 | def StaticCacheHeaders(): |
| 340 | """Returns HTTP headers for static content, based on the current time.""" |
| 341 | year_from_now = int(time.time()) + framework_constants.SECS_PER_YEAR |
| 342 | headers = [ |
| 343 | ('Cache-Control', |
| 344 | 'max-age=%d, private' % framework_constants.SECS_PER_YEAR), |
| 345 | ('Last-Modified', timestr.TimeForHTMLHeader()), |
| 346 | ('Expires', timestr.TimeForHTMLHeader(when=year_from_now)), |
| 347 | ] |
| 348 | logging.info('static headers are %r', headers) |
| 349 | return headers |
| 350 | |
| 351 | |
| 352 | def ComputeListDeltas(old_list, new_list): |
| 353 | """Given an old and new list, return the items added and removed. |
| 354 | |
| 355 | Args: |
| 356 | old_list: old list of values for comparison. |
| 357 | new_list: new list of values for comparison. |
| 358 | |
| 359 | Returns: |
| 360 | Two lists: one with all the values added (in new_list but was not |
| 361 | in old_list), and one with all the values removed (not in new_list |
| 362 | but was in old_lit). |
| 363 | """ |
| 364 | if old_list == new_list: |
| 365 | return [], [] # A common case: nothing was added or removed. |
| 366 | |
| 367 | added = set(new_list) |
| 368 | added.difference_update(old_list) |
| 369 | removed = set(old_list) |
| 370 | removed.difference_update(new_list) |
| 371 | return list(added), list(removed) |
| 372 | |
| 373 | |
| 374 | def GetRoleName(effective_ids, project): |
| 375 | """Determines the name of the role a member has for a given project. |
| 376 | |
| 377 | Args: |
| 378 | effective_ids: set of user IDs to get the role name for. |
| 379 | project: Project PB containing the different the different member lists. |
| 380 | |
| 381 | Returns: |
| 382 | The name of the role. |
| 383 | """ |
| 384 | if not effective_ids.isdisjoint(project.owner_ids): |
| 385 | return 'Owner' |
| 386 | if not effective_ids.isdisjoint(project.committer_ids): |
| 387 | return 'Committer' |
| 388 | if not effective_ids.isdisjoint(project.contributor_ids): |
| 389 | return 'Contributor' |
| 390 | return None |
| 391 | |
| 392 | |
| 393 | def GetHotlistRoleName(effective_ids, hotlist): |
| 394 | """Determines the name of the role a member has for a given hotlist.""" |
| 395 | if not effective_ids.isdisjoint(hotlist.owner_ids): |
| 396 | return 'Owner' |
| 397 | if not effective_ids.isdisjoint(hotlist.editor_ids): |
| 398 | return 'Editor' |
| 399 | if not effective_ids.isdisjoint(hotlist.follower_ids): |
| 400 | return 'Follower' |
| 401 | return None |
| 402 | |
| 403 | |
| 404 | class UserSettings(object): |
| 405 | """Abstract class providing static methods for user settings forms.""" |
| 406 | |
| 407 | @classmethod |
| 408 | def GatherUnifiedSettingsPageData( |
| 409 | cls, logged_in_user_id, settings_user_view, settings_user, |
| 410 | settings_user_prefs): |
| 411 | """Gather EZT variables needed for the unified user settings form. |
| 412 | |
| 413 | Args: |
| 414 | logged_in_user_id: The user ID of the acting user. |
| 415 | settings_user_view: The UserView of the target user. |
| 416 | settings_user: The User PB of the target user. |
| 417 | settings_user_prefs: UserPrefs object for the view user. |
| 418 | |
| 419 | Returns: |
| 420 | A dictionary giving the names and values of all the variables to |
| 421 | be exported to EZT to support the unified user settings form template. |
| 422 | """ |
| 423 | |
| 424 | settings_user_prefs_view = template_helpers.EZTItem( |
| 425 | **{name: None for name in framework_bizobj.USER_PREF_DEFS}) |
| 426 | if settings_user_prefs: |
| 427 | for upv in settings_user_prefs.prefs: |
| 428 | if upv.value == 'true': |
| 429 | setattr(settings_user_prefs_view, upv.name, True) |
| 430 | elif upv.value == 'false': |
| 431 | setattr(settings_user_prefs_view, upv.name, None) |
| 432 | |
| 433 | logging.info('settings_user_prefs_view is %r' % settings_user_prefs_view) |
| 434 | return { |
| 435 | 'settings_user': settings_user_view, |
| 436 | 'settings_user_pb': template_helpers.PBProxy(settings_user), |
| 437 | 'settings_user_is_banned': ezt.boolean(settings_user.banned), |
| 438 | 'self': ezt.boolean(logged_in_user_id == settings_user_view.user_id), |
| 439 | 'profile_url_fragment': ( |
| 440 | settings_user_view.profile_url[len('/u/'):]), |
| 441 | 'preview_on_hover': ezt.boolean(settings_user.preview_on_hover), |
| 442 | 'settings_user_prefs': settings_user_prefs_view, |
| 443 | } |
| 444 | |
| 445 | @classmethod |
| 446 | def ProcessBanForm( |
| 447 | cls, cnxn, user_service, post_data, user_id, user): |
| 448 | """Process the posted form data from the ban user form. |
| 449 | |
| 450 | Args: |
| 451 | cnxn: connection to the SQL database. |
| 452 | user_service: An instance of UserService for saving changes. |
| 453 | post_data: The parsed post data from the form submission request. |
| 454 | user_id: The user id of the target user. |
| 455 | user: The user PB of the target user. |
| 456 | """ |
| 457 | user_service.UpdateUserBan( |
| 458 | cnxn, user_id, user, is_banned='banned' in post_data, |
| 459 | banned_reason=post_data.get('banned_reason', '')) |
| 460 | |
| 461 | @classmethod |
| 462 | def ProcessSettingsForm( |
| 463 | cls, we, post_data, user, admin=False): |
| 464 | """Process the posted form data from the unified user settings form. |
| 465 | |
| 466 | Args: |
| 467 | we: A WorkEnvironment with cnxn and services. |
| 468 | post_data: The parsed post data from the form submission request. |
| 469 | user: The user PB of the target user. |
| 470 | admin: Whether settings reserved for admins are supported. |
| 471 | """ |
| 472 | obscure_email = 'obscure_email' in post_data |
| 473 | |
| 474 | kwargs = {} |
| 475 | if admin: |
| 476 | kwargs.update(is_site_admin='site_admin' in post_data) |
| 477 | kwargs.update(is_banned='banned' in post_data, |
| 478 | banned_reason=post_data.get('banned_reason', '')) |
| 479 | |
| 480 | we.UpdateUserSettings( |
| 481 | user, notify='notify' in post_data, |
| 482 | notify_starred='notify_starred' in post_data, |
| 483 | email_compact_subject='email_compact_subject' in post_data, |
| 484 | email_view_widget='email_view_widget' in post_data, |
| 485 | notify_starred_ping='notify_starred_ping' in post_data, |
| 486 | preview_on_hover='preview_on_hover' in post_data, |
| 487 | obscure_email=obscure_email, |
| 488 | vacation_message=post_data.get('vacation_message', ''), |
| 489 | **kwargs) |
| 490 | |
| 491 | user_prefs = [] |
| 492 | for pref_name in ['restrict_new_issues', 'public_issue_notice']: |
| 493 | user_prefs.append(user_pb2.UserPrefValue( |
| 494 | name=pref_name, |
| 495 | value=('true' if pref_name in post_data else 'false'))) |
| 496 | we.SetUserPrefs(user.user_id, user_prefs) |
| 497 | |
| 498 | |
| 499 | def GetHostPort(project_name=None): |
| 500 | """Get string domain name and port number.""" |
| 501 | |
| 502 | app_id = app_identity.get_application_id() |
| 503 | if ':' in app_id: |
| 504 | domain, app_id = app_id.split(':') |
| 505 | else: |
| 506 | domain = '' |
| 507 | |
| 508 | if domain.startswith('google'): |
| 509 | hostport = '%s.googleplex.com' % app_id |
| 510 | else: |
| 511 | hostport = '%s.appspot.com' % app_id |
| 512 | |
| 513 | live_site_domain = GetPreferredDomain(hostport) |
| 514 | if project_name: |
| 515 | project_needed_domain = GetNeededDomain(project_name, live_site_domain) |
| 516 | if project_needed_domain: |
| 517 | return project_needed_domain |
| 518 | |
| 519 | return live_site_domain |
| 520 | |
| 521 | |
| 522 | def IssueCommentURL( |
| 523 | hostport, project, local_id, seq_num=None): |
| 524 | """Return a URL pointing directly to the specified comment.""" |
| 525 | servlet_name = urls.ISSUE_DETAIL |
| 526 | detail_url = FormatAbsoluteURLForDomain( |
| 527 | hostport, project.project_name, servlet_name, id=local_id) |
| 528 | if seq_num: |
| 529 | detail_url += '#c%d' % seq_num |
| 530 | |
| 531 | return detail_url |
| 532 | |
| 533 | |
| 534 | def MurmurHash3_x86_32(key, seed=0x0): |
| 535 | """Implements the x86/32-bit version of Murmur Hash 3.0. |
| 536 | |
| 537 | MurmurHash3 is written by Austin Appleby, and is placed in the public |
| 538 | domain. See https://code.google.com/p/smhasher/ for details. |
| 539 | |
| 540 | This pure python implementation of the x86/32 bit version of MurmurHash3 is |
| 541 | written by Fredrik Kihlander and also placed in the public domain. |
| 542 | See https://github.com/wc-duck/pymmh3 for details. |
| 543 | |
| 544 | The MurmurHash3 algorithm is chosen for these reasons: |
| 545 | * It is fast, even when implemented in pure python. |
| 546 | * It is remarkably well distributed, and unlikely to cause collisions. |
| 547 | * It is stable and unchanging (any improvements will be in MurmurHash4). |
| 548 | * It is well-tested, and easily usable in other contexts (such as bulk |
| 549 | data imports). |
| 550 | |
| 551 | Args: |
| 552 | key (string): the data that you want hashed |
| 553 | seed (int): An offset, treated as essentially part of the key. |
| 554 | |
| 555 | Returns: |
| 556 | A 32-bit integer (can be interpreted as either signed or unsigned). |
| 557 | """ |
| 558 | key = bytearray(key.encode('utf-8')) |
| 559 | |
| 560 | def fmix(h): |
| 561 | h ^= h >> 16 |
| 562 | h = (h * 0x85ebca6b) & 0xFFFFFFFF |
| 563 | h ^= h >> 13 |
| 564 | h = (h * 0xc2b2ae35) & 0xFFFFFFFF |
| 565 | h ^= h >> 16 |
| 566 | return h; |
| 567 | |
| 568 | length = len(key) |
| 569 | nblocks = int(length // 4) |
| 570 | |
| 571 | h1 = seed; |
| 572 | |
| 573 | c1 = 0xcc9e2d51 |
| 574 | c2 = 0x1b873593 |
| 575 | |
| 576 | # body |
| 577 | for block_start in range(0, nblocks * 4, 4): |
| 578 | k1 = key[ block_start + 3 ] << 24 | \ |
| 579 | key[ block_start + 2 ] << 16 | \ |
| 580 | key[ block_start + 1 ] << 8 | \ |
| 581 | key[ block_start + 0 ] |
| 582 | |
| 583 | k1 = c1 * k1 & 0xFFFFFFFF |
| 584 | k1 = (k1 << 15 | k1 >> 17) & 0xFFFFFFFF |
| 585 | k1 = (c2 * k1) & 0xFFFFFFFF; |
| 586 | |
| 587 | h1 ^= k1 |
| 588 | h1 = ( h1 << 13 | h1 >> 19 ) & 0xFFFFFFFF |
| 589 | h1 = ( h1 * 5 + 0xe6546b64 ) & 0xFFFFFFFF |
| 590 | |
| 591 | # tail |
| 592 | tail_index = nblocks * 4 |
| 593 | k1 = 0 |
| 594 | tail_size = length & 3 |
| 595 | |
| 596 | if tail_size >= 3: |
| 597 | k1 ^= key[ tail_index + 2 ] << 16 |
| 598 | if tail_size >= 2: |
| 599 | k1 ^= key[ tail_index + 1 ] << 8 |
| 600 | if tail_size >= 1: |
| 601 | k1 ^= key[ tail_index + 0 ] |
| 602 | |
| 603 | if tail_size != 0: |
| 604 | k1 = ( k1 * c1 ) & 0xFFFFFFFF |
| 605 | k1 = ( k1 << 15 | k1 >> 17 ) & 0xFFFFFFFF |
| 606 | k1 = ( k1 * c2 ) & 0xFFFFFFFF |
| 607 | h1 ^= k1 |
| 608 | |
| 609 | return fmix( h1 ^ length ) |
| 610 | |
| 611 | |
| 612 | def MakeRandomKey(length=RANDOM_KEY_LENGTH, chars=RANDOM_KEY_CHARACTERS): |
| 613 | """Return a string with lots of random characters.""" |
| 614 | chars = [random.choice(chars) for _ in range(length)] |
| 615 | return ''.join(chars) |
| 616 | |
| 617 | |
| 618 | def IsServiceAccount(email, client_emails=None): |
| 619 | """Return a boolean value whether this email is a service account.""" |
| 620 | if email.endswith('gserviceaccount.com'): |
| 621 | return True |
| 622 | if client_emails is None: |
| 623 | _, client_emails = ( |
| 624 | client_config_svc.GetClientConfigSvc().GetClientIDEmails()) |
| 625 | return email in client_emails |
| 626 | |
| 627 | |
| 628 | def GetPreferredDomain(domain): |
| 629 | """Get preferred domain to display. |
| 630 | |
| 631 | The preferred domain replaces app_id for default version of monorail-prod |
| 632 | and monorail-staging. |
| 633 | """ |
| 634 | return settings.preferred_domains.get(domain, domain) |
| 635 | |
| 636 | |
| 637 | def GetUserAvailability(user, is_group=False): |
| 638 | """Return (str, str) that explains why the user might not be available.""" |
| 639 | if not user.user_id: |
| 640 | return None, None |
| 641 | if user.banned: |
| 642 | return 'Banned', 'banned' |
| 643 | if user.vacation_message: |
| 644 | return user.vacation_message, 'none' |
| 645 | if user.email_bounce_timestamp: |
| 646 | return 'Email to this user bounced', 'none' |
| 647 | # No availability shown for user groups, or addresses that are |
| 648 | # likely to be mailing lists. |
| 649 | if is_group or (user.email and '-' in user.email): |
| 650 | return None, None |
| 651 | if not user.last_visit_timestamp: |
| 652 | return 'User never visited', 'never' |
| 653 | secs_ago = int(time.time()) - user.last_visit_timestamp |
| 654 | last_visit_str = timestr.FormatRelativeDate( |
| 655 | user.last_visit_timestamp, days_only=True) |
| 656 | if secs_ago > 30 * framework_constants.SECS_PER_DAY: |
| 657 | return 'Last visit > 30 days ago', 'none' |
| 658 | if secs_ago > 15 * framework_constants.SECS_PER_DAY: |
| 659 | return ('Last visit %s' % last_visit_str), 'unsure' |
| 660 | return None, None |