blob: 56311e40e699c3d8dccb03c9723dae3aeb265ce1 [file] [log] [blame]
pam@chromium.orgf46aed92012-03-08 09:18:17 +00001# Copyright (c) 2012 The Chromium Authors. All rights reserved.
dpranke@chromium.org2a009622011-03-01 02:43:31 +00002# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
dpranke@chromium.org17cc2442012-10-17 21:12:09 +00005"""A database of OWNERS files.
6
7OWNERS files indicate who is allowed to approve changes in a specific directory
8(or who is allowed to make changes without needing approval of another OWNER).
9Note that all changes must still be reviewed by someone familiar with the code,
10so you may need approval from both an OWNER and a reviewer in many cases.
11
12The syntax of the OWNERS file is, roughly:
13
14lines := (\s* line? \s* "\n")*
15
16line := directive
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +000017 | "per-file" \s+ glob \s* "=" \s* directive
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000018 | comment
19
20directive := "set noparent"
peter@chromium.org2ce13132015-04-16 16:42:08 +000021 | "file:" glob
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000022 | email_address
23 | "*"
24
25glob := [a-zA-Z0-9_-*?]+
26
27comment := "#" [^"\n"]*
28
29Email addresses must follow the foo@bar.com short form (exact syntax given
30in BASIC_EMAIL_REGEXP, below). Filename globs follow the simple unix
31shell conventions, and relative and absolute paths are not allowed (i.e.,
32globs only refer to the files in the current directory).
33
34If a user's email is one of the email_addresses in the file, the user is
35considered an "OWNER" for all files in the directory.
36
37If the "per-file" directive is used, the line only applies to files in that
38directory that match the filename glob specified.
39
40If the "set noparent" directive used, then only entries in this OWNERS file
41apply to files in this directory; if the "set noparent" directive is not
42used, then entries in OWNERS files in enclosing (upper) directories also
43apply (up until a "set noparent is encountered").
44
45If "per-file glob=set noparent" is used, then global directives are ignored
46for the glob, and only the "per-file" owners are used for files matching that
47glob.
48
peter@chromium.org2ce13132015-04-16 16:42:08 +000049If the "file:" directive is used, the referred to OWNERS file will be parsed and
50considered when determining the valid set of OWNERS. If the filename starts with
51"//" it is relative to the root of the repository, otherwise it is relative to
52the current file
53
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000054Examples for all of these combinations can be found in tests/owners_unittest.py.
55"""
dpranke@chromium.org2a009622011-03-01 02:43:31 +000056
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +000057import collections
dpranke@chromium.orgc591a702012-12-20 20:14:58 +000058import random
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +000059import re
60
61
62# If this is present by itself on a line, this means that everyone can review.
63EVERYONE = '*'
64
65
66# Recognizes 'X@Y' email addresses. Very simplistic.
67BASIC_EMAIL_REGEXP = r'^[\w\-\+\%\.]+\@[\w\-\+\%\.]+$'
dpranke@chromium.org2a009622011-03-01 02:43:31 +000068
dpranke@chromium.org2a009622011-03-01 02:43:31 +000069
dpranke@chromium.org923950f2011-03-17 23:40:00 +000070def _assert_is_collection(obj):
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000071 assert not isinstance(obj, basestring)
maruel@chromium.org725f1c32011-04-01 20:24:54 +000072 # Module 'collections' has no 'Iterable' member
73 # pylint: disable=E1101
dpranke@chromium.orge6a4ab32011-03-31 01:23:08 +000074 if hasattr(collections, 'Iterable') and hasattr(collections, 'Sized'):
75 assert (isinstance(obj, collections.Iterable) and
76 isinstance(obj, collections.Sized))
dpranke@chromium.org923950f2011-03-17 23:40:00 +000077
78
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000079class SyntaxErrorInOwnersFile(Exception):
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000080 def __init__(self, path, lineno, msg):
81 super(SyntaxErrorInOwnersFile, self).__init__((path, lineno, msg))
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000082 self.path = path
dpranke@chromium.org86bbf192011-03-09 21:37:06 +000083 self.lineno = lineno
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000084 self.msg = msg
85
86 def __str__(self):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +000087 return '%s:%d syntax error: %s' % (self.path, self.lineno, self.msg)
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000088
89
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000090class Database(object):
91 """A database of OWNERS files for a repository.
92
93 This class allows you to find a suggested set of reviewers for a list
94 of changed files, and see if a list of changed files is covered by a
95 list of reviewers."""
96
dpranke@chromium.org17cc2442012-10-17 21:12:09 +000097 def __init__(self, root, fopen, os_path, glob):
dpranke@chromium.org898a10e2011-03-04 21:54:43 +000098 """Args:
dpranke@chromium.org2a009622011-03-01 02:43:31 +000099 root: the path to the root of the Repository
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000100 open: function callback to open a text file for reading
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000101 os_path: module/object callback with fields for 'abspath', 'dirname',
mbjorgef2d73522016-07-14 13:28:59 -0700102 'exists', 'join', and 'relpath'
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000103 glob: function callback to list entries in a directory match a glob
104 (i.e., glob.glob)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000105 """
106 self.root = root
107 self.fopen = fopen
108 self.os_path = os_path
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000109 self.glob = glob
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000110
dpranke@chromium.org627ea672011-03-11 23:29:03 +0000111 # Pick a default email regexp to use; callers can override as desired.
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000112 self.email_regexp = re.compile(BASIC_EMAIL_REGEXP)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000113
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000114 # Mapping of owners to the paths they own.
115 self.owned_by = {EVERYONE: set()}
116
117 # Mapping of paths to authorized owners.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000118 self.owners_for = {}
119
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000120 # Mapping reviewers to the preceding comment per file in the OWNERS files.
121 self.comments = {}
122
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000123 # Set of paths that stop us from looking above them for owners.
124 # (This is implicitly true for the root directory).
125 self.stop_looking = set([''])
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000126
peter@chromium.org2ce13132015-04-16 16:42:08 +0000127 # Set of files which have already been read.
128 self.read_files = set()
129
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000130 def reviewers_for(self, files, author):
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000131 """Returns a suggested set of reviewers that will cover the files.
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000132
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000133 files is a sequence of paths relative to (and under) self.root.
134 If author is nonempty, we ensure it is not included in the set returned
135 in order avoid suggesting the author as a reviewer for their own changes."""
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000136 self._check_paths(files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000137 self.load_data_needed_for(files)
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000138 suggested_owners = self._covering_set_of_owners_for(files, author)
dpranke@chromium.org9d66f482013-01-18 02:57:11 +0000139 if EVERYONE in suggested_owners:
140 if len(suggested_owners) > 1:
141 suggested_owners.remove(EVERYONE)
142 else:
143 suggested_owners = set(['<anyone>'])
144 return suggested_owners
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000145
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000146 def files_not_covered_by(self, files, reviewers):
147 """Returns the files not owned by one of the reviewers.
dpranke@chromium.orgfdecfb72011-03-16 23:27:23 +0000148
149 Args:
150 files is a sequence of paths relative to (and under) self.root.
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000151 reviewers is a sequence of strings matching self.email_regexp.
152 """
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000153 self._check_paths(files)
154 self._check_reviewers(reviewers)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000155 self.load_data_needed_for(files)
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000156
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000157 covered_objs = self._objs_covered_by(reviewers)
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000158 uncovered_files = [f for f in files
159 if not self._is_obj_covered_by(f, covered_objs)]
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000160
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000161 return set(uncovered_files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000162
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000163 def _check_paths(self, files):
164 def _is_under(f, pfx):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000165 return self.os_path.abspath(self.os_path.join(pfx, f)).startswith(pfx)
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000166 _assert_is_collection(files)
dpranke@chromium.orgb54a78e2012-12-13 23:37:23 +0000167 assert all(not self.os_path.isabs(f) and
168 _is_under(f, self.os_path.abspath(self.root)) for f in files)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000169
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000170 def _check_reviewers(self, reviewers):
dpranke@chromium.org923950f2011-03-17 23:40:00 +0000171 _assert_is_collection(reviewers)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000172 assert all(self.email_regexp.match(r) for r in reviewers)
173
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000174 def _objs_covered_by(self, reviewers):
175 objs = self.owned_by[EVERYONE]
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000176 for r in reviewers:
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000177 objs = objs | self.owned_by.get(r, set())
178 return objs
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000179
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000180 def _stop_looking(self, objname):
181 return objname in self.stop_looking
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000182
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000183 def _is_obj_covered_by(self, objname, covered_objs):
184 while not objname in covered_objs and not self._stop_looking(objname):
185 objname = self.os_path.dirname(objname)
186 return objname in covered_objs
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000187
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000188 def _enclosing_dir_with_owners(self, objname):
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000189 """Returns the innermost enclosing directory that has an OWNERS file."""
dpranke@chromium.org6b1e3ee2013-02-23 00:06:38 +0000190 dirpath = objname
pam@chromium.orgf46aed92012-03-08 09:18:17 +0000191 while not dirpath in self.owners_for:
192 if self._stop_looking(dirpath):
193 break
194 dirpath = self.os_path.dirname(dirpath)
195 return dirpath
196
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000197 def load_data_needed_for(self, files):
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000198 for f in files:
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000199 dirpath = self.os_path.dirname(f)
200 while not dirpath in self.owners_for:
peter@chromium.org2ce13132015-04-16 16:42:08 +0000201 self._read_owners(self.os_path.join(dirpath, 'OWNERS'))
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000202 if self._stop_looking(dirpath):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000203 break
204 dirpath = self.os_path.dirname(dirpath)
dpranke@chromium.org2a009622011-03-01 02:43:31 +0000205
peter@chromium.org2ce13132015-04-16 16:42:08 +0000206 def _read_owners(self, path):
207 owners_path = self.os_path.join(self.root, path)
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000208 if not self.os_path.exists(owners_path):
209 return
peter@chromium.org2ce13132015-04-16 16:42:08 +0000210
211 if owners_path in self.read_files:
212 return
213
214 self.read_files.add(owners_path)
215
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000216 comment = []
peter@chromium.org2ce13132015-04-16 16:42:08 +0000217 dirpath = self.os_path.dirname(path)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000218 in_comment = False
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000219 lineno = 0
220 for line in self.fopen(owners_path):
221 lineno += 1
222 line = line.strip()
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000223 if line.startswith('#'):
224 if not in_comment:
225 comment = []
226 comment.append(line[1:].strip())
227 in_comment = True
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000228 continue
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000229 if line == '':
230 continue
231 in_comment = False
232
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000233 if line == 'set noparent':
234 self.stop_looking.add(dirpath)
235 continue
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000236
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000237 m = re.match('per-file (.+)=(.+)', line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000238 if m:
dpranke@chromium.orgd16e48b2012-12-03 21:53:49 +0000239 glob_string = m.group(1).strip()
240 directive = m.group(2).strip()
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000241 full_glob_string = self.os_path.join(self.root, dirpath, glob_string)
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000242 if '/' in glob_string or '\\' in glob_string:
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000243 raise SyntaxErrorInOwnersFile(owners_path, lineno,
dpranke@chromium.org9e227d52012-10-20 23:47:42 +0000244 'per-file globs cannot span directories or use escapes: "%s"' %
245 line)
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000246 baselines = self.glob(full_glob_string)
dpranke@chromium.orge3b1c3d2012-10-20 22:28:14 +0000247 for baseline in (self.os_path.relpath(b, self.root) for b in baselines):
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000248 self._add_entry(baseline, directive, 'per-file line',
249 owners_path, lineno, '\n'.join(comment))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000250 continue
251
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000252 if line.startswith('set '):
253 raise SyntaxErrorInOwnersFile(owners_path, lineno,
254 'unknown option: "%s"' % line[4:].strip())
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000255
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000256 self._add_entry(dirpath, line, 'line', owners_path, lineno,
257 ' '.join(comment))
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000258
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000259 def _add_entry(self, path, directive,
260 line_type, owners_path, lineno, comment):
261 if directive == 'set noparent':
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000262 self.stop_looking.add(path)
peter@chromium.org2ce13132015-04-16 16:42:08 +0000263 elif directive.startswith('file:'):
264 owners_file = self._resolve_include(directive[5:], owners_path)
265 if not owners_file:
266 raise SyntaxErrorInOwnersFile(owners_path, lineno,
267 ('%s does not refer to an existing file.' % directive[5:]))
268
269 self._read_owners(owners_file)
270
271 dirpath = self.os_path.dirname(owners_file)
272 for key in self.owned_by:
273 if not dirpath in self.owned_by[key]:
274 continue
275 self.owned_by[key].add(path)
276
277 if dirpath in self.owners_for:
278 self.owners_for.setdefault(path, set()).update(self.owners_for[dirpath])
279
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000280 elif self.email_regexp.match(directive) or directive == EVERYONE:
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000281 self.comments.setdefault(directive, {})
282 self.comments[directive][path] = comment
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000283 self.owned_by.setdefault(directive, set()).add(path)
284 self.owners_for.setdefault(path, set()).add(directive)
285 else:
dpranke@chromium.org86bbf192011-03-09 21:37:06 +0000286 raise SyntaxErrorInOwnersFile(owners_path, lineno,
peter@chromium.org2ce13132015-04-16 16:42:08 +0000287 ('%s is not a "set" directive, file include, "*", '
dpranke@chromium.org17cc2442012-10-17 21:12:09 +0000288 'or an email address: "%s"' % (line_type, directive)))
289
peter@chromium.org2ce13132015-04-16 16:42:08 +0000290 def _resolve_include(self, path, start):
291 if path.startswith('//'):
292 include_path = path[2:]
293 else:
294 assert start.startswith(self.root)
mbjorgef2d73522016-07-14 13:28:59 -0700295 start = self.os_path.dirname(self.os_path.relpath(start, self.root))
peter@chromium.org2ce13132015-04-16 16:42:08 +0000296 include_path = self.os_path.join(start, path)
297
298 owners_path = self.os_path.join(self.root, include_path)
299 if not self.os_path.exists(owners_path):
300 return None
301
302 return include_path
303
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000304 def _covering_set_of_owners_for(self, files, author):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000305 dirs_remaining = set(self._enclosing_dir_with_owners(f) for f in files)
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000306 all_possible_owners = self.all_possible_owners(dirs_remaining, author)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000307 suggested_owners = set()
308 while dirs_remaining:
309 owner = self.lowest_cost_owner(all_possible_owners, dirs_remaining)
310 suggested_owners.add(owner)
311 dirs_to_remove = set(el[0] for el in all_possible_owners[owner])
312 dirs_remaining -= dirs_to_remove
313 return suggested_owners
dpranke@chromium.org5e5d37b2012-12-19 21:04:58 +0000314
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000315 def all_possible_owners(self, dirs, author):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000316 """Returns a list of (potential owner, distance-from-dir) tuples; a
317 distance of 1 is the lowest/closest possible distance (which makes the
318 subsequent math easier)."""
319 all_possible_owners = {}
zork@chromium.org046e1752012-05-07 05:56:12 +0000320 for current_dir in dirs:
zork@chromium.org046e1752012-05-07 05:56:12 +0000321 dirname = current_dir
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000322 distance = 1
323 while True:
324 for owner in self.owners_for.get(dirname, []):
dpranke@chromium.orgdbf8b4e2013-02-28 19:24:16 +0000325 if author and owner == author:
326 continue
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000327 all_possible_owners.setdefault(owner, [])
328 # If the same person is in multiple OWNERS files above a given
329 # directory, only count the closest one.
330 if not any(current_dir == el[0] for el in all_possible_owners[owner]):
331 all_possible_owners[owner].append((current_dir, distance))
dpranke@chromium.org7eea2592011-03-09 21:35:46 +0000332 if self._stop_looking(dirname):
dpranke@chromium.org6dada4e2011-03-08 22:32:40 +0000333 break
334 dirname = self.os_path.dirname(dirname)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000335 distance += 1
336 return all_possible_owners
zork@chromium.org046e1752012-05-07 05:56:12 +0000337
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000338 @staticmethod
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000339 def total_costs_by_owner(all_possible_owners, dirs):
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000340 # We want to minimize both the number of reviewers and the distance
341 # from the files/dirs needing reviews. The "pow(X, 1.75)" below is
342 # an arbitrarily-selected scaling factor that seems to work well - it
343 # will select one reviewer in the parent directory over three reviewers
344 # in subdirs, but not one reviewer over just two.
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000345 result = {}
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000346 for owner in all_possible_owners:
347 total_distance = 0
348 num_directories_owned = 0
349 for dirname, distance in all_possible_owners[owner]:
350 if dirname in dirs:
351 total_distance += distance
352 num_directories_owned += 1
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000353 if num_directories_owned:
354 result[owner] = (total_distance /
355 pow(num_directories_owned, 1.75))
356 return result
zork@chromium.org046e1752012-05-07 05:56:12 +0000357
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000358 @staticmethod
359 def lowest_cost_owner(all_possible_owners, dirs):
360 total_costs_by_owner = Database.total_costs_by_owner(all_possible_owners,
361 dirs)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000362 # Return the lowest cost owner. In the case of a tie, pick one randomly.
363 lowest_cost = min(total_costs_by_owner.itervalues())
364 lowest_cost_owners = filter(
ikarienator@chromium.orgfaf3fdf2013-09-20 02:11:48 +0000365 lambda owner: total_costs_by_owner[owner] == lowest_cost,
366 total_costs_by_owner)
dpranke@chromium.orgc591a702012-12-20 20:14:58 +0000367 return random.Random().choice(lowest_cost_owners)