Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(3)

Side by Side Diff: tools/get_archive.py

Issue 10862002: Apply vsm's suggestion of keeping a table of "earliest revisions" (Closed) Base URL: http://dart.googlecode.com/svn/branches/bleeding_edge/dart/
Patch Set: Created 8 years, 4 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « no previous file | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 #!/usr/bin/env python 1 #!/usr/bin/env python
2 # 2 #
3 # Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file 3 # Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file
4 # for details. All rights reserved. Use of this source code is governed by a 4 # for details. All rights reserved. Use of this source code is governed by a
5 # BSD-style license that can be found in the LICENSE file. 5 # BSD-style license that can be found in the LICENSE file.
6 6
7 # Gets or updates a DumpRenderTree (a nearly headless build of chrome). This is 7 # Gets or updates a DumpRenderTree (a nearly headless build of chrome). This is
8 # used for running browser tests of client applications. 8 # used for running browser tests of client applications.
9 9
10 import json 10 import json
(...skipping 42 matching lines...) Expand 10 before | Expand all | Expand 10 after
53 53
54 SDK_DIR = os.path.join(utils.GetBuildRoot(utils.GuessOS(), 'release', 'ia32'), 54 SDK_DIR = os.path.join(utils.GetBuildRoot(utils.GuessOS(), 'release', 'ia32'),
55 'dart-sdk') 55 'dart-sdk')
56 SDK_VERSION = os.path.join(SDK_DIR, 'LAST_VERSION') 56 SDK_VERSION = os.path.join(SDK_DIR, 'LAST_VERSION')
57 SDK_LATEST_PATTERN = 'gs://dart-editor-archive-continuous/latest/VERSION' 57 SDK_LATEST_PATTERN = 'gs://dart-editor-archive-continuous/latest/VERSION'
58 # TODO(efortuna): Once the x64 VM also is optimized, select the version 58 # TODO(efortuna): Once the x64 VM also is optimized, select the version
59 # based on whether we are running on a 32-bit or 64-bit system. 59 # based on whether we are running on a 32-bit or 64-bit system.
60 SDK_PERMANENT = ('gs://dart-editor-archive-continuous/%(version_num)s/' + 60 SDK_PERMANENT = ('gs://dart-editor-archive-continuous/%(version_num)s/' +
61 'dartsdk-%(osname)s-32.zip') 61 'dartsdk-%(osname)s-32.zip')
62 62
63 # Dictionary storing the earliest revision of each download we have stored.
64 LAST_VALID = {'dartium': 4285, 'chromedriver': 7823, 'sdk': 9761, 'drt': 5342}
65
63 sys.path.append(os.path.join(GSUTIL_DIR, 'boto')) 66 sys.path.append(os.path.join(GSUTIL_DIR, 'boto'))
64 import boto 67 import boto
65 68
66 69
67 def execute_command(*cmd): 70 def ExecuteCommand(*cmd):
68 """Execute a command in a subprocess.""" 71 """Execute a command in a subprocess."""
69 pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 72 pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
70 output, error = pipe.communicate() 73 output, error = pipe.communicate()
71 return pipe.returncode, output 74 return pipe.returncode, output
72 75
73 76
74 def execute_command_visible(*cmd): 77 def ExecuteCommandVisible(*cmd):
75 """Execute a command in a subprocess, but show stdout/stderr.""" 78 """Execute a command in a subprocess, but show stdout/stderr."""
76 result = subprocess.call(cmd, stdout=sys.stdout, stderr=sys.stderr, 79 result = subprocess.call(cmd, stdout=sys.stdout, stderr=sys.stderr,
77 stdin=sys.stdin) 80 stdin=sys.stdin)
78 if result != 0: 81 if result != 0:
79 raise Exception('Execution of "%s" failed' % ' '.join(cmd)) 82 raise Exception('Execution of "%s" failed' % ' '.join(cmd))
80 83
81 84
82 def gsutil(*cmd): 85 def Gsutil(*cmd):
83 return execute_command('python', GSUTIL, *cmd) 86 return ExecuteCommand('python', GSUTIL, *cmd)
84 87
85 88
86 def gsutil_visible(*cmd): 89 def GsutilVisible(*cmd):
87 execute_command_visible('python', GSUTIL, *cmd) 90 ExecuteCommandVisible('python', GSUTIL, *cmd)
88 91
89 92
90 def has_boto_config(): 93 def HasBotoConfig():
91 """Returns true if boto config exists.""" 94 """Returns true if boto config exists."""
92 95
93 config_paths = boto.pyami.config.BotoConfigLocations 96 config_paths = boto.pyami.config.BotoConfigLocations
94 if 'AWS_CREDENTIAL_FILE' in os.environ: 97 if 'AWS_CREDENTIAL_FILE' in os.environ:
95 config_paths.append(os.environ['AWS_CREDENTIAL_FILE']) 98 config_paths.append(os.environ['AWS_CREDENTIAL_FILE'])
96 for config_path in config_paths: 99 for config_path in config_paths:
97 if os.path.exists(config_path): 100 if os.path.exists(config_path):
98 return True 101 return True
99 102
100 return False 103 return False
101 104
102 105
103 def in_runhooks(): 106 def InRunhooks():
104 '''True if this script was called by "gclient runhooks" or "gclient sync"''' 107 '''True if this script was called by "gclient runhooks" or "gclient sync"'''
105 return 'runhooks' in sys.argv 108 return 'runhooks' in sys.argv
106 109
107 110
108 def ensure_config(): 111 def EnsureConfig():
109 # If ~/.boto doesn't exist, tell the user to run "gsutil config" 112 # If ~/.boto doesn't exist, tell the user to run "gsutil config"
110 if not has_boto_config(): 113 if not HasBotoConfig():
111 print >>sys.stderr, ''' 114 print >>sys.stderr, '''
112 ******************************************************************************* 115 *******************************************************************************
113 * WARNING: Can't download DumpRenderTree! This is required to test client apps. 116 * WARNING: Can't download DumpRenderTree! This is required to test client apps.
114 * You need to do a one-time configuration step to access Google Storage. 117 * You need to do a one-time configuration step to access Google Storage.
115 * Please run this command and follow the instructions: 118 * Please run this command and follow the instructions:
116 * %s config 119 * %s config
117 * 120 *
118 * NOTE: When prompted you can leave "project-id" blank. Just hit enter. 121 * NOTE: When prompted you can leave "project-id" blank. Just hit enter.
119 ******************************************************************************* 122 *******************************************************************************
120 ''' % GSUTIL 123 ''' % GSUTIL
121 sys.exit(1) 124 sys.exit(1)
122 125
123 126
124 def get_dartium_revision(name, directory, version_file, latest_pattern, 127 def GetDartiumRevision(name, directory, version_file, latest_pattern,
125 permanent_prefix, revision_num=None): 128 permanent_prefix, revision_num=None):
126 """Get the latest binary that is stored in the dartium archive. 129 """Get the latest binary that is stored in the dartium archive.
127 130
128 Args: 131 Args:
129 name: the name of the desired download. 132 name: the name of the desired download.
130 directory: target directory (recreated) to install binary 133 directory: target directory (recreated) to install binary
131 version_file: name of file with the current version stamp 134 version_file: name of file with the current version stamp
132 latest_pattern: the google store url pattern pointing to the latest binary 135 latest_pattern: the google store url pattern pointing to the latest binary
133 permanent_prefix: stable google store folder used to download versions 136 permanent_prefix: stable google store folder used to download versions
134 revision_num: The desired revision number to retrieve. If revision_num is 137 revision_num: The desired revision number to retrieve. If revision_num is
135 None, we return the latest revision. If the revision number is specified 138 None, we return the latest revision. If the revision number is specified
136 but unavailable, find the nearest older revision and use that instead. 139 but unavailable, find the nearest older revision and use that instead.
137 """ 140 """
138 osdict = {'Darwin':'mac', 'Linux':'lucid64', 'Windows':'win'} 141 osdict = {'Darwin':'mac', 'Linux':'lucid64', 'Windows':'win'}
139 142
140 def find_permanent_url(out, osname, revision_num): 143 def FindPermanentUrl(out, osname, revision_num):
141 output_lines = out.split() 144 output_lines = out.split()
142 latest = output_lines[-1] 145 latest = output_lines[-1]
143 if not revision_num: 146 if not revision_num:
144 revision_num = latest[latest.rindex('-') + 1 : latest.index('.')] 147 revision_num = latest[latest.rindex('-') + 1 : latest.index('.')]
145 latest = (permanent_prefix[:permanent_prefix.rindex('/')] % { 'osname' : 148 latest = (permanent_prefix[:permanent_prefix.rindex('/')] % { 'osname' :
146 osname } + latest[latest.rindex('/'):]) 149 osname } + latest[latest.rindex('/'):])
147 else: 150 else:
148 latest = (permanent_prefix % { 'osname' : osname, 'num1' : revision_num, 151 latest = (permanent_prefix % { 'osname' : osname, 'num1' : revision_num,
149 'num2' : revision_num }) 152 'num2' : revision_num })
150 foundURL = False 153 foundURL = False
151 while not foundURL: 154 while not foundURL:
152 # Test to ensure this URL exists because the dartium-archive builds can 155 # Test to ensure this URL exists because the dartium-archive builds can
153 # have unusual numbering (a range of CL numbers) sometimes. 156 # have unusual numbering (a range of CL numbers) sometimes.
154 result, out = gsutil('ls', permanent_prefix % {'osname' : osname, 157 result, out = Gsutil('ls', permanent_prefix % {'osname' : osname,
155 'num1': '*', 'num2': revision_num }) 158 'num1': '*', 'num2': revision_num })
156 if result == 0: 159 if result == 0:
157 # First try to find one with the the second number the same as the 160 # First try to find one with the the second number the same as the
158 # requested number. 161 # requested number.
159 latest = out.split()[0] 162 latest = out.split()[0]
160 foundURL = True 163 foundURL = True
161 else: 164 else:
162 # Now try to find one with a nearby CL num. 165 # Now try to find one with a nearby CL num.
163 revision_num = int(revision_num) - 1 166 revision_num = int(revision_num) - 1
164 if revision_num <= 0: 167 if revision_num <= 0:
165 too_early_error() 168 TooEarlyError()
166 return latest 169 return latest
167 170
168 get_from_gsutil(name, directory, version_file, latest_pattern, osdict, 171 GetFromGsutil(name, directory, version_file, latest_pattern, osdict,
169 find_permanent_url, revision_num) 172 FindPermanentUrl, revision_num)
170 173
171 def get_sdk_revision(name, directory, version_file, latest_pattern, 174
175 def GetSdkRevision(name, directory, version_file, latest_pattern,
172 permanent_prefix, revision_num): 176 permanent_prefix, revision_num):
173 """Get a revision of the SDK from the editor build archive. 177 """Get a revision of the SDK from the editor build archive.
174 178
175 Args: 179 Args:
176 name: the name of the desired download 180 name: the name of the desired download
177 directory: target directory (recreated) to install binary 181 directory: target directory (recreated) to install binary
178 version_file: name of file with the current version stamp 182 version_file: name of file with the current version stamp
179 latest_pattern: the google store url pattern pointing to the latest binary 183 latest_pattern: the google store url pattern pointing to the latest binary
180 permanent_prefix: stable google store folder used to download versions 184 permanent_prefix: stable google store folder used to download versions
181 revision_num: the desired revision number, or None for the most recent 185 revision_num: the desired revision number, or None for the most recent
182 """ 186 """
183 osdict = {'Darwin':'macos', 'Linux':'linux', 'Windows':'win32'} 187 osdict = {'Darwin':'macos', 'Linux':'linux', 'Windows':'win32'}
184 def find_permanent_url(out, osname, not_used): 188 def FindPermanentUrl(out, osname, not_used):
185 rev_num = revision_num 189 rev_num = revision_num
186 if not rev_num: 190 if not rev_num:
187 temp_file = tempfile.NamedTemporaryFile() 191 temp_file = tempfile.NamedTemporaryFile()
188 temp_file_url = 'file://' + temp_file.name 192 temp_file_url = 'file://' + temp_file.name
189 gsutil('cp', latest_pattern % {'osname' : osname }, temp_file_url) 193 Gsutil('cp', latest_pattern % {'osname' : osname }, temp_file_url)
190 temp_file.seek(0) 194 temp_file.seek(0)
191 version_info = temp_file.read() 195 version_info = temp_file.read()
192 temp_file.close() 196 temp_file.close()
193 if version_info != '': 197 if version_info != '':
194 rev_num = json.loads(version_info)['revision'] 198 rev_num = json.loads(version_info)['revision']
195 else: 199 else:
196 print 'Unable to get latest version information.' 200 print 'Unable to get latest version information.'
197 return '' 201 return ''
198 latest = (permanent_prefix % { 'osname' : osname, 'version_num': rev_num}) 202 latest = (permanent_prefix % { 'osname' : osname, 'version_num': rev_num})
199 return latest 203 return latest
200 204
201 get_from_gsutil(name, directory, version_file, latest_pattern, osdict, 205 GetFromGsutil(name, directory, version_file, latest_pattern, osdict,
202 find_permanent_url, revision_num) 206 FindPermanentUrl, revision_num)
203 207
204 def get_from_gsutil(name, directory, version_file, latest_pattern, 208
209 def GetFromGsutil(name, directory, version_file, latest_pattern,
205 os_name_dict, get_permanent_url, revision_num = ''): 210 os_name_dict, get_permanent_url, revision_num = ''):
206 """Download and unzip the desired file from Google Storage. 211 """Download and unzip the desired file from Google Storage.
207 Args: 212 Args:
208 name: the name of the desired download 213 name: the name of the desired download
209 directory: target directory (recreated) to install binary 214 directory: target directory (recreated) to install binary
210 version_file: name of file with the current version stamp 215 version_file: name of file with the current version stamp
211 latest_pattern: the google store url pattern pointing to the latest binary 216 latest_pattern: the google store url pattern pointing to the latest binary
212 os_name_dict: a dictionary of operating system names and their corresponding 217 os_name_dict: a dictionary of operating system names and their corresponding
213 strings on the google storage site. 218 strings on the google storage site.
214 get_permanent_url: a function that accepts a listing of available files 219 get_permanent_url: a function that accepts a listing of available files
215 and the os name, and returns a permanent URL for downloading. 220 and the os name, and returns a permanent URL for downloading.
216 revision_num: the desired revision number to get (if not supplied, we get 221 revision_num: the desired revision number to get (if not supplied, we get
217 the latest revision) 222 the latest revision)
218 """ 223 """
219 system = platform.system() 224 system = platform.system()
220 try: 225 try:
221 osname = os_name_dict[system] 226 osname = os_name_dict[system]
222 except KeyError: 227 except KeyError:
223 print >>sys.stderr, ('WARNING: platform "%s" does not support' 228 print >>sys.stderr, ('WARNING: platform "%s" does not support'
224 '%s.') % (system, name) 229 '%s.') % (system, name)
225 return 0 230 return 0
226 231
227 ensure_config() 232 EnsureConfig()
228 233
229 # Query for the lastest version 234 # Query for the lastest version
230 pattern = latest_pattern % { 'osname' : osname } 235 pattern = latest_pattern % { 'osname' : osname }
231 result, out = gsutil('ls', pattern) 236 result, out = Gsutil('ls', pattern)
232 if result == 0: 237 if result == 0:
233 # use permanent link instead, just in case the latest zip entry gets deleted 238 # use permanent link instead, just in case the latest zip entry gets deleted
234 # while we are downloading it. 239 # while we are downloading it.
235 latest = get_permanent_url(out, osname, revision_num) 240 latest = get_permanent_url(out, osname, revision_num)
236 else: # e.g. no access 241 else: # e.g. no access
237 print "Couldn't download %s: %s\n%s" % (name, pattern, out) 242 print "Couldn't download %s: %s\n%s" % (name, pattern, out)
238 if not os.path.exists(version_file): 243 if not os.path.exists(version_file):
239 print "Using %s will not work. Please try again later." % name 244 print "Using %s will not work. Please try again later." % name
240 return 0 245 return 0
241 246
242 # Check if we need to update the file 247 # Check if we need to update the file
243 if os.path.exists(version_file): 248 if os.path.exists(version_file):
244 v = open(version_file, 'r').read() 249 v = open(version_file, 'r').read()
245 if v == latest: 250 if v == latest:
246 if not in_runhooks(): 251 if not InRunhooks():
247 print name + ' is up to date.\nVersion: ' + latest 252 print name + ' is up to date.\nVersion: ' + latest
248 return 0 # up to date 253 return 0 # up to date
249 254
250 if os.path.exists(directory): 255 if os.path.exists(directory):
251 print 'Removing old %s tree %s' % (name, directory) 256 print 'Removing old %s tree %s' % (name, directory)
252 shutil.rmtree(directory) 257 shutil.rmtree(directory)
253 258
254 # download the zip file to a temporary path, and unzip to the target location 259 # download the zip file to a temporary path, and unzip to the target location
255 temp_dir = tempfile.mkdtemp() 260 temp_dir = tempfile.mkdtemp()
256 try: 261 try:
257 temp_zip = os.path.join(temp_dir, 'drt.zip') 262 temp_zip = os.path.join(temp_dir, 'drt.zip')
258 temp_zip_url = 'file://' + temp_zip 263 temp_zip_url = 'file://' + temp_zip
259 # It's nice to show download progress 264 # It's nice to show download progress
260 gsutil_visible('cp', latest, temp_zip_url) 265 GsutilVisible('cp', latest, temp_zip_url)
261 266
262 if platform.system() != 'Windows': 267 if platform.system() != 'Windows':
263 # The Python zip utility does not preserve executable permissions, but 268 # The Python zip utility does not preserve executable permissions, but
264 # this does not seem to be a problem for Windows, which does not have a 269 # this does not seem to be a problem for Windows, which does not have a
265 # built in zip utility. :-/ 270 # built in zip utility. :-/
266 result, out = execute_command('unzip', temp_zip, '-d', temp_dir) 271 result, out = ExecuteCommand('unzip', temp_zip, '-d', temp_dir)
267 if result != 0: 272 if result != 0:
268 raise Exception('Execution of "unzip %s -d %s" failed: %s' % 273 raise Exception('Execution of "unzip %s -d %s" failed: %s' %
269 (temp_zip, temp_dir, str(out))) 274 (temp_zip, temp_dir, str(out)))
270 unzipped_dir = temp_dir + '/' + os.path.basename(latest)[:-len('.zip')] 275 unzipped_dir = temp_dir + '/' + os.path.basename(latest)[:-len('.zip')]
271 else: 276 else:
272 z = zipfile.ZipFile(temp_zip) 277 z = zipfile.ZipFile(temp_zip)
273 z.extractall(temp_dir) 278 z.extractall(temp_dir)
274 unzipped_dir = os.path.join(temp_dir, 279 unzipped_dir = os.path.join(temp_dir,
275 os.path.basename(latest)[:-len('.zip')]) 280 os.path.basename(latest)[:-len('.zip')])
276 z.close() 281 z.close()
277 if directory == SDK_DIR: 282 if directory == SDK_DIR:
278 unzipped_dir = os.path.join(temp_dir, 'dart-sdk') 283 unzipped_dir = os.path.join(temp_dir, 'dart-sdk')
279 shutil.move(unzipped_dir, directory) 284 shutil.move(unzipped_dir, directory)
280 finally: 285 finally:
281 shutil.rmtree(temp_dir) 286 shutil.rmtree(temp_dir)
282 287
283 # create the version stamp 288 # create the version stamp
284 v = open(version_file, 'w') 289 v = open(version_file, 'w')
285 v.write(latest) 290 v.write(latest)
286 v.close() 291 v.close()
287 292
288 print 'Successfully downloaded to %s' % directory 293 print 'Successfully downloaded to %s' % directory
289 return 0 294 return 0
290 295
291 def too_early_error(): 296
297 def TooEarlyError():
292 """Quick shortcutting function, to return early if someone requests a revision 298 """Quick shortcutting function, to return early if someone requests a revision
293 that is smaller than the earliest stored. This saves us from doing repeated 299 that is smaller than the earliest stored. This saves us from doing repeated
294 requests until we get down to 0.""" 300 requests until we get down to 0."""
295 print ('Unable to download requested revision because it is earlier than the ' 301 print ('Unable to download requested revision because it is earlier than the '
296 'earliest revision stored.') 302 'earliest revision stored.')
297 sys.exit(1) 303 sys.exit(1)
298 304
299 def main(): 305
306 def Main():
vsm 2012/08/20 21:25:47 I think the style is still lowercase for main, but
300 parser = optparse.OptionParser(usage='usage: %prog [options] download_name') 307 parser = optparse.OptionParser(usage='usage: %prog [options] download_name')
301 parser.add_option('-r', '--revision', dest='revision', 308 parser.add_option('-r', '--revision', dest='revision',
302 help='Desired revision number to retrieve for the SDK. If ' 309 help='Desired revision number to retrieve for the SDK. If '
303 'unspecified, retrieve the latest SDK build.', 310 'unspecified, retrieve the latest SDK build.',
304 action='store', default=None) 311 action='store', default=None)
305 args, positional = parser.parse_args() 312 args, positional = parser.parse_args()
306 313
314 if args.revision and int(args.revision) < LAST_VALID[positional[0]]:
315 return TooEarlyError()
316
307 if positional[0] == 'dartium': 317 if positional[0] == 'dartium':
308 if args.revision and args.revision < 4285: 318 GetDartiumRevision('Dartium', DARTIUM_DIR, DARTIUM_VERSION,
309 return too_early_error()
310 get_dartium_revision('Dartium', DARTIUM_DIR, DARTIUM_VERSION,
311 DARTIUM_LATEST_PATTERN, DARTIUM_PERMANENT_PATTERN, 319 DARTIUM_LATEST_PATTERN, DARTIUM_PERMANENT_PATTERN,
312 args.revision) 320 args.revision)
313 elif positional[0] == 'chromedriver': 321 elif positional[0] == 'chromedriver':
314 if args.revision and args.revision < 7823: 322 GetDartiumRevision('chromedriver', CHROMEDRIVER_DIR, CHROMEDRIVER_VERSION,
315 return too_early_error()
316 get_dartium_revision('chromedriver', CHROMEDRIVER_DIR, CHROMEDRIVER_VERSION,
317 CHROMEDRIVER_LATEST_PATTERN, 323 CHROMEDRIVER_LATEST_PATTERN,
318 CHROMEDRIVER_PERMANENT_PATTERN, args.revision) 324 CHROMEDRIVER_PERMANENT_PATTERN, args.revision)
319 elif positional[0] == 'sdk': 325 elif positional[0] == 'sdk':
320 if args.revision and args.revision < 9761: 326 GetSdkRevision('sdk', SDK_DIR, SDK_VERSION, SDK_LATEST_PATTERN,
321 return too_early_error()
322 get_sdk_revision('sdk', SDK_DIR, SDK_VERSION, SDK_LATEST_PATTERN,
323 SDK_PERMANENT, args.revision) 327 SDK_PERMANENT, args.revision)
324 elif positional[0] == 'drt': 328 elif positional[0] == 'drt':
325 if args.revision and args.revision < 5342: 329 GetDartiumRevision('DumpRenderTree', DRT_DIR, DRT_VERSION,
326 return too_early_error()
327 get_dartium_revision('DumpRenderTree', DRT_DIR, DRT_VERSION,
328 DRT_LATEST_PATTERN, DRT_PERMANENT_PATTERN, 330 DRT_LATEST_PATTERN, DRT_PERMANENT_PATTERN,
329 args.revision) 331 args.revision)
330 else: 332 else:
331 print ('Please specify the target you wish to download from Google Storage ' 333 print ('Please specify the target you wish to download from Google Storage '
332 '("drt", "dartium", "chromedriver", or "sdk")') 334 '("drt", "dartium", "chromedriver", or "sdk")')
333 335
334 if __name__ == '__main__': 336 if __name__ == '__main__':
335 sys.exit(main()) 337 sys.exit(Main())
OLDNEW
« no previous file with comments | « no previous file | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698