swagger.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals, absolute_import
  3. import itertools
  4. import re
  5. from inspect import isclass, getdoc
  6. from collections import OrderedDict
  7. try:
  8. from collections.abc import Hashable
  9. except ImportError:
  10. # TODO Remove this to drop Python2 support
  11. from collections import Hashable
  12. from six import string_types, itervalues, iteritems, iterkeys
  13. from flask import current_app
  14. from werkzeug.routing import parse_rule
  15. from . import fields
  16. from .model import Model, ModelBase, OrderedModel
  17. from .reqparse import RequestParser
  18. from .utils import merge, not_none, not_none_sorted
  19. from ._http import HTTPStatus
  20. try:
  21. from urllib.parse import quote
  22. except ImportError:
  23. from urllib import quote
  24. #: Maps Flask/Werkzeug rooting types to Swagger ones
  25. PATH_TYPES = {
  26. "int": "integer",
  27. "float": "number",
  28. "string": "string",
  29. "default": "string",
  30. }
  31. #: Maps Python primitives types to Swagger ones
  32. PY_TYPES = {
  33. int: "integer",
  34. float: "number",
  35. str: "string",
  36. bool: "boolean",
  37. None: "void",
  38. }
  39. RE_URL = re.compile(r"<(?:[^:<>]+:)?([^<>]+)>")
  40. DEFAULT_RESPONSE_DESCRIPTION = "Success"
  41. DEFAULT_RESPONSE = {"description": DEFAULT_RESPONSE_DESCRIPTION}
  42. RE_RAISES = re.compile(
  43. r"^:raises\s+(?P<name>[\w\d_]+)\s*:\s*(?P<description>.*)$", re.MULTILINE
  44. )
  45. def ref(model):
  46. """Return a reference to model in definitions"""
  47. name = model.name if isinstance(model, ModelBase) else model
  48. return {"$ref": "#/definitions/{0}".format(quote(name, safe=""))}
  49. def _v(value):
  50. """Dereference values (callable)"""
  51. return value() if callable(value) else value
  52. def extract_path(path):
  53. """
  54. Transform a Flask/Werkzeug URL pattern in a Swagger one.
  55. """
  56. return RE_URL.sub(r"{\1}", path)
  57. def extract_path_params(path):
  58. """
  59. Extract Flask-style parameters from an URL pattern as Swagger ones.
  60. """
  61. params = OrderedDict()
  62. for converter, arguments, variable in parse_rule(path):
  63. if not converter:
  64. continue
  65. param = {"name": variable, "in": "path", "required": True}
  66. if converter in PATH_TYPES:
  67. param["type"] = PATH_TYPES[converter]
  68. elif converter in current_app.url_map.converters:
  69. param["type"] = "string"
  70. else:
  71. raise ValueError("Unsupported type converter: %s" % converter)
  72. params[variable] = param
  73. return params
  74. def _param_to_header(param):
  75. param.pop("in", None)
  76. param.pop("name", None)
  77. return _clean_header(param)
  78. def _clean_header(header):
  79. if isinstance(header, string_types):
  80. header = {"description": header}
  81. typedef = header.get("type", "string")
  82. if isinstance(typedef, Hashable) and typedef in PY_TYPES:
  83. header["type"] = PY_TYPES[typedef]
  84. elif (
  85. isinstance(typedef, (list, tuple))
  86. and len(typedef) == 1
  87. and typedef[0] in PY_TYPES
  88. ):
  89. header["type"] = "array"
  90. header["items"] = {"type": PY_TYPES[typedef[0]]}
  91. elif hasattr(typedef, "__schema__"):
  92. header.update(typedef.__schema__)
  93. else:
  94. header["type"] = typedef
  95. return not_none(header)
  96. def parse_docstring(obj):
  97. raw = getdoc(obj)
  98. summary = raw.strip(" \n").split("\n")[0].split(".")[0] if raw else None
  99. raises = {}
  100. details = raw.replace(summary, "").lstrip(". \n").strip(" \n") if raw else None
  101. for match in RE_RAISES.finditer(raw or ""):
  102. raises[match.group("name")] = match.group("description")
  103. if details:
  104. details = details.replace(match.group(0), "")
  105. parsed = {
  106. "raw": raw,
  107. "summary": summary or None,
  108. "details": details or None,
  109. "returns": None,
  110. "params": [],
  111. "raises": raises,
  112. }
  113. return parsed
  114. def is_hidden(resource, route_doc=None):
  115. """
  116. Determine whether a Resource has been hidden from Swagger documentation
  117. i.e. by using Api.doc(False) decorator
  118. """
  119. if route_doc is False:
  120. return True
  121. else:
  122. return hasattr(resource, "__apidoc__") and resource.__apidoc__ is False
  123. def build_request_body_parameters_schema(body_params):
  124. """
  125. :param body_params: List of JSON schema of body parameters.
  126. :type body_params: list of dict, generated from the json body parameters of a request parser
  127. :return dict: The Swagger schema representation of the request body
  128. :Example:
  129. {
  130. 'name': 'payload',
  131. 'required': True,
  132. 'in': 'body',
  133. 'schema': {
  134. 'type': 'object',
  135. 'properties': [
  136. 'parameter1': {
  137. 'type': 'integer'
  138. },
  139. 'parameter2': {
  140. 'type': 'string'
  141. }
  142. ]
  143. }
  144. }
  145. """
  146. properties = {}
  147. for param in body_params:
  148. properties[param["name"]] = {"type": param.get("type", "string")}
  149. return {
  150. "name": "payload",
  151. "required": True,
  152. "in": "body",
  153. "schema": {"type": "object", "properties": properties},
  154. }
  155. class Swagger(object):
  156. """
  157. A Swagger documentation wrapper for an API instance.
  158. """
  159. def __init__(self, api):
  160. self.api = api
  161. self._registered_models = {}
  162. def as_dict(self):
  163. """
  164. Output the specification as a serializable ``dict``.
  165. :returns: the full Swagger specification in a serializable format
  166. :rtype: dict
  167. """
  168. basepath = self.api.base_path
  169. if len(basepath) > 1 and basepath.endswith("/"):
  170. basepath = basepath[:-1]
  171. infos = {
  172. "title": _v(self.api.title),
  173. "version": _v(self.api.version),
  174. }
  175. if self.api.description:
  176. infos["description"] = _v(self.api.description)
  177. if self.api.terms_url:
  178. infos["termsOfService"] = _v(self.api.terms_url)
  179. if self.api.contact and (self.api.contact_email or self.api.contact_url):
  180. infos["contact"] = {
  181. "name": _v(self.api.contact),
  182. "email": _v(self.api.contact_email),
  183. "url": _v(self.api.contact_url),
  184. }
  185. if self.api.license:
  186. infos["license"] = {"name": _v(self.api.license)}
  187. if self.api.license_url:
  188. infos["license"]["url"] = _v(self.api.license_url)
  189. paths = {}
  190. tags = self.extract_tags(self.api)
  191. # register errors
  192. responses = self.register_errors()
  193. for ns in self.api.namespaces:
  194. for resource, urls, route_doc, kwargs in ns.resources:
  195. for url in self.api.ns_urls(ns, urls):
  196. path = extract_path(url)
  197. serialized = self.serialize_resource(
  198. ns, resource, url, route_doc=route_doc, **kwargs
  199. )
  200. paths[path] = serialized
  201. # register all models if required
  202. if current_app.config["RESTX_INCLUDE_ALL_MODELS"]:
  203. for m in self.api.models:
  204. self.register_model(m)
  205. # merge in the top-level authorizations
  206. for ns in self.api.namespaces:
  207. if ns.authorizations:
  208. if self.api.authorizations is None:
  209. self.api.authorizations = {}
  210. self.api.authorizations = merge(
  211. self.api.authorizations, ns.authorizations
  212. )
  213. specs = {
  214. "swagger": "2.0",
  215. "basePath": basepath,
  216. "paths": not_none_sorted(paths),
  217. "info": infos,
  218. "produces": list(iterkeys(self.api.representations)),
  219. "consumes": ["application/json"],
  220. "securityDefinitions": self.api.authorizations or None,
  221. "security": self.security_requirements(self.api.security) or None,
  222. "tags": tags,
  223. "definitions": self.serialize_definitions() or None,
  224. "responses": responses or None,
  225. "host": self.get_host(),
  226. }
  227. return not_none(specs)
  228. def get_host(self):
  229. hostname = current_app.config.get("SERVER_NAME", None) or None
  230. if hostname and self.api.blueprint and self.api.blueprint.subdomain:
  231. hostname = ".".join((self.api.blueprint.subdomain, hostname))
  232. return hostname
  233. def extract_tags(self, api):
  234. tags = []
  235. by_name = {}
  236. for tag in api.tags:
  237. if isinstance(tag, string_types):
  238. tag = {"name": tag}
  239. elif isinstance(tag, (list, tuple)):
  240. tag = {"name": tag[0], "description": tag[1]}
  241. elif isinstance(tag, dict) and "name" in tag:
  242. pass
  243. else:
  244. raise ValueError("Unsupported tag format for {0}".format(tag))
  245. tags.append(tag)
  246. by_name[tag["name"]] = tag
  247. for ns in api.namespaces:
  248. # hide namespaces without any Resources
  249. if not ns.resources:
  250. continue
  251. # hide namespaces with all Resources hidden from Swagger documentation
  252. if all(is_hidden(r.resource, route_doc=r.route_doc) for r in ns.resources):
  253. continue
  254. if ns.name not in by_name:
  255. tags.append(
  256. {"name": ns.name, "description": ns.description}
  257. if ns.description
  258. else {"name": ns.name}
  259. )
  260. elif ns.description:
  261. by_name[ns.name]["description"] = ns.description
  262. return tags
  263. def extract_resource_doc(self, resource, url, route_doc=None):
  264. route_doc = {} if route_doc is None else route_doc
  265. if route_doc is False:
  266. return False
  267. doc = merge(getattr(resource, "__apidoc__", {}), route_doc)
  268. if doc is False:
  269. return False
  270. # ensure unique names for multiple routes to the same resource
  271. # provides different Swagger operationId's
  272. doc["name"] = (
  273. "{}_{}".format(resource.__name__, url) if route_doc else resource.__name__
  274. )
  275. params = merge(self.expected_params(doc), doc.get("params", OrderedDict()))
  276. params = merge(params, extract_path_params(url))
  277. # Track parameters for late deduplication
  278. up_params = {(n, p.get("in", "query")): p for n, p in params.items()}
  279. need_to_go_down = set()
  280. methods = [m.lower() for m in resource.methods or []]
  281. for method in methods:
  282. method_doc = doc.get(method, OrderedDict())
  283. method_impl = getattr(resource, method)
  284. if hasattr(method_impl, "im_func"):
  285. method_impl = method_impl.im_func
  286. elif hasattr(method_impl, "__func__"):
  287. method_impl = method_impl.__func__
  288. method_doc = merge(
  289. method_doc, getattr(method_impl, "__apidoc__", OrderedDict())
  290. )
  291. if method_doc is not False:
  292. method_doc["docstring"] = parse_docstring(method_impl)
  293. method_params = self.expected_params(method_doc)
  294. method_params = merge(method_params, method_doc.get("params", {}))
  295. inherited_params = OrderedDict(
  296. (k, v) for k, v in iteritems(params) if k in method_params
  297. )
  298. method_doc["params"] = merge(inherited_params, method_params)
  299. for name, param in method_doc["params"].items():
  300. key = (name, param.get("in", "query"))
  301. if key in up_params:
  302. need_to_go_down.add(key)
  303. doc[method] = method_doc
  304. # Deduplicate parameters
  305. # For each couple (name, in), if a method overrides it,
  306. # we need to move the paramter down to each method
  307. if need_to_go_down:
  308. for method in methods:
  309. method_doc = doc.get(method)
  310. if not method_doc:
  311. continue
  312. params = {
  313. (n, p.get("in", "query")): p
  314. for n, p in (method_doc["params"] or {}).items()
  315. }
  316. for key in need_to_go_down:
  317. if key not in params:
  318. method_doc["params"][key[0]] = up_params[key]
  319. doc["params"] = OrderedDict(
  320. (k[0], p) for k, p in up_params.items() if k not in need_to_go_down
  321. )
  322. return doc
  323. def expected_params(self, doc):
  324. params = OrderedDict()
  325. if "expect" not in doc:
  326. return params
  327. for expect in doc.get("expect", []):
  328. if isinstance(expect, RequestParser):
  329. parser_params = OrderedDict(
  330. (p["name"], p) for p in expect.__schema__ if p["in"] != "body"
  331. )
  332. params.update(parser_params)
  333. body_params = [p for p in expect.__schema__ if p["in"] == "body"]
  334. if body_params:
  335. params["payload"] = build_request_body_parameters_schema(
  336. body_params
  337. )
  338. elif isinstance(expect, ModelBase):
  339. params["payload"] = not_none(
  340. {
  341. "name": "payload",
  342. "required": True,
  343. "in": "body",
  344. "schema": self.serialize_schema(expect),
  345. }
  346. )
  347. elif isinstance(expect, (list, tuple)):
  348. if len(expect) == 2:
  349. # this is (payload, description) shortcut
  350. model, description = expect
  351. params["payload"] = not_none(
  352. {
  353. "name": "payload",
  354. "required": True,
  355. "in": "body",
  356. "schema": self.serialize_schema(model),
  357. "description": description,
  358. }
  359. )
  360. else:
  361. params["payload"] = not_none(
  362. {
  363. "name": "payload",
  364. "required": True,
  365. "in": "body",
  366. "schema": self.serialize_schema(expect),
  367. }
  368. )
  369. return params
  370. def register_errors(self):
  371. responses = {}
  372. for exception, handler in iteritems(self.api.error_handlers):
  373. doc = parse_docstring(handler)
  374. response = {"description": doc["summary"]}
  375. apidoc = getattr(handler, "__apidoc__", {})
  376. self.process_headers(response, apidoc)
  377. if "responses" in apidoc:
  378. _, model, _ = list(apidoc["responses"].values())[0]
  379. response["schema"] = self.serialize_schema(model)
  380. responses[exception.__name__] = not_none(response)
  381. return responses
  382. def serialize_resource(self, ns, resource, url, route_doc=None, **kwargs):
  383. doc = self.extract_resource_doc(resource, url, route_doc=route_doc)
  384. if doc is False:
  385. return
  386. path = {"parameters": self.parameters_for(doc) or None}
  387. for method in [m.lower() for m in resource.methods or []]:
  388. methods = [m.lower() for m in kwargs.get("methods", [])]
  389. if doc[method] is False or methods and method not in methods:
  390. continue
  391. path[method] = self.serialize_operation(doc, method)
  392. path[method]["tags"] = [ns.name]
  393. return not_none(path)
  394. def serialize_operation(self, doc, method):
  395. operation = {
  396. "responses": self.responses_for(doc, method) or None,
  397. "summary": doc[method]["docstring"]["summary"],
  398. "description": self.description_for(doc, method) or None,
  399. "operationId": self.operation_id_for(doc, method),
  400. "parameters": self.parameters_for(doc[method]) or None,
  401. "security": self.security_for(doc, method),
  402. }
  403. # Handle 'produces' mimetypes documentation
  404. if "produces" in doc[method]:
  405. operation["produces"] = doc[method]["produces"]
  406. # Handle deprecated annotation
  407. if doc.get("deprecated") or doc[method].get("deprecated"):
  408. operation["deprecated"] = True
  409. # Handle form exceptions:
  410. doc_params = list(doc.get("params", {}).values())
  411. all_params = doc_params + (operation["parameters"] or [])
  412. if all_params and any(p["in"] == "formData" for p in all_params):
  413. if any(p["type"] == "file" for p in all_params):
  414. operation["consumes"] = ["multipart/form-data"]
  415. else:
  416. operation["consumes"] = [
  417. "application/x-www-form-urlencoded",
  418. "multipart/form-data",
  419. ]
  420. operation.update(self.vendor_fields(doc, method))
  421. return not_none(operation)
  422. def vendor_fields(self, doc, method):
  423. """
  424. Extract custom 3rd party Vendor fields prefixed with ``x-``
  425. See: https://swagger.io/specification/#specification-extensions
  426. """
  427. return dict(
  428. (k if k.startswith("x-") else "x-{0}".format(k), v)
  429. for k, v in iteritems(doc[method].get("vendor", {}))
  430. )
  431. def description_for(self, doc, method):
  432. """Extract the description metadata and fallback on the whole docstring"""
  433. parts = []
  434. if "description" in doc:
  435. parts.append(doc["description"] or "")
  436. if method in doc and "description" in doc[method]:
  437. parts.append(doc[method]["description"])
  438. if doc[method]["docstring"]["details"]:
  439. parts.append(doc[method]["docstring"]["details"])
  440. return "\n".join(parts).strip()
  441. def operation_id_for(self, doc, method):
  442. """Extract the operation id"""
  443. return (
  444. doc[method]["id"]
  445. if "id" in doc[method]
  446. else self.api.default_id(doc["name"], method)
  447. )
  448. def parameters_for(self, doc):
  449. params = []
  450. for name, param in iteritems(doc["params"]):
  451. param["name"] = name
  452. if "type" not in param and "schema" not in param:
  453. param["type"] = "string"
  454. if "in" not in param:
  455. param["in"] = "query"
  456. if "type" in param and "schema" not in param:
  457. ptype = param.get("type", None)
  458. if isinstance(ptype, (list, tuple)):
  459. typ = ptype[0]
  460. param["type"] = "array"
  461. param["items"] = {"type": PY_TYPES.get(typ, typ)}
  462. elif isinstance(ptype, (type, type(None))) and ptype in PY_TYPES:
  463. param["type"] = PY_TYPES[ptype]
  464. params.append(param)
  465. # Handle fields mask
  466. mask = doc.get("__mask__")
  467. if mask and current_app.config["RESTX_MASK_SWAGGER"]:
  468. param = {
  469. "name": current_app.config["RESTX_MASK_HEADER"],
  470. "in": "header",
  471. "type": "string",
  472. "format": "mask",
  473. "description": "An optional fields mask",
  474. }
  475. if isinstance(mask, string_types):
  476. param["default"] = mask
  477. params.append(param)
  478. return params
  479. def responses_for(self, doc, method):
  480. # TODO: simplify/refactor responses/model handling
  481. responses = {}
  482. for d in doc, doc[method]:
  483. if "responses" in d:
  484. for code, response in iteritems(d["responses"]):
  485. code = str(code)
  486. if isinstance(response, string_types):
  487. description = response
  488. model = None
  489. kwargs = {}
  490. elif len(response) == 3:
  491. description, model, kwargs = response
  492. elif len(response) == 2:
  493. description, model = response
  494. kwargs = {}
  495. else:
  496. raise ValueError("Unsupported response specification")
  497. description = description or DEFAULT_RESPONSE_DESCRIPTION
  498. if code in responses:
  499. responses[code].update(description=description)
  500. else:
  501. responses[code] = {"description": description}
  502. if model:
  503. schema = self.serialize_schema(model)
  504. envelope = kwargs.get("envelope")
  505. if envelope:
  506. schema = {"properties": {envelope: schema}}
  507. responses[code]["schema"] = schema
  508. self.process_headers(
  509. responses[code], doc, method, kwargs.get("headers")
  510. )
  511. if "model" in d:
  512. code = str(d.get("default_code", HTTPStatus.OK))
  513. if code not in responses:
  514. responses[code] = self.process_headers(
  515. DEFAULT_RESPONSE.copy(), doc, method
  516. )
  517. responses[code]["schema"] = self.serialize_schema(d["model"])
  518. if "docstring" in d:
  519. for name, description in iteritems(d["docstring"]["raises"]):
  520. for exception, handler in iteritems(self.api.error_handlers):
  521. error_responses = getattr(handler, "__apidoc__", {}).get(
  522. "responses", {}
  523. )
  524. code = (
  525. str(list(error_responses.keys())[0])
  526. if error_responses
  527. else None
  528. )
  529. if code and exception.__name__ == name:
  530. responses[code] = {"$ref": "#/responses/{0}".format(name)}
  531. break
  532. if not responses:
  533. responses[str(HTTPStatus.OK.value)] = self.process_headers(
  534. DEFAULT_RESPONSE.copy(), doc, method
  535. )
  536. return responses
  537. def process_headers(self, response, doc, method=None, headers=None):
  538. method_doc = doc.get(method, {})
  539. if "headers" in doc or "headers" in method_doc or headers:
  540. response["headers"] = dict(
  541. (k, _clean_header(v))
  542. for k, v in itertools.chain(
  543. iteritems(doc.get("headers", {})),
  544. iteritems(method_doc.get("headers", {})),
  545. iteritems(headers or {}),
  546. )
  547. )
  548. return response
  549. def serialize_definitions(self):
  550. return dict(
  551. (name, model.__schema__)
  552. for name, model in iteritems(self._registered_models)
  553. )
  554. def serialize_schema(self, model):
  555. if isinstance(model, (list, tuple)):
  556. model = model[0]
  557. return {
  558. "type": "array",
  559. "items": self.serialize_schema(model),
  560. }
  561. elif isinstance(model, ModelBase):
  562. self.register_model(model)
  563. return ref(model)
  564. elif isinstance(model, string_types):
  565. self.register_model(model)
  566. return ref(model)
  567. elif isclass(model) and issubclass(model, fields.Raw):
  568. return self.serialize_schema(model())
  569. elif isinstance(model, fields.Raw):
  570. return model.__schema__
  571. elif isinstance(model, (type, type(None))) and model in PY_TYPES:
  572. return {"type": PY_TYPES[model]}
  573. raise ValueError("Model {0} not registered".format(model))
  574. def register_model(self, model):
  575. name = model.name if isinstance(model, ModelBase) else model
  576. if name not in self.api.models:
  577. raise ValueError("Model {0} not registered".format(name))
  578. specs = self.api.models[name]
  579. if name in self._registered_models:
  580. return ref(model)
  581. self._registered_models[name] = specs
  582. if isinstance(specs, ModelBase):
  583. for parent in specs.__parents__:
  584. self.register_model(parent)
  585. if isinstance(specs, (Model, OrderedModel)):
  586. for field in itervalues(specs):
  587. self.register_field(field)
  588. return ref(model)
  589. def register_field(self, field):
  590. if isinstance(field, fields.Polymorph):
  591. for model in itervalues(field.mapping):
  592. self.register_model(model)
  593. elif isinstance(field, fields.Nested):
  594. self.register_model(field.nested)
  595. elif isinstance(field, (fields.List, fields.Wildcard)):
  596. self.register_field(field.container)
  597. def security_for(self, doc, method):
  598. security = None
  599. if "security" in doc:
  600. auth = doc["security"]
  601. security = self.security_requirements(auth)
  602. if "security" in doc[method]:
  603. auth = doc[method]["security"]
  604. security = self.security_requirements(auth)
  605. return security
  606. def security_requirements(self, value):
  607. if isinstance(value, (list, tuple)):
  608. return [self.security_requirement(v) for v in value]
  609. elif value:
  610. requirement = self.security_requirement(value)
  611. return [requirement] if requirement else None
  612. else:
  613. return []
  614. def security_requirement(self, value):
  615. if isinstance(value, (string_types)):
  616. return {value: []}
  617. elif isinstance(value, dict):
  618. return dict(
  619. (k, v if isinstance(v, (list, tuple)) else [v])
  620. for k, v in iteritems(value)
  621. )
  622. else:
  623. return None