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

Side by Side Diff: recipe_engine/doc.py

Issue 2856003002: [doc] fix doc to work in all known repos. (Closed)
Patch Set: fix other test Created 3 years, 7 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
OLDNEW
1 #!/usr/bin/env python 1 #!/usr/bin/env python
2 # Copyright 2013 The LUCI Authors. All rights reserved. 2 # Copyright 2013 The LUCI Authors. All rights reserved.
3 # Use of this source code is governed under the Apache License, Version 2.0 3 # Use of this source code is governed under the Apache License, Version 2.0
4 # that can be found in the LICENSE file. 4 # that can be found in the LICENSE file.
5 5
6 from __future__ import print_function, absolute_import 6 from __future__ import print_function, absolute_import
7 7
8 import ast 8 import ast
9 import inspect 9 import inspect
10 import json 10 import json
(...skipping 55 matching lines...) Expand 10 before | Expand all | Expand 10 after
66 astunparse.Unparser(node, buf) 66 astunparse.Unparser(node, buf)
67 return buf.getvalue() 67 return buf.getvalue()
68 68
69 69
70 def _find_value_of(mod_ast, target): 70 def _find_value_of(mod_ast, target):
71 """Looks for an assignment to `target`, returning the assignment value ast 71 """Looks for an assignment to `target`, returning the assignment value ast
72 node and the line number of the assignment. 72 node and the line number of the assignment.
73 """ 73 """
74 for node in mod_ast.body: 74 for node in mod_ast.body:
75 if isinstance(node, ast.Assign): 75 if isinstance(node, ast.Assign):
76 if len(node.targets) == 1 and node.targets[0].id == target: 76 if (len(node.targets) == 1 and
77 isinstance(node.targets[0], ast.Name) and
iannucci 2017/05/03 00:05:34 There are other sorts of nodes which can show up i
78 node.targets[0].id == target):
77 return node.value, node.lineno 79 return node.value, node.lineno
78 return None, None 80 return None, None
79 81
80 82
81 def _expand_mock_imports(*mock_imports): 83 def _expand_mock_imports(*mock_imports):
82 """Returns an expanded set of mock imports. 84 """Returns an expanded set of mock imports.
83 85
84 mock_imports is expected to be a dict which looks like: 86 mock_imports is expected to be a dict which looks like:
85 { 87 {
86 "absolute.module.name": SomeObject, 88 "absolute.module.name": SomeObject,
(...skipping 32 matching lines...) Expand 10 before | Expand all | Expand 10 after
119 expanded_imports[dotted_name+'.'+name] = getattr(obj, name) 121 expanded_imports[dotted_name+'.'+name] = getattr(obj, name)
120 for i in range(len(toks)-1, 0, -1): 122 for i in range(len(toks)-1, 0, -1):
121 partial = '.'.join(toks[:i]) 123 partial = '.'.join(toks[:i])
122 cur_obj = expanded_imports.setdefault(partial, expando()) 124 cur_obj = expanded_imports.setdefault(partial, expando())
123 if not isinstance(cur_obj, expando): 125 if not isinstance(cur_obj, expando):
124 raise ValueError('nested mock imports! %r', partial) 126 raise ValueError('nested mock imports! %r', partial)
125 setattr(cur_obj, toks[i], expanded_imports[partial+'.'+toks[i]]) 127 setattr(cur_obj, toks[i], expanded_imports[partial+'.'+toks[i]])
126 128
127 return expanded_imports 129 return expanded_imports
128 130
131 ALL_IMPORTS = {} # used in doc_test to ensure everything is actually importable
132 KNOWN_OBJECTS = {}
129 133
130 _decorator_imports = { 134 _decorator_imports = {
131 'recipe_engine.util.returns_placeholder': util.returns_placeholder, 135 'recipe_engine.util.returns_placeholder': util.returns_placeholder,
132 'recipe_engine.recipe_api.non_step': recipe_api.non_step, 136 'recipe_engine.recipe_api.non_step': recipe_api.non_step,
133 'recipe_engine.recipe_api.infer_composite_step': ( 137 'recipe_engine.recipe_api.infer_composite_step': (
134 recipe_api.infer_composite_step) 138 recipe_api.infer_composite_step)
135 } 139 }
140 KNOWN_OBJECTS.update(_decorator_imports)
136 141
137 _config_imports = { 142 _config_imports = {
138 'recipe_engine.config.ConfigGroup': config.ConfigGroup, 143 'recipe_engine.config.ConfigGroup': config.ConfigGroup,
139 'recipe_engine.config.ConfigList': config.ConfigList, 144 'recipe_engine.config.ConfigList': config.ConfigList,
140 'recipe_engine.config.Set': config.Set, 145 'recipe_engine.config.Set': config.Set,
141 'recipe_engine.config.Dict': config.Dict, 146 'recipe_engine.config.Dict': config.Dict,
142 'recipe_engine.config.List': config.List, 147 'recipe_engine.config.List': config.List,
143 'recipe_engine.config.Single': config.Single, 148 'recipe_engine.config.Single': config.Single,
144 'recipe_engine.config.Static': config.Static, 149 'recipe_engine.config.Static': config.Static,
145 'recipe_engine.config.Enum': config.Enum, 150 'recipe_engine.config.Enum': config.Enum,
146 } 151 }
152 KNOWN_OBJECTS.update(_config_imports)
147 153
148 _placeholder_imports = { 154 _placeholder_imports = {
149 'recipe_engine.util.OutputPlaceholder': util.OutputPlaceholder, 155 'recipe_engine.util.OutputPlaceholder': util.OutputPlaceholder,
150 'recipe_engine.util.InputPlaceholder': util.InputPlaceholder, 156 'recipe_engine.util.InputPlaceholder': util.InputPlaceholder,
151 'recipe_engine.util.Placeholder': util.Placeholder, 157 'recipe_engine.util.Placeholder': util.Placeholder,
152 } 158 }
159 KNOWN_OBJECTS.update(_placeholder_imports)
153 160
154 MOCK_IMPORTS_PARAMETERS = _expand_mock_imports({ 161 _property_imports = {
155 'recipe_engine.recipe_api.Property': recipe_api.Property, 162 'recipe_engine.recipe_api.Property': recipe_api.Property,
156 }, _config_imports) 163 }
164 KNOWN_OBJECTS.update(_property_imports)
157 165
158 MOCK_IMPORTS_RETURN_SCHEMA = _expand_mock_imports({ 166 _return_schema_imports = {
159 'recipe_engine.config.ReturnSchema': config.ReturnSchema, 167 'recipe_engine.config.ReturnSchema': config.ReturnSchema,
160 'recipe_engine.config.ConfigGroupSchema': config.ConfigGroupSchema, 168 'recipe_engine.config.ConfigGroupSchema': config.ConfigGroupSchema,
161 }, _config_imports) 169 }
170 KNOWN_OBJECTS.update(_return_schema_imports)
162 171
163 MOCK_IMPORTS_RECIPE = _expand_mock_imports({ 172 _util_imports = {
164 'recipe_engine.types.freeze': types.freeze, 173 'recipe_engine.types.freeze': types.freeze,
165 }, _decorator_imports, _placeholder_imports) 174 }
175 KNOWN_OBJECTS.update(_util_imports)
166 176
167 MOCK_IMPORTS_MODULE = _expand_mock_imports({ 177 _recipe_api_class_imports = {
168 'recipe_engine.recipe_api.RecipeApi': recipe_api.RecipeApi, 178 'recipe_engine.recipe_api.RecipeApi': recipe_api.RecipeApi,
169 'recipe_engine.recipe_api.RecipeApiPlain': recipe_api.RecipeApiPlain, 179 'recipe_engine.recipe_api.RecipeApiPlain': recipe_api.RecipeApiPlain,
170 }, _decorator_imports, _placeholder_imports) 180 }
171 181 KNOWN_OBJECTS.update(_recipe_api_class_imports)
iannucci 2017/05/03 00:05:34 This change allows the 'known_objects' section of
172 ALL_IMPORTS = {}
173 ALL_IMPORTS.update(MOCK_IMPORTS_PARAMETERS)
174 ALL_IMPORTS.update(MOCK_IMPORTS_RETURN_SCHEMA)
175 ALL_IMPORTS.update(MOCK_IMPORTS_RECIPE)
176 ALL_IMPORTS.update(MOCK_IMPORTS_MODULE)
177 182
178 183
179 def _parse_mock_imports(mod_ast, expanded_imports): 184 def _parse_mock_imports(mod_ast, expanded_imports):
180 """Parses a module ast node for import statements and resolves them against 185 """Parses a module ast node for import statements and resolves them against
181 expanded_imports (such as you might get from _expand_mock_imports). 186 expanded_imports (such as you might get from _expand_mock_imports).
182 187
183 If an import is not recognized, it is omitted from the returned dictionary. 188 If an import is not recognized, it is omitted from the returned dictionary.
184 189
185 Returns a dictionary suitable for eval'ing a statement in mod_ast, with 190 Returns a dictionary suitable for eval'ing a statement in mod_ast, with
186 symbols from mod_ast's imports resolved to real objects, as per 191 symbols from mod_ast's imports resolved to real objects, as per
(...skipping 83 matching lines...) Expand 10 before | Expand all | Expand 10 after
270 relpath=relpath, 275 relpath=relpath,
271 lineno=lineno, 276 lineno=lineno,
272 ) 277 )
273 spec = uv.normalize_deps_spec(ast.literal_eval(_unparse(DEPS))) 278 spec = uv.normalize_deps_spec(ast.literal_eval(_unparse(DEPS)))
274 for pkg, mod_name in sorted(spec.itervalues()): 279 for pkg, mod_name in sorted(spec.itervalues()):
275 ret.module_links.add(package=pkg.name, name=mod_name) 280 ret.module_links.add(package=pkg.name, name=mod_name)
276 281
277 return ret 282 return ret
278 283
279 284
285 def extract_jsonish_assignments(mod_ast):
286 ret = {}
287 for node in mod_ast.body:
288 if not isinstance(node, ast.Assign):
289 continue
290 if len(node.targets) != 1:
291 continue
292 if not isinstance(node.targets[0], ast.Name):
293 continue
294 try:
295 ret[node.targets[0].id] = ast.literal_eval(node.value)
296 except (KeyError, ValueError):
297 pass
298 return ret
299
300
280 def parse_parameter(param): 301 def parse_parameter(param):
281 assert isinstance(param, recipe_api.Property), type(param) 302 assert isinstance(param, recipe_api.Property), type(param)
282 default = None 303 default = None
283 if param._default is not recipe_api.PROPERTY_SENTINEL: 304 if param._default is not recipe_api.PROPERTY_SENTINEL:
284 default = json.dumps(param._default) 305 default = json.dumps(param._default)
285 306
286 return doc.Doc.Parameter( 307 return doc.Doc.Parameter(
287 docstring=param.help, 308 docstring=param.help,
288 kind=param.kind.schema_proto() if param.kind else None, 309 kind=param.kind.schema_proto() if param.kind else None,
289 default_json=default) 310 default_json=default)
290 311
291 312
313 MOCK_IMPORTS_PARAMETERS = _expand_mock_imports(
314 _property_imports, _config_imports)
315 ALL_IMPORTS.update(MOCK_IMPORTS_PARAMETERS)
316
317
292 def parse_parameters(mod_ast, relpath): 318 def parse_parameters(mod_ast, relpath):
293 parameters, lineno = _find_value_of(mod_ast, 'PROPERTIES') 319 parameters, lineno = _find_value_of(mod_ast, 'PROPERTIES')
294 if not parameters: 320 if not parameters:
295 return None 321 return None
296 322
297 imports = _parse_mock_imports(mod_ast, MOCK_IMPORTS_PARAMETERS) 323 imports = _parse_mock_imports(mod_ast, MOCK_IMPORTS_PARAMETERS)
324 imports.update(extract_jsonish_assignments(mod_ast))
298 data = eval(_unparse(parameters), imports) 325 data = eval(_unparse(parameters), imports)
299 if not data: 326 if not data:
300 return None 327 return None
301 328
302 for k, v in sorted(data.iteritems()): 329 for k, v in sorted(data.iteritems()):
303 data[k] = parse_parameter(v) 330 data[k] = parse_parameter(v)
304 331
305 return doc.Doc.Parameters(relpath=relpath, lineno=lineno, parameters=data) 332 return doc.Doc.Parameters(relpath=relpath, lineno=lineno, parameters=data)
306 333
307 334
308 def parse_func(func_node, relpath, imports): 335 def parse_func(func_node, relpath, imports):
309 ret = doc.Doc.Func( 336 ret = doc.Doc.Func(
310 name=func_node.name, 337 name=func_node.name,
311 relpath=relpath, 338 relpath=relpath,
312 lineno=func_node.lineno, 339 lineno=func_node.lineno,
313 docstring=ast.get_docstring(func_node) or '', 340 docstring=ast.get_docstring(func_node) or '',
314 ) 341 )
315 342
316 for exp in func_node.decorator_list: 343 for exp in func_node.decorator_list:
317 item = _apply_imports_to_unparsed_expression(exp, imports) 344 item = _apply_imports_to_unparsed_expression(exp, imports)
318 if isinstance(item, str): 345 if isinstance(item, str):
319 ret.decorators.add(generic=item) 346 ret.decorators.add(generic=item)
320 else: 347 else:
321 ret.decorators.add(known=item.__module__+'.'+item.__name__) 348 ret.decorators.add(known=item.__module__+'.'+item.__name__)
322 349
323 ret.signature = _unparse(func_node.args).strip() 350 ret.signature = _unparse(func_node.args).strip()
324 return ret 351 return ret
325 352
326 353
354 MOCK_IMPORTS_RETURN_SCHEMA = _expand_mock_imports(
355 _return_schema_imports, _config_imports)
356 ALL_IMPORTS.update(MOCK_IMPORTS_RETURN_SCHEMA)
357
358
327 def parse_return_schema(mod_ast, relpath): 359 def parse_return_schema(mod_ast, relpath):
328 imports = _parse_mock_imports(mod_ast, MOCK_IMPORTS_RETURN_SCHEMA) 360 imports = _parse_mock_imports(mod_ast, MOCK_IMPORTS_RETURN_SCHEMA)
329 schema, lineno = _find_value_of(mod_ast, 'RETURN_SCHEMA') 361 schema, lineno = _find_value_of(mod_ast, 'RETURN_SCHEMA')
330 if not schema: 362 if not schema:
331 return None 363 return None
332 schema = eval(_unparse(schema), imports) 364 schema = eval(_unparse(schema), imports)
333 if not schema: 365 if not schema:
334 return None 366 return None
335 return doc.Doc.ReturnSchema(relpath=relpath, lineno=lineno, 367 return doc.Doc.ReturnSchema(relpath=relpath, lineno=lineno,
336 schema=schema.schema_proto()) 368 schema=schema.schema_proto())
337 369
338 370
371 MOCK_IMPORTS_RECIPE = _expand_mock_imports(
372 _util_imports, _decorator_imports, _placeholder_imports)
373 ALL_IMPORTS.update(MOCK_IMPORTS_RECIPE)
374
375
339 def parse_recipe(uv, base_dir, relpath, recipe_name): 376 def parse_recipe(uv, base_dir, relpath, recipe_name):
340 recipe = _grab_ast(base_dir, relpath) 377 recipe = _grab_ast(base_dir, relpath)
341 if not recipe: 378 if not recipe:
342 return None 379 return None
343 classes, funcs = _extract_classes_funcs(recipe, relpath, MOCK_IMPORTS_RECIPE) 380 classes, funcs = _extract_classes_funcs(recipe, relpath, MOCK_IMPORTS_RECIPE)
344 funcs.pop('GenTests', None) 381 funcs.pop('GenTests', None)
345 382
346 # TODO(iannucci): parse RequireClients 383 # TODO(iannucci): parse RequireClients
347 384
348 return doc.Doc.Recipe( 385 return doc.Doc.Recipe(
349 name=recipe_name, 386 name=recipe_name,
350 relpath=relpath, 387 relpath=relpath,
351 docstring=ast.get_docstring(recipe) or '', 388 docstring=ast.get_docstring(recipe) or '',
352 deps=parse_deps(uv, recipe, relpath), 389 deps=parse_deps(uv, recipe, relpath),
353 parameters=parse_parameters(recipe, relpath), 390 parameters=parse_parameters(recipe, relpath),
354 return_schema=parse_return_schema(recipe, relpath), 391 return_schema=parse_return_schema(recipe, relpath),
355 classes=classes, 392 classes=classes,
356 funcs=funcs, 393 funcs=funcs,
357 ) 394 )
358 395
359 396
397 MOCK_IMPORTS_MODULE = _expand_mock_imports(
398 _recipe_api_class_imports, _decorator_imports, _placeholder_imports)
399 ALL_IMPORTS.update(MOCK_IMPORTS_MODULE)
400
401
360 def parse_module(uv, base_dir, relpath, mod_name): 402 def parse_module(uv, base_dir, relpath, mod_name):
361 native_relpath = _to_native(relpath) 403 native_relpath = _to_native(relpath)
362 404
363 api_relpath = relpath + '/api.py' 405 api_relpath = relpath + '/api.py'
364 api = _grab_ast(base_dir, _to_native(api_relpath)) 406 api = _grab_ast(base_dir, _to_native(api_relpath))
365 if not api: 407 if not api:
366 return None 408 return None
367 409
368 init_relpath = os.path.join(native_relpath, '__init__.py') 410 init_relpath = os.path.join(native_relpath, '__init__.py')
369 init = _grab_ast(base_dir, init_relpath) 411 init = _grab_ast(base_dir, init_relpath)
370 if not init: 412 if not init:
371 return None 413 return None
372 414
373 imports = _parse_mock_imports(api, MOCK_IMPORTS_MODULE) 415 imports = _parse_mock_imports(api, MOCK_IMPORTS_MODULE)
374 classes, funcs = _extract_classes_funcs(api, api_relpath, imports) 416 classes, funcs = _extract_classes_funcs(api, api_relpath, imports)
375 417
376 api_class = None 418 api_class = None
377 for name, val in sorted(classes.iteritems()): 419 for name, val in sorted(classes.iteritems()):
378 if any(b.known in KNOWN_RECIPE_API_BASES for b in val.bases): 420 if any(b.known in _recipe_api_class_imports for b in val.bases):
379 api_class = classes.pop(name) 421 api_class = classes.pop(name)
380 break 422 break
381 if not api_class: 423 if not api_class:
382 LOGGER.error('could not determine main RecipeApi class: %r', relpath) 424 LOGGER.error('could not determine main RecipeApi class: %r', relpath)
383 return None 425 return None
384 426
385 # TODO(iannucci): bundle_extra_paths.txt 427 # TODO(iannucci): bundle_extra_paths.txt
386 428
387 return doc.Doc.Module( 429 return doc.Doc.Module(
388 name=mod_name, 430 name=mod_name,
(...skipping 24 matching lines...) Expand all
413 455
414 for recipe_path, recipe_name in uv.loop_over_recipes(): 456 for recipe_path, recipe_name in uv.loop_over_recipes():
415 relpath = posixpath.relpath(recipe_path, base_dir) 457 relpath = posixpath.relpath(recipe_path, base_dir)
416 recipe = parse_recipe(uv, base_dir, relpath, recipe_name) 458 recipe = parse_recipe(uv, base_dir, relpath, recipe_name)
417 if recipe: 459 if recipe:
418 ret.recipes[recipe_name].CopyFrom(recipe) 460 ret.recipes[recipe_name].CopyFrom(recipe)
419 461
420 return ret 462 return ret
421 463
422 464
423 KNOWN_RECIPE_API_BASES = {
424 'recipe_engine.recipe_api.RecipeApi': recipe_api.RecipeApi,
425 'recipe_engine.recipe_api.RecipeApiPlain': recipe_api.RecipeApiPlain,
426 }
427
428
429 KNOWN_OBJECTS = {
430 'recipe_engine.recipe_api.non_step': recipe_api.non_step,
431 'recipe_engine.recipe_api.infer_composite_step': (
432 recipe_api.infer_composite_step),
433
434 'recipe_engine.util.returns_placeholder': util.returns_placeholder,
435 }
436 KNOWN_OBJECTS.update(KNOWN_RECIPE_API_BASES)
437
438
439 RECIPE_ENGINE_URL = 'https://github.com/luci/recipes-py' 465 RECIPE_ENGINE_URL = 'https://github.com/luci/recipes-py'
440 466
441 467
442 def _set_known_objects(base): 468 def _set_known_objects(base):
443 source_cache = {} 469 source_cache = {}
444 470
471 def _add_it(key, fname, target):
472 relpath = os.path.relpath(fname, RECIPE_ENGINE_BASE)
473 for node in source_cache[fname].body:
474 if isinstance(node, ast.ClassDef) and node.name == target:
475 base.known_objects[key].klass.CopyFrom(parse_class(node, relpath, {}))
476 return
477 elif isinstance(node, ast.FunctionDef) and node.name == target:
478 base.known_objects[key].func.CopyFrom(parse_func(node, relpath, {}))
479 return
480 elif isinstance(node, ast.Assign) and node.targets[0].id == target:
481 # This is an alias in the form of:
482 # Target = RealImplementation
483 _add_it(key, fname, node.value.id)
iannucci 2017/05/03 00:05:34 This is necessary for some of the type aliases in
484 return
485
486 raise ValueError('could not find %r in %r' % (key, relpath))
487
445 for k, v in KNOWN_OBJECTS.iteritems(): 488 for k, v in KNOWN_OBJECTS.iteritems():
446 base.known_objects[k].url = RECIPE_ENGINE_URL 489 base.known_objects[k].url = RECIPE_ENGINE_URL
447 _, target = k.rsplit('.', 1) 490 _, target = k.rsplit('.', 1)
448 fname = inspect.getsourcefile(v) 491 fname = inspect.getsourcefile(v)
449 if fname not in source_cache: 492 if fname not in source_cache:
450 # we load and cache the whole source file so that ast.parse gets the right 493 # we load and cache the whole source file so that ast.parse gets the right
451 # line numbers for all the definitions. 494 # line numbers for all the definitions.
452 source_lines, _ = inspect.findsource(v) 495 source_lines, _ = inspect.findsource(v)
453 source_cache[fname] = ast.parse(''.join(source_lines), fname) 496 source_cache[fname] = ast.parse(''.join(source_lines), fname)
454 497
455 relpath = os.path.relpath(fname, RECIPE_ENGINE_BASE) 498 _add_it(k, fname, target)
456 for node in source_cache[fname].body:
457 if isinstance(node, ast.ClassDef) and node.name == target:
458 base.known_objects[k].klass.CopyFrom(parse_class(node, relpath, {}))
459 break
460 elif isinstance(node, ast.FunctionDef) and node.name == target:
461 base.known_objects[k].func.CopyFrom(parse_func(node, relpath, {}))
462 break
463 else:
464 raise ValueError('could not find %r in %r' % (k, relpath))
465 499
466 500
467 def add_subparser(parser): 501 def add_subparser(parser):
468 doc_kinds=('binarypb', 'jsonpb', 'textpb', 'markdown(github)', 502 doc_kinds=('binarypb', 'jsonpb', 'textpb', 'markdown(github)',
469 'markdown(gitiles)') 503 'markdown(gitiles)')
470 helpstr = ( 504 helpstr = (
471 'List all known modules reachable from the current package, with their ' 505 'List all known modules reachable from the current package, with their '
472 'documentation.' 506 'documentation.'
473 ) 507 )
474 doc_p = parser.add_parser( 508 doc_p = parser.add_parser(
(...skipping 33 matching lines...) Expand 10 before | Expand all | Expand 10 after
508 elif args.kind == 'binarypb': 542 elif args.kind == 'binarypb':
509 sys.stdout.write(node.SerializeToString()) 543 sys.stdout.write(node.SerializeToString())
510 elif args.kind == 'textpb': 544 elif args.kind == 'textpb':
511 sys.stdout.write(textpb.MessageToString(node)) 545 sys.stdout.write(textpb.MessageToString(node))
512 elif args.kind == 'markdown-github': 546 elif args.kind == 'markdown-github':
513 doc_markdown.Emit(doc_markdown.Printer(doc_markdown.GITHUB), node) 547 doc_markdown.Emit(doc_markdown.Printer(doc_markdown.GITHUB), node)
514 elif args.kind == 'markdown-gitiles': 548 elif args.kind == 'markdown-gitiles':
515 doc_markdown.Emit(doc_markdown.Printer(doc_markdown.GITILES), node) 549 doc_markdown.Emit(doc_markdown.Printer(doc_markdown.GITILES), node)
516 else: 550 else:
517 raise NotImplementedError('--kind=%s' % args.kind) 551 raise NotImplementedError('--kind=%s' % args.kind)
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698