OLD | NEW |
| (Empty) |
1 #!/usr/bin/env python | |
2 # Copyright (c) 2011 Google Inc. All rights reserved. | |
3 # | |
4 # Redistribution and use in source and binary forms, with or without | |
5 # modification, are permitted provided that the following conditions are | |
6 # met: | |
7 # | |
8 # * Redistributions of source code must retain the above copyright | |
9 # notice, this list of conditions and the following disclaimer. | |
10 # * Redistributions in binary form must reproduce the above | |
11 # copyright notice, this list of conditions and the following disclaimer | |
12 # in the documentation and/or other materials provided with the | |
13 # distribution. | |
14 # * Neither the name of Google Inc. nor the names of its | |
15 # contributors may be used to endorse or promote products derived from | |
16 # this software without specific prior written permission. | |
17 # | |
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
29 # | |
30 # Inspector protocol validator. | |
31 # | |
32 # Tests that subsequent protocol changes are not breaking backwards compatibilit
y. | |
33 # Following violations are reported: | |
34 # | |
35 # - Domain has been removed | |
36 # - Command has been removed | |
37 # - Required command parameter was added or changed from optional | |
38 # - Required response parameter was removed or changed to optional | |
39 # - Event has been removed | |
40 # - Required event parameter was removed or changed to optional | |
41 # - Parameter type has changed. | |
42 # | |
43 # For the parameters with composite types the above checks are also applied | |
44 # recursively to every property of the type. | |
45 # | |
46 # Adding --show_changes to the command line prints out a list of valid public AP
I changes. | |
47 | |
48 import collections | |
49 import copy | |
50 import os.path | |
51 import optparse | |
52 import re | |
53 import sys | |
54 | |
55 try: | |
56 import json | |
57 except ImportError: | |
58 import simplejson as json | |
59 | |
60 def list_to_map(items, key): | |
61 result = {} | |
62 for item in items: | |
63 if not "experimental" in item and not "hidden" in item: | |
64 result[item[key]] = item | |
65 return result | |
66 | |
67 | |
68 def named_list_to_map(container, name, key): | |
69 if name in container: | |
70 return list_to_map(container[name], key) | |
71 return {} | |
72 | |
73 | |
74 def removed(reverse): | |
75 if reverse: | |
76 return "added" | |
77 return "removed" | |
78 | |
79 | |
80 def required(reverse): | |
81 if reverse: | |
82 return "optional" | |
83 return "required" | |
84 | |
85 | |
86 def compare_schemas(d_1, d_2, reverse): | |
87 errors = [] | |
88 domains_1 = copy.deepcopy(d_1) | |
89 domains_2 = copy.deepcopy(d_2) | |
90 types_1 = normalize_types_in_schema(domains_1) | |
91 types_2 = normalize_types_in_schema(domains_2) | |
92 | |
93 domains_by_name_1 = list_to_map(domains_1, "domain") | |
94 domains_by_name_2 = list_to_map(domains_2, "domain") | |
95 | |
96 for name in domains_by_name_1: | |
97 domain_1 = domains_by_name_1[name] | |
98 if not name in domains_by_name_2: | |
99 errors.append("%s: domain has been %s" % (name, removed(reverse))) | |
100 continue | |
101 compare_domains(domain_1, domains_by_name_2[name], types_1, types_2, err
ors, reverse) | |
102 return errors | |
103 | |
104 | |
105 def compare_domains(domain_1, domain_2, types_map_1, types_map_2, errors, revers
e): | |
106 domain_name = domain_1["domain"] | |
107 commands_1 = named_list_to_map(domain_1, "commands", "name") | |
108 commands_2 = named_list_to_map(domain_2, "commands", "name") | |
109 for name in commands_1: | |
110 command_1 = commands_1[name] | |
111 if not name in commands_2: | |
112 errors.append("%s.%s: command has been %s" % (domain_1["domain"], na
me, removed(reverse))) | |
113 continue | |
114 compare_commands(domain_name, command_1, commands_2[name], types_map_1,
types_map_2, errors, reverse) | |
115 | |
116 events_1 = named_list_to_map(domain_1, "events", "name") | |
117 events_2 = named_list_to_map(domain_2, "events", "name") | |
118 for name in events_1: | |
119 event_1 = events_1[name] | |
120 if not name in events_2: | |
121 errors.append("%s.%s: event has been %s" % (domain_1["domain"], name
, removed(reverse))) | |
122 continue | |
123 compare_events(domain_name, event_1, events_2[name], types_map_1, types_
map_2, errors, reverse) | |
124 | |
125 | |
126 def compare_commands(domain_name, command_1, command_2, types_map_1, types_map_2
, errors, reverse): | |
127 context = domain_name + "." + command_1["name"] | |
128 | |
129 params_1 = named_list_to_map(command_1, "parameters", "name") | |
130 params_2 = named_list_to_map(command_2, "parameters", "name") | |
131 # Note the reversed order: we allow removing but forbid adding parameters. | |
132 compare_params_list(context, "parameter", params_2, params_1, types_map_2, t
ypes_map_1, 0, errors, not reverse) | |
133 | |
134 returns_1 = named_list_to_map(command_1, "returns", "name") | |
135 returns_2 = named_list_to_map(command_2, "returns", "name") | |
136 compare_params_list(context, "response parameter", returns_1, returns_2, typ
es_map_1, types_map_2, 0, errors, reverse) | |
137 | |
138 | |
139 def compare_events(domain_name, event_1, event_2, types_map_1, types_map_2, erro
rs, reverse): | |
140 context = domain_name + "." + event_1["name"] | |
141 params_1 = named_list_to_map(event_1, "parameters", "name") | |
142 params_2 = named_list_to_map(event_2, "parameters", "name") | |
143 compare_params_list(context, "parameter", params_1, params_2, types_map_1, t
ypes_map_2, 0, errors, reverse) | |
144 | |
145 | |
146 def compare_params_list(context, kind, params_1, params_2, types_map_1, types_ma
p_2, depth, errors, reverse): | |
147 for name in params_1: | |
148 param_1 = params_1[name] | |
149 if not name in params_2: | |
150 if not "optional" in param_1: | |
151 errors.append("%s.%s: required %s has been %s" % (context, name,
kind, removed(reverse))) | |
152 continue | |
153 | |
154 param_2 = params_2[name] | |
155 if param_2 and "optional" in param_2 and not "optional" in param_1: | |
156 errors.append("%s.%s: %s %s is now %s" % (context, name, required(re
verse), kind, required(not reverse))) | |
157 continue | |
158 type_1 = extract_type(param_1, types_map_1, errors) | |
159 type_2 = extract_type(param_2, types_map_2, errors) | |
160 compare_types(context + "." + name, kind, type_1, type_2, types_map_1, t
ypes_map_2, depth, errors, reverse) | |
161 | |
162 | |
163 def compare_types(context, kind, type_1, type_2, types_map_1, types_map_2, depth
, errors, reverse): | |
164 if depth > 10: | |
165 return | |
166 | |
167 base_type_1 = type_1["type"] | |
168 base_type_2 = type_2["type"] | |
169 | |
170 if base_type_1 != base_type_2: | |
171 errors.append("%s: %s base type mismatch, '%s' vs '%s'" % (context, kind
, base_type_1, base_type_2)) | |
172 elif base_type_1 == "object": | |
173 params_1 = named_list_to_map(type_1, "properties", "name") | |
174 params_2 = named_list_to_map(type_2, "properties", "name") | |
175 # If both parameters have the same named type use it in the context. | |
176 if "id" in type_1 and "id" in type_2 and type_1["id"] == type_2["id"]: | |
177 type_name = type_1["id"] | |
178 else: | |
179 type_name = "<object>" | |
180 context += " %s->%s" % (kind, type_name) | |
181 compare_params_list(context, "property", params_1, params_2, types_map_1
, types_map_2, depth + 1, errors, reverse) | |
182 elif base_type_1 == "array": | |
183 item_type_1 = extract_type(type_1["items"], types_map_1, errors) | |
184 item_type_2 = extract_type(type_2["items"], types_map_2, errors) | |
185 compare_types(context, kind, item_type_1, item_type_2, types_map_1, type
s_map_2, depth + 1, errors, reverse) | |
186 | |
187 | |
188 def extract_type(typed_object, types_map, errors): | |
189 if "type" in typed_object: | |
190 result = { "id": "<transient>", "type": typed_object["type"] } | |
191 if typed_object["type"] == "object": | |
192 result["properties"] = [] | |
193 elif typed_object["type"] == "array": | |
194 result["items"] = typed_object["items"] | |
195 return result | |
196 elif "$ref" in typed_object: | |
197 ref = typed_object["$ref"] | |
198 if not ref in types_map: | |
199 errors.append("Can not resolve type: %s" % ref) | |
200 types_map[ref] = { "id": "<transient>", "type": "object" } | |
201 return types_map[ref] | |
202 | |
203 | |
204 def normalize_types_in_schema(domains): | |
205 types = {} | |
206 for domain in domains: | |
207 domain_name = domain["domain"] | |
208 normalize_types(domain, domain_name, types) | |
209 return types | |
210 | |
211 | |
212 def normalize_types(obj, domain_name, types): | |
213 if isinstance(obj, list): | |
214 for item in obj: | |
215 normalize_types(item, domain_name, types) | |
216 elif isinstance(obj, dict): | |
217 for key, value in obj.items(): | |
218 if key == "$ref" and value.find(".") == -1: | |
219 obj[key] = "%s.%s" % (domain_name, value) | |
220 elif key == "id": | |
221 obj[key] = "%s.%s" % (domain_name, value) | |
222 types[obj[key]] = obj | |
223 else: | |
224 normalize_types(value, domain_name, types) | |
225 | |
226 | |
227 def load_schema(file, domains): | |
228 if not os.path.isfile(file): | |
229 return | |
230 input_file = open(file, "r") | |
231 json_string = input_file.read() | |
232 parsed_json = json.loads(json_string) | |
233 domains += parsed_json["domains"] | |
234 return parsed_json["version"] | |
235 | |
236 | |
237 def self_test(): | |
238 def create_test_schema_1(): | |
239 return [ | |
240 { | |
241 "domain": "Network", | |
242 "types": [ | |
243 { | |
244 "id": "LoaderId", | |
245 "type": "string" | |
246 }, | |
247 { | |
248 "id": "Headers", | |
249 "type": "object" | |
250 }, | |
251 { | |
252 "id": "Request", | |
253 "type": "object", | |
254 "properties": [ | |
255 { "name": "url", "type": "string" }, | |
256 { "name": "method", "type": "string" }, | |
257 { "name": "headers", "$ref": "Headers" }, | |
258 { "name": "becameOptionalField", "type": "string" }, | |
259 { "name": "removedField", "type": "string" }, | |
260 ] | |
261 } | |
262 ], | |
263 "commands": [ | |
264 { | |
265 "name": "removedCommand", | |
266 }, | |
267 { | |
268 "name": "setExtraHTTPHeaders", | |
269 "parameters": [ | |
270 { "name": "headers", "$ref": "Headers" }, | |
271 { "name": "mismatched", "type": "string" }, | |
272 { "name": "becameOptional", "$ref": "Headers" }, | |
273 { "name": "removedRequired", "$ref": "Headers" }, | |
274 { "name": "becameRequired", "$ref": "Headers", "optional
": True }, | |
275 { "name": "removedOptional", "$ref": "Headers", "optiona
l": True }, | |
276 ], | |
277 "returns": [ | |
278 { "name": "mimeType", "type": "string" }, | |
279 { "name": "becameOptional", "type": "string" }, | |
280 { "name": "removedRequired", "type": "string" }, | |
281 { "name": "becameRequired", "type": "string", "optional"
: True }, | |
282 { "name": "removedOptional", "type": "string", "optional
": True }, | |
283 ] | |
284 } | |
285 ], | |
286 "events": [ | |
287 { | |
288 "name": "requestWillBeSent", | |
289 "parameters": [ | |
290 { "name": "frameId", "type": "string", "experimental": T
rue }, | |
291 { "name": "request", "$ref": "Request" }, | |
292 { "name": "becameOptional", "type": "string" }, | |
293 { "name": "removedRequired", "type": "string" }, | |
294 { "name": "becameRequired", "type": "string", "optional"
: True }, | |
295 { "name": "removedOptional", "type": "string", "optional
": True }, | |
296 ] | |
297 }, | |
298 { | |
299 "name": "removedEvent", | |
300 "parameters": [ | |
301 { "name": "errorText", "type": "string" }, | |
302 { "name": "canceled", "type": "boolean", "optional": Tru
e } | |
303 ] | |
304 } | |
305 ] | |
306 }, | |
307 { | |
308 "domain": "removedDomain" | |
309 } | |
310 ] | |
311 | |
312 def create_test_schema_2(): | |
313 return [ | |
314 { | |
315 "domain": "Network", | |
316 "types": [ | |
317 { | |
318 "id": "LoaderId", | |
319 "type": "string" | |
320 }, | |
321 { | |
322 "id": "Request", | |
323 "type": "object", | |
324 "properties": [ | |
325 { "name": "url", "type": "string" }, | |
326 { "name": "method", "type": "string" }, | |
327 { "name": "headers", "type": "object" }, | |
328 { "name": "becameOptionalField", "type": "string", "opti
onal": True }, | |
329 ] | |
330 } | |
331 ], | |
332 "commands": [ | |
333 { | |
334 "name": "addedCommand", | |
335 }, | |
336 { | |
337 "name": "setExtraHTTPHeaders", | |
338 "parameters": [ | |
339 { "name": "headers", "type": "object" }, | |
340 { "name": "mismatched", "type": "object" }, | |
341 { "name": "becameOptional", "type": "object" , "optional
": True }, | |
342 { "name": "addedRequired", "type": "object" }, | |
343 { "name": "becameRequired", "type": "object" }, | |
344 { "name": "addedOptional", "type": "object", "optional":
True }, | |
345 ], | |
346 "returns": [ | |
347 { "name": "mimeType", "type": "string" }, | |
348 { "name": "becameOptional", "type": "string", "optional"
: True }, | |
349 { "name": "addedRequired", "type": "string"}, | |
350 { "name": "becameRequired", "type": "string" }, | |
351 { "name": "addedOptional", "type": "string", "optional":
True }, | |
352 ] | |
353 } | |
354 ], | |
355 "events": [ | |
356 { | |
357 "name": "requestWillBeSent", | |
358 "parameters": [ | |
359 { "name": "request", "$ref": "Request" }, | |
360 { "name": "becameOptional", "type": "string", "optional"
: True }, | |
361 { "name": "addedRequired", "type": "string"}, | |
362 { "name": "becameRequired", "type": "string" }, | |
363 { "name": "addedOptional", "type": "string", "optional":
True }, | |
364 ] | |
365 }, | |
366 { | |
367 "name": "addedEvent" | |
368 } | |
369 ] | |
370 }, | |
371 { | |
372 "domain": "addedDomain" | |
373 } | |
374 ] | |
375 | |
376 expected_errors = [ | |
377 "removedDomain: domain has been removed", | |
378 "Network.removedCommand: command has been removed", | |
379 "Network.removedEvent: event has been removed", | |
380 "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, '
object' vs 'string'", | |
381 "Network.setExtraHTTPHeaders.addedRequired: required parameter has been
added", | |
382 "Network.setExtraHTTPHeaders.becameRequired: optional parameter is now r
equired", | |
383 "Network.setExtraHTTPHeaders.removedRequired: required response paramete
r has been removed", | |
384 "Network.setExtraHTTPHeaders.becameOptional: required response parameter
is now optional", | |
385 "Network.requestWillBeSent.removedRequired: required parameter has been
removed", | |
386 "Network.requestWillBeSent.becameOptional: required parameter is now opt
ional", | |
387 "Network.requestWillBeSent.request parameter->Network.Request.removedFie
ld: required property has been removed", | |
388 "Network.requestWillBeSent.request parameter->Network.Request.becameOpti
onalField: required property is now optional", | |
389 ] | |
390 | |
391 expected_errors_reverse = [ | |
392 "addedDomain: domain has been added", | |
393 "Network.addedEvent: event has been added", | |
394 "Network.addedCommand: command has been added", | |
395 "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, 's
tring' vs 'object'", | |
396 "Network.setExtraHTTPHeaders.removedRequired: required parameter has been
removed", | |
397 "Network.setExtraHTTPHeaders.becameOptional: required parameter is now op
tional", | |
398 "Network.setExtraHTTPHeaders.addedRequired: required response parameter h
as been added", | |
399 "Network.setExtraHTTPHeaders.becameRequired: optional response parameter
is now required", | |
400 "Network.requestWillBeSent.becameRequired: optional parameter is now requ
ired", | |
401 "Network.requestWillBeSent.addedRequired: required parameter has been add
ed", | |
402 ] | |
403 | |
404 def is_subset(subset, superset, message): | |
405 for i in range(len(subset)): | |
406 if subset[i] not in superset: | |
407 sys.stderr.write("%s error: %s\n" % (message, subset[i])) | |
408 return False | |
409 return True | |
410 | |
411 def errors_match(expected, actual): | |
412 return (is_subset(actual, expected, "Unexpected") and | |
413 is_subset(expected, actual, "Missing")) | |
414 | |
415 return (errors_match(expected_errors, | |
416 compare_schemas(create_test_schema_1(), create_test_sch
ema_2(), False)) and | |
417 errors_match(expected_errors_reverse, | |
418 compare_schemas(create_test_schema_2(), create_test_sch
ema_1(), True))) | |
419 | |
420 | |
421 | |
422 def load_domains_and_baselines(file, domains, baseline_domains): | |
423 version = load_schema(os.path.normpath(file), domains) | |
424 suffix = "-%s.%s.json" % (version["major"], version["minor"]) | |
425 baseline_file = file.replace(".json", suffix) | |
426 load_schema(os.path.normpath(baseline_file), baseline_domains) | |
427 return version | |
428 | |
429 | |
430 def main(): | |
431 if not self_test(): | |
432 sys.stderr.write("Self-test failed") | |
433 return 1 | |
434 | |
435 cmdline_parser = optparse.OptionParser() | |
436 cmdline_parser.add_option("--show_changes") | |
437 cmdline_parser.add_option("--o") | |
438 arg_options, arg_values = cmdline_parser.parse_args() | |
439 | |
440 if len(arg_values) < 1 or not arg_options.o: | |
441 sys.stderr.write("Usage: %s --o OUTPUT_FILE [--show_changes] PROTOCOL_FO
LDER1 ?PROTOCOL_FOLDER2 \n" % sys.argv[0]) | |
442 return 1 | |
443 | |
444 output_path = arg_options.o | |
445 output_file = open(output_path, "w") | |
446 | |
447 domains = [] | |
448 baseline_domains = [] | |
449 version = load_domains_and_baselines(arg_values[0], domains, baseline_domain
s) | |
450 if len(arg_values) > 1: | |
451 load_domains_and_baselines(arg_values[1], domains, baseline_domains) | |
452 | |
453 expected_errors = [ | |
454 "Debugger.globalObjectCleared: event has been removed", | |
455 "Runtime.executionContextCreated.context parameter->Runtime.ExecutionCon
textDescription.frameId: required property has been removed", | |
456 "Debugger.canSetScriptSource: command has been removed", | |
457 "Console.messageRepeatCountUpdated: event has been removed", | |
458 "Console.messagesCleared: event has been removed" | |
459 ] | |
460 | |
461 errors = compare_schemas(baseline_domains, domains, False) | |
462 unexpected_errors = [] | |
463 for i in range(len(errors)): | |
464 if errors[i] not in expected_errors: | |
465 unexpected_errors.append(errors[i]) | |
466 if len(unexpected_errors) > 0: | |
467 sys.stderr.write(" Compatibility checks FAILED\n") | |
468 for error in unexpected_errors: | |
469 sys.stderr.write( " %s\n" % error) | |
470 return 1 | |
471 | |
472 if arg_options.show_changes: | |
473 changes = compare_schemas(domains, baseline_domains, True) | |
474 if len(changes) > 0: | |
475 print " Public changes since %s:" % version | |
476 for change in changes: | |
477 print " %s" % change | |
478 | |
479 json.dump({"version": version, "domains": domains}, output_file, indent=4, s
ort_keys=False, separators=(',', ': ')) | |
480 output_file.close() | |
481 | |
482 if __name__ == '__main__': | |
483 sys.exit(main()) | |
OLD | NEW |