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

Side by Side Diff: lib/src/runner/parse_metadata.dart

Issue 1093313002: Refactor parseMetadata. (Closed) Base URL: git@github.com:dart-lang/test@master
Patch Set: Created 5 years, 8 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
« 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 // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file 1 // Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file
2 // for details. All rights reserved. Use of this source code is governed by a 2 // for details. All rights reserved. Use of this source code is governed by a
3 // BSD-style license that can be found in the LICENSE file. 3 // BSD-style license that can be found in the LICENSE file.
4 4
5 library test.runner.parse_metadata; 5 library test.runner.parse_metadata;
6 6
7 import 'dart:io'; 7 import 'dart:io';
8 8
9 import 'package:analyzer/analyzer.dart'; 9 import 'package:analyzer/analyzer.dart';
10 import 'package:analyzer/src/generated/ast.dart'; 10 import 'package:analyzer/src/generated/ast.dart';
11 import 'package:path/path.dart' as p; 11 import 'package:path/path.dart' as p;
12 import 'package:source_span/source_span.dart'; 12 import 'package:source_span/source_span.dart';
13 13
14 import '../backend/metadata.dart'; 14 import '../backend/metadata.dart';
15 import '../frontend/timeout.dart'; 15 import '../frontend/timeout.dart';
16 import '../util/dart.dart'; 16 import '../util/dart.dart';
17 17
18 /// The valid argument names for [new Duration].
19 const _durationArgs = const [
20 "days",
21 "hours",
22 "minutes",
23 "seconds",
24 "milliseconds",
25 "microseconds"
26 ];
27
28 /// Parse the test metadata for the test file at [path]. 18 /// Parse the test metadata for the test file at [path].
29 /// 19 ///
30 /// Throws an [AnalysisError] if parsing fails or a [FormatException] if the 20 /// Throws an [AnalysisError] if parsing fails or a [FormatException] if the
31 /// test annotations are incorrect. 21 /// test annotations are incorrect.
32 Metadata parseMetadata(String path) { 22 Metadata parseMetadata(String path) => new _Parser(path).parse();
33 var timeout; 23
34 var testOn; 24 /// A parser for test suite metadata.
35 var skip; 25 class _Parser {
36 26 /// The path to the test suite.
37 var contents = new File(path).readAsStringSync(); 27 final String _path;
38 var directives = parseDirectives(contents, name: path).directives; 28
39 var annotations = directives.isEmpty ? [] : directives.first.metadata; 29 /// All annotations at the top of the file.
40 30 List<Annotation> _annotations;
41 // We explicitly *don't* just look for "package:test" imports here, 31
42 // because it could be re-exported from another library. 32 /// All prefixes defined by imports in this file.
43 var prefixes = directives.map((directive) { 33 Set<String> _prefixes;
44 if (directive is! ImportDirective) return null; 34
45 if (directive.prefix == null) return null; 35 _Parser(String path)
46 return directive.prefix.name; 36 : _path = path {
47 }).where((prefix) => prefix != null).toSet(); 37 var contents = new File(path).readAsStringSync();
48 38 var directives = parseDirectives(contents, name: path).directives;
49 for (var annotation in annotations) { 39 _annotations = directives.isEmpty ? [] : directives.first.metadata;
50 // The annotation syntax is ambiguous between named constructors and 40
51 // prefixed annotations, so we need to resolve that ambiguity using the 41 // We explicitly *don't* just look for "package:test" imports here,
52 // known prefixes. The analyzer parses "@x.y()" as prefix "x", annotation 42 // because it could be re-exported from another library.
53 // "y", and named constructor null. It parses "@x.y.z()" as prefix "x", 43 _prefixes = directives.map((directive) {
54 // annotation "y", and named constructor "z". 44 if (directive is! ImportDirective) return null;
55 var name; 45 if (directive.prefix == null) return null;
56 var constructorName; 46 return directive.prefix.name;
57 var identifier = annotation.name; 47 }).where((prefix) => prefix != null).toSet();
58 if (identifier is PrefixedIdentifier && 48 }
59 !prefixes.contains(identifier.prefix.name) && 49
60 annotation.constructorName == null) { 50 /// Parses the metadata.
61 name = identifier.prefix.name; 51 Metadata parse() {
62 constructorName = identifier.identifier.name; 52 var timeout;
53 var testOn;
54 var skip;
55
56 for (var annotation in _annotations) {
57 // The annotation syntax is ambiguous between named constructors and
58 // prefixed annotations, so we need to resolve that ambiguity using the
59 // known prefixes. The analyzer parses "@x.y()" as prefix "x", annotation
60 // "y", and named constructor null. It parses "@x.y.z()" as prefix "x",
61 // annotation "y", and named constructor "z".
62 var name;
63 var constructorName;
64 var identifier = annotation.name;
65 if (identifier is PrefixedIdentifier &&
66 !_prefixes.contains(identifier.prefix.name) &&
67 annotation.constructorName == null) {
68 name = identifier.prefix.name;
69 constructorName = identifier.identifier.name;
70 } else {
71 name = identifier is PrefixedIdentifier
72 ? identifier.identifier.name
73 : identifier.name;
74 if (annotation.constructorName != null) {
75 constructorName = annotation.constructorName.name;
76 }
77 }
78
79 if (name == 'TestOn') {
80 _assertSingleAnnotation(testOn, 'TestOn', annotation);
81 testOn = _parseTestOn(annotation, constructorName);
82 } else if (name == 'Timeout') {
83 _assertSingleAnnotation(timeout, 'Timeout', annotation);
84 timeout = _parseTimeout(annotation, constructorName);
85 } else if (name == 'Skip') {
86 _assertSingleAnnotation(skip, 'Skip', annotation);
87 skip = _parseSkip(annotation, constructorName);
88 }
89 }
90
91 try {
92 return new Metadata.parse(
93 testOn: testOn == null ? null : testOn.stringValue,
94 timeout: timeout,
95 skip: skip);
96 } on SourceSpanFormatException catch (error) {
97 var file = new SourceFile(new File(_path).readAsStringSync(),
98 url: p.toUri(_path));
99 var span = contextualizeSpan(error.span, testOn, file);
100 if (span == null) rethrow;
101 throw new SourceSpanFormatException(error.message, span);
102 }
103 }
104
105 /// Parses a `@TestOn` annotation.
106 ///
107 /// [annotation] is the annotation. [constructorName] is the name of the named
108 /// constructor for the annotation, if any.
109 StringLiteral _parseTestOn(Annotation annotation, String constructorName) {
110 _assertConstructorName(constructorName, 'TestOn', annotation);
111 _assertArguments(annotation.arguments, 'TestOn', annotation, positional: 1);
112 return _parseString(annotation.arguments.arguments.first);
113 }
114
115 /// Parses a `@Timeout` annotation.
116 ///
117 /// [annotation] is the annotation. [constructorName] is the name of the named
118 /// constructor for the annotation, if any.
119 Timeout _parseTimeout(Annotation annotation, String constructorName) {
120 _assertConstructorName(constructorName, 'Timeout', annotation,
121 validNames: [null, 'factor']);
122
123 var description = 'Timeout';
124 if (constructorName != null) description += '.$constructorName';
125
126 _assertArguments(annotation.arguments, description, annotation,
127 positional: 1);
128
129 var args = annotation.arguments.arguments;
130 if (constructorName == null) return new Timeout(_parseDuration(args.first));
131 return new Timeout.factor(_parseNum(args.first));
132 }
133
134 /// Parses a `@Skip` annotation.
135 ///
136 /// [annotation] is the annotation. [constructorName] is the name of the named
137 /// constructor for the annotation, if any.
138 ///
139 /// Returns either `true` or a reason string.
140 _parseSkip(Annotation annotation, String constructorName) {
141 _assertConstructorName(constructorName, 'Skip', annotation);
142 _assertArguments(annotation.arguments, 'Skip', annotation, optional: 1);
143
144 var args = annotation.arguments.arguments;
145 return args.isEmpty ? true : _parseString(args.first).stringValue;
146 }
147
148 /// Parses a `const Duration` expression.
149 Duration _parseDuration(Expression expression) {
150 _parseConstructor(expression, 'Duration');
151
152 var constructor = expression as InstanceCreationExpression;
153 var values = _assertArguments(
154 constructor.argumentList, 'Duration', constructor, named: [
155 'days', 'hours', 'minutes', 'seconds', 'milliseconds', 'microseconds'
156 ]);
157
158 for (var key in values.keys.toList()) {
159 if (values.containsKey(key)) values[key] = _parseInt(values[key]);
160 }
161
162 return new Duration(
163 days: values["days"] == null ? 0 : values["days"],
164 hours: values["hours"] == null ? 0 : values["hours"],
165 minutes: values["minutes"] == null ? 0 : values["minutes"],
166 seconds: values["seconds"] == null ? 0 : values["seconds"],
167 milliseconds: values["milliseconds"] == null ? 0 : values["milliseconds" ],
168 microseconds:
169 values["microseconds"] == null ? 0 : values["microseconds"]);
170 }
171
172 /// Asserts that [existing] is null.
173 ///
174 /// [name] is the name of the annotation and [node] is its location, used for
175 /// error reporting.
176 void _assertSingleAnnotation(Object existing, String name, AstNode node) {
177 if (existing == null) return;
178 throw new SourceSpanFormatException(
179 "Only a single $name annotation may be used for a given test file.",
180 _spanFor(node));
181 }
182
183 /// Asserts that [constructorName] is a valid constructor name for an AST
184 /// node.
185 ///
186 /// [nodeName] is the name of the class being constructed, and [node] is the
187 /// AST node for that class. [validNames], if passed, is the set of valid
188 /// constructor names; if an unnamed constructor is valid, it should include
189 /// `null`. By default, only an unnamed constructor is allowed.
190 void _assertConstructorName(String constructorName, String nodeName,
191 AstNode node, {Iterable<String> validNames}) {
192 if (validNames == null) validNames = [null];
193 if (validNames.contains(constructorName)) return;
194
195 if (constructorName == null) {
196 throw new SourceSpanFormatException(
197 "$nodeName doesn't have an unnamed constructor.",
198 _spanFor(node));
63 } else { 199 } else {
64 name = identifier is PrefixedIdentifier 200 throw new SourceSpanFormatException(
65 ? identifier.identifier.name 201 '$nodeName doesn\'t have a constructor named "$constructorName".',
66 : identifier.name; 202 _spanFor(node));
67 if (annotation.constructorName != null) { 203 }
68 constructorName = annotation.constructorName.name; 204 }
205
206 /// Parses a constructor invocation for [className].
207 ///
208 /// [validNames], if passed, is the set of valid constructor names; if an
209 /// unnamed constructor is valid, it should include `null`. By default, only
210 /// an unnamed constructor is allowed.
211 ///
212 /// Returns the name of the named constructor, if any.
213 String _parseConstructor(Expression expression, String className,
214 {Iterable<String> validNames}) {
215 if (validNames == null) validNames = [null];
216
217 if (expression is! InstanceCreationExpression) {
218 throw new SourceSpanFormatException(
219 "Expected a $className.", _spanFor(expression));
220 }
221
222 var constructor = expression as InstanceCreationExpression;
223 if (constructor.constructorName.type.name.name != className) {
224 throw new SourceSpanFormatException(
225 "Expected a $className.", _spanFor(constructor));
226 }
227
228 if (constructor.keyword.lexeme != "const") {
229 throw new SourceSpanFormatException(
230 "$className must use a const constructor.", _spanFor(constructor));
231 }
232
233 var name = constructor.constructorName == null
234 ? null
235 : constructor.constructorName.name;
236 _assertConstructorName(name, className, expression,
237 validNames: validNames);
238 return name;
239 }
240
241 /// Assert that [arguments] is a valid argument list.
242 ///
243 /// [name] describes the function and [node] is its AST node. [positional] is
244 /// the number of required positional arguments, [optional] the number of
245 /// optional positional arguments, and [named] the set of valid argument
246 /// names.
247 ///
248 /// The set of parsed named arguments is returned.
249 Map<String, Expression> _assertArguments(ArgumentList arguments, String name,
250 AstNode node, {int positional, int optional, Iterable<String> named}) {
251 if (positional == null) positional = 0;
252 if (optional == null) optional = 0;
253 if (named == null) named = new Set();
254
255 if (arguments == null) {
256 throw new SourceSpanFormatException(
257 '$name takes arguments.', _spanFor(node));
258 }
259
260 var actualNamed = arguments.arguments
261 .where((arg) => arg is NamedExpression).toList();
262 if (!actualNamed.isEmpty && named.isEmpty) {
263 throw new SourceSpanFormatException(
264 "$name doesn't take named arguments.", _spanFor(actualNamed.first));
265 }
266
267 var namedValues = {};
268 for (var argument in actualNamed) {
269 var argumentName = argument.name.label.name;
270 if (!named.contains(argumentName)) {
271 throw new SourceSpanFormatException(
272 '$name doesn\'t take an argument named "$argumentName".',
273 _spanFor(argument));
274 } else if (namedValues.containsKey(argumentName)) {
275 throw new SourceSpanFormatException(
276 'An argument named "$argumentName" was already passed.',
277 _spanFor(argument));
278 } else {
279 namedValues[argumentName] = argument.expression;
69 } 280 }
70 } 281 }
71 282
72 if (name == 'TestOn') { 283 var actualPositional = arguments.arguments.length - actualNamed.length;
73 if (testOn != null) { 284 if (actualPositional < positional) {
285 var buffer = new StringBuffer("$name takes ");
286 if (optional != 0) buffer.write("at least ");
287 buffer.write("$positional argument");
288 if (positional > 1) buffer.write("s");
289 buffer.write(".");
290 throw new SourceSpanFormatException(
291 buffer.toString(), _spanFor(arguments));
292 }
293
294 if (actualPositional > positional + optional) {
295 if (optional + positional == 0) {
296 var buffer = new StringBuffer("$name doesn't take ");
297 if (!named.isEmpty) buffer.write("positional ");
298 buffer.write("arguments.");
74 throw new SourceSpanFormatException( 299 throw new SourceSpanFormatException(
75 "Only a single TestOn annotation may be used for a given test file." , 300 buffer.toString(), _spanFor(arguments));
76 _spanFor(annotation, path));
77 } 301 }
78 testOn = _parseTestOn(annotation, constructorName, path); 302
79 } else if (name == 'Timeout') { 303 var buffer = new StringBuffer("$name takes ");
80 if (timeout != null) { 304 if (optional != 0) buffer.write("at most ");
81 throw new SourceSpanFormatException( 305 buffer.write("${positional + optional} argument");
82 "Only a single Timeout annotation may be used for a given test file. ", 306 if (positional > 1) buffer.write("s");
83 _spanFor(annotation, path)); 307 buffer.write(".");
84 } 308 throw new SourceSpanFormatException(
85 timeout = _parseTimeout(annotation, constructorName, path); 309 buffer.toString(), _spanFor(arguments));
86 } else if (name == 'Skip') { 310 }
87 if (skip != null) { 311
88 throw new SourceSpanFormatException( 312 return namedValues;
89 "Only a single Skip annotation may be used for a given test file.", 313 }
90 _spanFor(annotation, path)); 314
91 } 315 /// Parses a constant number literal.
92 skip = _parseSkip(annotation, constructorName, path); 316 num _parseNum(Expression expression) {
93 } 317 if (expression is IntegerLiteral) return expression.value;
94 } 318 if (expression is DoubleLiteral) return expression.value;
95
96 try {
97 return new Metadata.parse(
98 testOn: testOn == null ? null : testOn.stringValue,
99 timeout: timeout,
100 skip: skip);
101 } on SourceSpanFormatException catch (error) {
102 var file = new SourceFile(new File(path).readAsStringSync(),
103 url: p.toUri(path));
104 var span = contextualizeSpan(error.span, testOn, file);
105 if (span == null) rethrow;
106 throw new SourceSpanFormatException(error.message, span);
107 }
108 }
109
110 /// Parses a `@TestOn` annotation.
111 ///
112 /// [annotation] is the annotation. [constructorName] is the name of the named
113 /// constructor for the annotation, if any. [path] is the path to the file from
114 /// which the annotation was parsed.
115 StringLiteral _parseTestOn(Annotation annotation, String constructorName,
116 String path) {
117 if (constructorName != null) {
118 throw new SourceSpanFormatException( 319 throw new SourceSpanFormatException(
119 'TestOn doesn\'t have a constructor named "$constructorName".', 320 "Expected a number.", _spanFor(expression));
120 _spanFor(annotation, path)); 321 }
121 } 322
122 323 /// Parses a constant int literal.
123 if (annotation.arguments == null) { 324 int _parseInt(Expression expression) {
325 if (expression is IntegerLiteral) return expression.value;
124 throw new SourceSpanFormatException( 326 throw new SourceSpanFormatException(
125 'TestOn takes one argument.', _spanFor(annotation, path)); 327 "Expected an integer.", _spanFor(expression));
126 } 328 }
127 329
128 var args = annotation.arguments.arguments; 330 /// Parses a constant String literal.
129 if (args.isEmpty) { 331 StringLiteral _parseString(Expression expression) {
332 if (expression is StringLiteral) return expression;
130 throw new SourceSpanFormatException( 333 throw new SourceSpanFormatException(
131 'TestOn takes one argument.', _spanFor(annotation.arguments, path)); 334 "Expected a String.", _spanFor(expression));
132 } 335 }
133 336
134 if (args.first is NamedExpression) { 337 /// Creates a [SourceSpan] for [node].
135 throw new SourceSpanFormatException( 338 SourceSpan _spanFor(AstNode node) {
136 "TestOn doesn't take named parameters.", _spanFor(args.first, path));
137 }
138
139 if (args.length > 1) {
140 throw new SourceSpanFormatException(
141 "TestOn takes only one argument.",
142 _spanFor(annotation.arguments, path));
143 }
144
145 if (args.first is! StringLiteral) {
146 throw new SourceSpanFormatException(
147 "TestOn takes a String.", _spanFor(args.first, path));
148 }
149
150 return args.first;
151 }
152
153 /// Parses a `@Timeout` annotation.
154 ///
155 /// [annotation] is the annotation. [constructorName] is the name of the named
156 /// constructor for the annotation, if any. [path] is the path to the file from
157 /// which the annotation was parsed.
158 Timeout _parseTimeout(Annotation annotation, String constructorName,
159 String path) {
160 if (constructorName != null && constructorName != 'factor') {
161 throw new SourceSpanFormatException(
162 'Timeout doesn\'t have a constructor named "$constructorName".',
163 _spanFor(annotation, path));
164 }
165
166 var description = 'Timeout';
167 if (constructorName != null) description += '.$constructorName';
168
169 if (annotation.arguments == null) {
170 throw new SourceSpanFormatException(
171 '$description takes one argument.', _spanFor(annotation, path));
172 }
173
174 var args = annotation.arguments.arguments;
175 if (args.isEmpty) {
176 throw new SourceSpanFormatException(
177 '$description takes one argument.',
178 _spanFor(annotation.arguments, path));
179 }
180
181 if (args.first is NamedExpression) {
182 throw new SourceSpanFormatException(
183 "$description doesn't take named parameters.",
184 _spanFor(args.first, path));
185 }
186
187 if (args.length > 1) {
188 throw new SourceSpanFormatException(
189 "$description takes only one argument.",
190 _spanFor(annotation.arguments, path));
191 }
192
193 if (constructorName == null) {
194 return new Timeout(_parseDuration(args.first, path));
195 } else {
196 return new Timeout.factor(_parseNum(args.first, path));
197 }
198 }
199
200 /// Parses a `@Skip` annotation.
201 ///
202 /// [annotation] is the annotation. [constructorName] is the name of the named
203 /// constructor for the annotation, if any. [path] is the path to the file from
204 /// which the annotation was parsed.
205 ///
206 /// Returns either `true` or a reason string.
207 _parseSkip(Annotation annotation, String constructorName, String path) {
208 if (constructorName != null) {
209 throw new SourceSpanFormatException(
210 'Skip doesn\'t have a constructor named "$constructorName".',
211 _spanFor(annotation, path));
212 }
213
214 if (annotation.arguments == null) {
215 throw new SourceSpanFormatException(
216 'Skip must have parentheses.', _spanFor(annotation, path));
217 }
218
219 var args = annotation.arguments.arguments;
220 if (args.length > 1) {
221 throw new SourceSpanFormatException(
222 'Skip takes zero arguments or one argument.',
223 _spanFor(annotation.arguments, path));
224 }
225
226 if (args.isEmpty) return true;
227
228 if (args.first is NamedExpression) {
229 throw new SourceSpanFormatException(
230 "Skip doesn't take named parameters.", _spanFor(args.first, path));
231 }
232
233 if (args.first is! StringLiteral) {
234 throw new SourceSpanFormatException(
235 "Skip takes a String.", _spanFor(args.first, path));
236 }
237
238 return args.first.stringValue;
239 }
240
241 /// Parses a `const Duration` expression.
242 Duration _parseDuration(Expression expression, String path) {
243 if (expression is! InstanceCreationExpression) {
244 throw new SourceSpanFormatException(
245 "Expected a Duration.",
246 _spanFor(expression, path));
247 }
248
249 var constructor = expression as InstanceCreationExpression;
250 if (constructor.constructorName.type.name.name != 'Duration') {
251 throw new SourceSpanFormatException(
252 "Expected a Duration.",
253 _spanFor(constructor, path));
254 }
255
256 if (constructor.keyword.lexeme != "const") {
257 throw new SourceSpanFormatException(
258 "Duration must use a const constructor.",
259 _spanFor(constructor, path));
260 }
261
262 if (constructor.constructorName.name != null) {
263 throw new SourceSpanFormatException(
264 "Duration doesn't have a constructor named "
265 '"${constructor.constructorName}".',
266 _spanFor(constructor.constructorName, path));
267 }
268
269 var values = {};
270 var args = constructor.argumentList.arguments;
271 for (var argument in args) {
272 if (argument is! NamedExpression) {
273 throw new SourceSpanFormatException(
274 "Duration doesn't take positional arguments.",
275 _spanFor(argument, path));
276 }
277
278 var name = argument.name.label.name;
279 if (!_durationArgs.contains(name)) {
280 throw new SourceSpanFormatException(
281 'Duration doesn\'t take an argument named "$name".',
282 _spanFor(argument, path));
283 }
284
285 if (values.containsKey(name)) {
286 throw new SourceSpanFormatException(
287 'An argument named "$name" was already passed.',
288 _spanFor(argument, path));
289 }
290
291 values[name] = _parseInt(argument.expression, path);
292 }
293
294 return new Duration(
295 days: values["days"] == null ? 0 : values["days"],
296 hours: values["hours"] == null ? 0 : values["hours"],
297 minutes: values["minutes"] == null ? 0 : values["minutes"],
298 seconds: values["seconds"] == null ? 0 : values["seconds"],
299 milliseconds: values["milliseconds"] == null ? 0 : values["milliseconds"],
300 microseconds:
301 values["microseconds"] == null ? 0 : values["microseconds"]);
302 }
303
304 /// Parses a constant number literal.
305 num _parseNum(Expression expression, String path) {
306 if (expression is IntegerLiteral) return expression.value;
307 if (expression is DoubleLiteral) return expression.value;
308 throw new SourceSpanFormatException(
309 "Expected a number.", _spanFor(expression, path));
310 }
311
312 /// Parses a constant int literal.
313 int _parseInt(Expression expression, String path) {
314 if (expression is IntegerLiteral) return expression.value;
315 throw new SourceSpanFormatException(
316 "Expected an integer.", _spanFor(expression, path));
317 }
318
319 /// Creates a [SourceSpan] for [node].
320 SourceSpan _spanFor(AstNode node, String path) =>
321 // Load a SourceFile from scratch here since we're only ever going to emit 339 // Load a SourceFile from scratch here since we're only ever going to emit
322 // one error per file anyway. 340 // one error per file anyway.
323 new SourceFile(new File(path).readAsStringSync(), url: p.toUri(path)) 341 var contents = new File(_path).readAsStringSync();
342 return new SourceFile(contents, url: p.toUri(_path))
324 .span(node.offset, node.end); 343 .span(node.offset, node.end);
344 }
345 }
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