From 9b5234c85fa217d72c1edee7d408cd874f5823b7 Mon Sep 17 00:00:00 2001 From: Sam Pegler Date: Thu, 9 Jan 2020 21:54:15 +0000 Subject: [PATCH 01/14] Partial: Remove encodings and future imports. --- flask_restplus/__about__.py | 2 + flask_restplus/_http.py | 140 ++++++++ flask_restplus/postman.py | 189 ++++++++++ flask_restplus/swagger.py | 631 +++++++++++++++++++++++++++++++++ flask_restx/__init__.py | 3 - flask_restx/api.py | 3 - flask_restx/apidoc.py | 3 - flask_restx/cors.py | 3 - flask_restx/errors.py | 3 - flask_restx/fields.py | 3 - flask_restx/inputs.py | 2 - flask_restx/marshalling.py | 5 +- flask_restx/mask.py | 3 - flask_restx/model.py | 3 - flask_restx/namespace.py | 3 - flask_restx/representations.py | 3 - flask_restx/reqparse.py | 3 - flask_restx/resource.py | 3 - flask_restx/utils.py | 3 - tests/conftest.py | 3 - tests/test_accept.py | 3 - tests/test_api.py | 3 - tests/test_apidoc.py | 3 - tests/test_cors.py | 3 - tests/test_errors.py | 3 - tests/test_fields.py | 5 +- tests/test_fields_mask.py | 3 - tests/test_inputs.py | 3 - tests/test_marshalling.py | 3 - tests/test_model.py | 3 - tests/test_namespace.py | 1 - tests/test_payload.py | 3 - tests/test_postman.py | 3 - tests/test_reqparse.py | 3 - tests/test_schemas.py | 3 - tests/test_swagger.py | 3 - tests/test_swagger_utils.py | 3 - tests/test_utils.py | 3 - 38 files changed, 964 insertions(+), 101 deletions(-) create mode 100644 flask_restplus/__about__.py create mode 100644 flask_restplus/_http.py create mode 100644 flask_restplus/postman.py create mode 100644 flask_restplus/swagger.py diff --git a/flask_restplus/__about__.py b/flask_restplus/__about__.py new file mode 100644 index 00000000..7ab99eaf --- /dev/null +++ b/flask_restplus/__about__.py @@ -0,0 +1,2 @@ +__version__ = '0.13.1.dev' +__description__ = 'Fully featured framework for fast, easy and documented API development with Flask' diff --git a/flask_restplus/_http.py b/flask_restplus/_http.py new file mode 100644 index 00000000..8fee4765 --- /dev/null +++ b/flask_restplus/_http.py @@ -0,0 +1,140 @@ +""" +This file is backported from Python 3.5 http built-in module. +""" + +from enum import IntEnum + + +class HTTPStatus(IntEnum): + """HTTP status codes and reason phrases + + Status codes from the following RFCs are all observed: + + * RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616 + * RFC 6585: Additional HTTP Status Codes + * RFC 3229: Delta encoding in HTTP + * RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518 + * RFC 5842: Binding Extensions to WebDAV + * RFC 7238: Permanent Redirect + * RFC 2295: Transparent Content Negotiation in HTTP + * RFC 2774: An HTTP Extension Framework + """ + def __new__(cls, value, phrase, description=''): + obj = int.__new__(cls, value) + obj._value_ = value + + obj.phrase = phrase + obj.description = description + return obj + + def __str__(self): + return str(self.value) + + # informational + CONTINUE = 100, 'Continue', 'Request received, please continue' + SWITCHING_PROTOCOLS = (101, 'Switching Protocols', + 'Switching to new protocol; obey Upgrade header') + PROCESSING = 102, 'Processing' + + # success + OK = 200, 'OK', 'Request fulfilled, document follows' + CREATED = 201, 'Created', 'Document created, URL follows' + ACCEPTED = (202, 'Accepted', + 'Request accepted, processing continues off-line') + NON_AUTHORITATIVE_INFORMATION = (203, + 'Non-Authoritative Information', 'Request fulfilled from cache') + NO_CONTENT = 204, 'No Content', 'Request fulfilled, nothing follows' + RESET_CONTENT = 205, 'Reset Content', 'Clear input form for further input' + PARTIAL_CONTENT = 206, 'Partial Content', 'Partial content follows' + MULTI_STATUS = 207, 'Multi-Status' + ALREADY_REPORTED = 208, 'Already Reported' + IM_USED = 226, 'IM Used' + + # redirection + MULTIPLE_CHOICES = (300, 'Multiple Choices', + 'Object has several resources -- see URI list') + MOVED_PERMANENTLY = (301, 'Moved Permanently', + 'Object moved permanently -- see URI list') + FOUND = 302, 'Found', 'Object moved temporarily -- see URI list' + SEE_OTHER = 303, 'See Other', 'Object moved -- see Method and URL list' + NOT_MODIFIED = (304, 'Not Modified', + 'Document has not changed since given time') + USE_PROXY = (305, 'Use Proxy', + 'You must use proxy specified in Location to access this resource') + TEMPORARY_REDIRECT = (307, 'Temporary Redirect', + 'Object moved temporarily -- see URI list') + PERMANENT_REDIRECT = (308, 'Permanent Redirect', + 'Object moved temporarily -- see URI list') + + # client error + BAD_REQUEST = (400, 'Bad Request', + 'Bad request syntax or unsupported method') + UNAUTHORIZED = (401, 'Unauthorized', + 'No permission -- see authorization schemes') + PAYMENT_REQUIRED = (402, 'Payment Required', + 'No payment -- see charging schemes') + FORBIDDEN = (403, 'Forbidden', + 'Request forbidden -- authorization will not help') + NOT_FOUND = (404, 'Not Found', + 'Nothing matches the given URI') + METHOD_NOT_ALLOWED = (405, 'Method Not Allowed', + 'Specified method is invalid for this resource') + NOT_ACCEPTABLE = (406, 'Not Acceptable', + 'URI not available in preferred format') + PROXY_AUTHENTICATION_REQUIRED = (407, + 'Proxy Authentication Required', + 'You must authenticate with this proxy before proceeding') + REQUEST_TIMEOUT = (408, 'Request Timeout', + 'Request timed out; try again later') + CONFLICT = 409, 'Conflict', 'Request conflict' + GONE = (410, 'Gone', + 'URI no longer exists and has been permanently removed') + LENGTH_REQUIRED = (411, 'Length Required', + 'Client must specify Content-Length') + PRECONDITION_FAILED = (412, 'Precondition Failed', + 'Precondition in headers is false') + REQUEST_ENTITY_TOO_LARGE = (413, 'Request Entity Too Large', + 'Entity is too large') + REQUEST_URI_TOO_LONG = (414, 'Request-URI Too Long', + 'URI is too long') + UNSUPPORTED_MEDIA_TYPE = (415, 'Unsupported Media Type', + 'Entity body in unsupported format') + REQUESTED_RANGE_NOT_SATISFIABLE = (416, + 'Requested Range Not Satisfiable', + 'Cannot satisfy request range') + EXPECTATION_FAILED = (417, 'Expectation Failed', + 'Expect condition could not be satisfied') + UNPROCESSABLE_ENTITY = 422, 'Unprocessable Entity' + LOCKED = 423, 'Locked' + FAILED_DEPENDENCY = 424, 'Failed Dependency' + UPGRADE_REQUIRED = 426, 'Upgrade Required' + PRECONDITION_REQUIRED = (428, 'Precondition Required', + 'The origin server requires the request to be conditional') + TOO_MANY_REQUESTS = (429, 'Too Many Requests', + 'The user has sent too many requests in ' + 'a given amount of time ("rate limiting")') + REQUEST_HEADER_FIELDS_TOO_LARGE = (431, + 'Request Header Fields Too Large', + 'The server is unwilling to process the request because its header ' + 'fields are too large') + + # server errors + INTERNAL_SERVER_ERROR = (500, 'Internal Server Error', + 'Server got itself in trouble') + NOT_IMPLEMENTED = (501, 'Not Implemented', + 'Server does not support this operation') + BAD_GATEWAY = (502, 'Bad Gateway', + 'Invalid responses from another server/proxy') + SERVICE_UNAVAILABLE = (503, 'Service Unavailable', + 'The server cannot process the request due to a high load') + GATEWAY_TIMEOUT = (504, 'Gateway Timeout', + 'The gateway server did not receive a timely response') + HTTP_VERSION_NOT_SUPPORTED = (505, 'HTTP Version Not Supported', + 'Cannot fulfill request') + VARIANT_ALSO_NEGOTIATES = 506, 'Variant Also Negotiates' + INSUFFICIENT_STORAGE = 507, 'Insufficient Storage' + LOOP_DETECTED = 508, 'Loop Detected' + NOT_EXTENDED = 510, 'Not Extended' + NETWORK_AUTHENTICATION_REQUIRED = (511, + 'Network Authentication Required', + 'The client needs to authenticate to gain network access') diff --git a/flask_restplus/postman.py b/flask_restplus/postman.py new file mode 100644 index 00000000..38d48245 --- /dev/null +++ b/flask_restplus/postman.py @@ -0,0 +1,189 @@ +from time import time +from uuid import uuid5, NAMESPACE_URL + +from six import iteritems +from six.moves.urllib.parse import urlencode + + +def clean(data): + '''Remove all keys where value is None''' + return dict((k, v) for k, v in iteritems(data) if v is not None) + + +DEFAULT_VARS = { + 'string': '', + 'integer': 0, + 'number': 0, +} + + +class Request(object): + '''Wraps a Swagger operation into a Postman Request''' + def __init__(self, collection, path, params, method, operation): + self.collection = collection + self.path = path + self.params = params + self.method = method.upper() + self.operation = operation + + @property + def id(self): + seed = str(' '.join((self.method, self.url))) + return str(uuid5(self.collection.uuid, seed)) + + @property + def url(self): + return self.collection.api.base_url.rstrip('/') + self.path + + @property + def headers(self): + headers = {} + # Handle content-type + if self.method != 'GET': + consumes = self.collection.api.__schema__.get('consumes', []) + consumes = self.operation.get('consumes', consumes) + if len(consumes): + headers['Content-Type'] = consumes[-1] + + # Add all parameters headers + for param in self.operation.get('parameters', []): + if param['in'] == 'header': + headers[param['name']] = param.get('default', '') + + # Add security headers if needed (global then local) + for security in self.collection.api.__schema__.get('security', []): + for key, header in iteritems(self.collection.apikeys): + if key in security: + headers[header] = '' + for security in self.operation.get('security', []): + for key, header in iteritems(self.collection.apikeys): + if key in security: + headers[header] = '' + + lines = [':'.join(line) for line in iteritems(headers)] + return '\n'.join(lines) + + @property + def folder(self): + if 'tags' not in self.operation or len(self.operation['tags']) == 0: + return + tag = self.operation['tags'][0] + for folder in self.collection.folders: + if folder.tag == tag: + return folder.id + + def as_dict(self, urlvars=False): + url, variables = self.process_url(urlvars) + return clean({ + 'id': self.id, + 'method': self.method, + 'name': self.operation['operationId'], + 'description': self.operation.get('summary'), + 'url': url, + 'headers': self.headers, + 'collectionId': self.collection.id, + 'folder': self.folder, + 'pathVariables': variables, + 'time': int(time()), + }) + + def process_url(self, urlvars=False): + url = self.url + path_vars = {} + url_vars = {} + params = dict((p['name'], p) for p in self.params) + params.update(dict((p['name'], p) for p in self.operation.get('parameters', []))) + if not params: + return url, None + for name, param in iteritems(params): + if param['in'] == 'path': + url = url.replace('{%s}' % name, ':%s' % name) + path_vars[name] = DEFAULT_VARS.get(param['type'], '') + elif param['in'] == 'query' and urlvars: + default = DEFAULT_VARS.get(param['type'], '') + url_vars[name] = param.get('default', default) + if url_vars: + url = '?'.join((url, urlencode(url_vars))) + return url, path_vars + + +class Folder(object): + def __init__(self, collection, tag): + self.collection = collection + self.tag = tag['name'] + self.description = tag['description'] + + @property + def id(self): + return str(uuid5(self.collection.uuid, str(self.tag))) + + @property + def order(self): + return [ + r.id for r in self.collection.requests + if r.folder == self.id + ] + + def as_dict(self): + return clean({ + 'id': self.id, + 'name': self.tag, + 'description': self.description, + 'order': self.order, + 'collectionId': self.collection.id + }) + + +class PostmanCollectionV1(object): + '''Postman Collection (V1 format) serializer''' + def __init__(self, api, swagger=False): + self.api = api + self.swagger = swagger + + @property + def uuid(self): + return uuid5(NAMESPACE_URL, self.api.base_url) + + @property + def id(self): + return str(self.uuid) + + @property + def requests(self): + if self.swagger: + # First request is Swagger specifications + yield Request(self, '/swagger.json', {}, 'get', { + 'operationId': 'Swagger specifications', + 'summary': 'The API Swagger specifications as JSON', + }) + # Then iter over API paths and methods + for path, operations in iteritems(self.api.__schema__['paths']): + path_params = operations.get('parameters', []) + + for method, operation in iteritems(operations): + if method != 'parameters': + yield Request(self, path, path_params, method, operation) + + @property + def folders(self): + for tag in self.api.__schema__['tags']: + yield Folder(self, tag) + + @property + def apikeys(self): + return dict( + (name, secdef['name']) + for name, secdef in iteritems(self.api.__schema__.get('securityDefinitions')) + if secdef.get('in') == 'header' and secdef.get('type') == 'apiKey' + ) + + def as_dict(self, urlvars=False): + return clean({ + 'id': self.id, + 'name': ' '.join((self.api.title, self.api.version)), + 'description': self.api.description, + 'order': [r.id for r in self.requests if not r.folder], + 'requests': [r.as_dict(urlvars=urlvars) for r in self.requests], + 'folders': [f.as_dict() for f in self.folders], + 'timestamp': int(time()), + }) diff --git a/flask_restplus/swagger.py b/flask_restplus/swagger.py new file mode 100644 index 00000000..43c3a391 --- /dev/null +++ b/flask_restplus/swagger.py @@ -0,0 +1,631 @@ +import itertools +import re + +from inspect import isclass, getdoc +try: + from collections.abc import OrderedDict, Hashable +except ImportError: + # TODO Remove this to drop Python2 support + from collections import OrderedDict, Hashable +from six import string_types, itervalues, iteritems, iterkeys + +from flask import current_app +from werkzeug.routing import parse_rule + +from . import fields +from .model import Model, ModelBase +from .reqparse import RequestParser +from .utils import merge, not_none, not_none_sorted +from ._http import HTTPStatus + +try: + from urllib.parse import quote +except ImportError: + from urllib import quote + +#: Maps Flask/Werkzeug rooting types to Swagger ones +PATH_TYPES = { + 'int': 'integer', + 'float': 'number', + 'string': 'string', + 'default': 'string', +} + + +#: Maps Python primitives types to Swagger ones +PY_TYPES = { + int: 'integer', + float: 'number', + str: 'string', + bool: 'boolean', + None: 'void' +} + +RE_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>') + +DEFAULT_RESPONSE_DESCRIPTION = 'Success' +DEFAULT_RESPONSE = {'description': DEFAULT_RESPONSE_DESCRIPTION} + +RE_RAISES = re.compile(r'^:raises\s+(?P[\w\d_]+)\s*:\s*(?P.*)$', re.MULTILINE) + + +def ref(model): + '''Return a reference to model in definitions''' + name = model.name if isinstance(model, ModelBase) else model + return {'$ref': '#/definitions/{0}'.format(quote(name, safe=''))} + + +def _v(value): + '''Dereference values (callable)''' + return value() if callable(value) else value + + +def extract_path(path): + ''' + Transform a Flask/Werkzeug URL pattern in a Swagger one. + ''' + return RE_URL.sub(r'{\1}', path) + + +def extract_path_params(path): + ''' + Extract Flask-style parameters from an URL pattern as Swagger ones. + ''' + params = OrderedDict() + for converter, arguments, variable in parse_rule(path): + if not converter: + continue + param = { + 'name': variable, + 'in': 'path', + 'required': True + } + + if converter in PATH_TYPES: + param['type'] = PATH_TYPES[converter] + elif converter in current_app.url_map.converters: + param['type'] = 'string' + else: + raise ValueError('Unsupported type converter: %s' % converter) + params[variable] = param + return params + + +def _param_to_header(param): + param.pop('in', None) + param.pop('name', None) + return _clean_header(param) + + +def _clean_header(header): + if isinstance(header, string_types): + header = {'description': header} + typedef = header.get('type', 'string') + if isinstance(typedef, Hashable) and typedef in PY_TYPES: + header['type'] = PY_TYPES[typedef] + elif isinstance(typedef, (list, tuple)) and len(typedef) == 1 and typedef[0] in PY_TYPES: + header['type'] = 'array' + header['items'] = {'type': PY_TYPES[typedef[0]]} + elif hasattr(typedef, '__schema__'): + header.update(typedef.__schema__) + else: + header['type'] = typedef + return not_none(header) + + +def parse_docstring(obj): + raw = getdoc(obj) + summary = raw.strip(' \n').split('\n')[0].split('.')[0] if raw else None + raises = {} + details = raw.replace(summary, '').lstrip('. \n').strip(' \n') if raw else None + for match in RE_RAISES.finditer(raw or ''): + raises[match.group('name')] = match.group('description') + if details: + details = details.replace(match.group(0), '') + parsed = { + 'raw': raw, + 'summary': summary or None, + 'details': details or None, + 'returns': None, + 'params': [], + 'raises': raises, + } + return parsed + + +def is_hidden(resource, route_doc=None): + ''' + Determine whether a Resource has been hidden from Swagger documentation + i.e. by using Api.doc(False) decorator + ''' + if route_doc is False: + return True + else: + return hasattr(resource, "__apidoc__") and resource.__apidoc__ is False + + +class Swagger(object): + ''' + A Swagger documentation wrapper for an API instance. + ''' + def __init__(self, api): + self.api = api + self._registered_models = {} + + def as_dict(self): + ''' + Output the specification as a serializable ``dict``. + + :returns: the full Swagger specification in a serializable format + :rtype: dict + ''' + basepath = self.api.base_path + if len(basepath) > 1 and basepath.endswith('/'): + basepath = basepath[:-1] + infos = { + 'title': _v(self.api.title), + 'version': _v(self.api.version), + } + if self.api.description: + infos['description'] = _v(self.api.description) + if self.api.terms_url: + infos['termsOfService'] = _v(self.api.terms_url) + if self.api.contact and (self.api.contact_email or self.api.contact_url): + infos['contact'] = { + 'name': _v(self.api.contact), + 'email': _v(self.api.contact_email), + 'url': _v(self.api.contact_url), + } + if self.api.license: + infos['license'] = {'name': _v(self.api.license)} + if self.api.license_url: + infos['license']['url'] = _v(self.api.license_url) + + paths = {} + tags = self.extract_tags(self.api) + + # register errors + responses = self.register_errors() + + for ns in self.api.namespaces: + for resource, urls, route_doc, kwargs in ns.resources: + for url in self.api.ns_urls(ns, urls): + path = extract_path(url) + serialized = self.serialize_resource( + ns, + resource, + url, + route_doc=route_doc, + **kwargs + ) + paths[path] = serialized + + # merge in the top-level authorizations + for ns in self.api.namespaces: + if ns.authorizations: + if self.api.authorizations is None: + self.api.authorizations = {} + self.api.authorizations = merge(self.api.authorizations, ns.authorizations) + + specs = { + 'swagger': '2.0', + 'basePath': basepath, + 'paths': not_none_sorted(paths), + 'info': infos, + 'produces': list(iterkeys(self.api.representations)), + 'consumes': ['application/json'], + 'securityDefinitions': self.api.authorizations or None, + 'security': self.security_requirements(self.api.security) or None, + 'tags': tags, + 'definitions': self.serialize_definitions() or None, + 'responses': responses or None, + 'host': self.get_host(), + } + return not_none(specs) + + def get_host(self): + hostname = current_app.config.get('SERVER_NAME', None) or None + if hostname and self.api.blueprint and self.api.blueprint.subdomain: + hostname = '.'.join((self.api.blueprint.subdomain, hostname)) + return hostname + + def extract_tags(self, api): + tags = [] + by_name = {} + for tag in api.tags: + if isinstance(tag, string_types): + tag = {'name': tag} + elif isinstance(tag, (list, tuple)): + tag = {'name': tag[0], 'description': tag[1]} + elif isinstance(tag, dict) and 'name' in tag: + pass + else: + raise ValueError('Unsupported tag format for {0}'.format(tag)) + tags.append(tag) + by_name[tag['name']] = tag + for ns in api.namespaces: + # hide namespaces without any Resources + if not ns.resources: + continue + # hide namespaces with all Resources hidden from Swagger documentation + if all( + is_hidden(r.resource, route_doc=r.route_doc) + for r in ns.resources + ): + continue + if ns.name not in by_name: + tags.append({ + 'name': ns.name, + 'description': ns.description + } if ns.description else {'name': ns.name}) + elif ns.description: + by_name[ns.name]['description'] = ns.description + return tags + + def extract_resource_doc(self, resource, url, route_doc=None): + route_doc = {} if route_doc is None else route_doc + if route_doc is False: + return False + doc = merge(getattr(resource, '__apidoc__', {}), route_doc) + if doc is False: + return False + + # ensure unique names for multiple routes to the same resource + # provides different Swagger operationId's + doc["name"] = ( + "{}_{}".format(resource.__name__, url) + if route_doc + else resource.__name__ + ) + + params = merge(self.expected_params(doc), doc.get('params', OrderedDict())) + params = merge(params, extract_path_params(url)) + # Track parameters for late deduplication + up_params = {(n, p.get('in', 'query')): p for n, p in params.items()} + need_to_go_down = set() + methods = [m.lower() for m in resource.methods or []] + for method in methods: + method_doc = doc.get(method, OrderedDict()) + method_impl = getattr(resource, method) + if hasattr(method_impl, 'im_func'): + method_impl = method_impl.im_func + elif hasattr(method_impl, '__func__'): + method_impl = method_impl.__func__ + method_doc = merge(method_doc, getattr(method_impl, '__apidoc__', OrderedDict())) + if method_doc is not False: + method_doc['docstring'] = parse_docstring(method_impl) + method_params = self.expected_params(method_doc) + method_params = merge(method_params, method_doc.get('params', {})) + inherited_params = OrderedDict((k, v) for k, v in iteritems(params) if k in method_params) + method_doc['params'] = merge(inherited_params, method_params) + for name, param in method_doc['params'].items(): + key = (name, param.get('in', 'query')) + if key in up_params: + need_to_go_down.add(key) + doc[method] = method_doc + # Deduplicate parameters + # For each couple (name, in), if a method overrides it, + # we need to move the paramter down to each method + if need_to_go_down: + for method in methods: + method_doc = doc.get(method) + if not method_doc: + continue + params = { + (n, p.get('in', 'query')): p + for n, p in (method_doc['params'] or {}).items() + } + for key in need_to_go_down: + if key not in params: + method_doc['params'][key[0]] = up_params[key] + doc['params'] = OrderedDict( + (k[0], p) for k, p in up_params.items() if k not in need_to_go_down + ) + return doc + + def expected_params(self, doc): + params = OrderedDict() + if 'expect' not in doc: + return params + + for expect in doc.get('expect', []): + if isinstance(expect, RequestParser): + parser_params = OrderedDict((p['name'], p) for p in expect.__schema__) + params.update(parser_params) + elif isinstance(expect, ModelBase): + params['payload'] = not_none({ + 'name': 'payload', + 'required': True, + 'in': 'body', + 'schema': self.serialize_schema(expect), + }) + elif isinstance(expect, (list, tuple)): + if len(expect) == 2: + # this is (payload, description) shortcut + model, description = expect + params['payload'] = not_none({ + 'name': 'payload', + 'required': True, + 'in': 'body', + 'schema': self.serialize_schema(model), + 'description': description + }) + else: + params['payload'] = not_none({ + 'name': 'payload', + 'required': True, + 'in': 'body', + 'schema': self.serialize_schema(expect), + }) + return params + + def register_errors(self): + responses = {} + for exception, handler in iteritems(self.api.error_handlers): + doc = parse_docstring(handler) + response = { + 'description': doc['summary'] + } + apidoc = getattr(handler, '__apidoc__', {}) + self.process_headers(response, apidoc) + if 'responses' in apidoc: + _, model, _ = list(apidoc['responses'].values())[0] + response['schema'] = self.serialize_schema(model) + responses[exception.__name__] = not_none(response) + return responses + + def serialize_resource(self, ns, resource, url, route_doc=None, **kwargs): + doc = self.extract_resource_doc(resource, url, route_doc=route_doc) + if doc is False: + return + path = { + 'parameters': self.parameters_for(doc) or None + } + for method in [m.lower() for m in resource.methods or []]: + methods = [m.lower() for m in kwargs.get('methods', [])] + if doc[method] is False or methods and method not in methods: + continue + path[method] = self.serialize_operation(doc, method) + path[method]['tags'] = [ns.name] + return not_none(path) + + def serialize_operation(self, doc, method): + operation = { + 'responses': self.responses_for(doc, method) or None, + 'summary': doc[method]['docstring']['summary'], + 'description': self.description_for(doc, method) or None, + 'operationId': self.operation_id_for(doc, method), + 'parameters': self.parameters_for(doc[method]) or None, + 'security': self.security_for(doc, method), + } + # Handle 'produces' mimetypes documentation + if 'produces' in doc[method]: + operation['produces'] = doc[method]['produces'] + # Handle deprecated annotation + if doc.get('deprecated') or doc[method].get('deprecated'): + operation['deprecated'] = True + # Handle form exceptions: + doc_params = list(doc.get('params', {}).values()) + all_params = doc_params + (operation['parameters'] or []) + if all_params and any(p['in'] == 'formData' for p in all_params): + if any(p['type'] == 'file' for p in all_params): + operation['consumes'] = ['multipart/form-data'] + else: + operation['consumes'] = ['application/x-www-form-urlencoded', 'multipart/form-data'] + operation.update(self.vendor_fields(doc, method)) + return not_none(operation) + + def vendor_fields(self, doc, method): + ''' + Extract custom 3rd party Vendor fields prefixed with ``x-`` + + See: http://swagger.io/specification/#specification-extensions-128 + ''' + return dict( + (k if k.startswith('x-') else 'x-{0}'.format(k), v) + for k, v in iteritems(doc[method].get('vendor', {})) + ) + + def description_for(self, doc, method): + '''Extract the description metadata and fallback on the whole docstring''' + parts = [] + if 'description' in doc: + parts.append(doc['description'] or "") + if method in doc and 'description' in doc[method]: + parts.append(doc[method]['description']) + if doc[method]['docstring']['details']: + parts.append(doc[method]['docstring']['details']) + + return '\n'.join(parts).strip() + + def operation_id_for(self, doc, method): + '''Extract the operation id''' + return doc[method]['id'] if 'id' in doc[method] else self.api.default_id(doc['name'], method) + + def parameters_for(self, doc): + params = [] + for name, param in iteritems(doc['params']): + param['name'] = name + if 'type' not in param and 'schema' not in param: + param['type'] = 'string' + if 'in' not in param: + param['in'] = 'query' + + if 'type' in param and 'schema' not in param: + ptype = param.get('type', None) + if isinstance(ptype, (list, tuple)): + typ = ptype[0] + param['type'] = 'array' + param['items'] = {'type': PY_TYPES.get(typ, typ)} + + elif isinstance(ptype, (type, type(None))) and ptype in PY_TYPES: + param['type'] = PY_TYPES[ptype] + + params.append(param) + + # Handle fields mask + mask = doc.get('__mask__') + if (mask and current_app.config['RESTPLUS_MASK_SWAGGER']): + param = { + 'name': current_app.config['RESTPLUS_MASK_HEADER'], + 'in': 'header', + 'type': 'string', + 'format': 'mask', + 'description': 'An optional fields mask', + } + if isinstance(mask, string_types): + param['default'] = mask + params.append(param) + + return params + + def responses_for(self, doc, method): + # TODO: simplify/refactor responses/model handling + responses = {} + + for d in doc, doc[method]: + if 'responses' in d: + for code, response in iteritems(d['responses']): + code = str(code) + if isinstance(response, string_types): + description = response + model = None + kwargs = {} + elif len(response) == 3: + description, model, kwargs = response + elif len(response) == 2: + description, model = response + kwargs = {} + else: + raise ValueError('Unsupported response specification') + description = description or DEFAULT_RESPONSE_DESCRIPTION + if code in responses: + responses[code].update(description=description) + else: + responses[code] = {'description': description} + if model: + schema = self.serialize_schema(model) + envelope = kwargs.get('envelope') + if envelope: + schema = {'properties': {envelope: schema}} + responses[code]['schema'] = schema + self.process_headers(responses[code], doc, method, kwargs.get('headers')) + if 'model' in d: + code = str(d.get('default_code', HTTPStatus.OK)) + if code not in responses: + responses[code] = self.process_headers(DEFAULT_RESPONSE.copy(), doc, method) + responses[code]['schema'] = self.serialize_schema(d['model']) + + if 'docstring' in d: + for name, description in iteritems(d['docstring']['raises']): + for exception, handler in iteritems(self.api.error_handlers): + error_responses = getattr(handler, '__apidoc__', {}).get('responses', {}) + code = str(list(error_responses.keys())[0]) if error_responses else None + if code and exception.__name__ == name: + responses[code] = {'$ref': '#/responses/{0}'.format(name)} + break + + if not responses: + responses[str(HTTPStatus.OK.value)] = self.process_headers(DEFAULT_RESPONSE.copy(), doc, method) + return responses + + def process_headers(self, response, doc, method=None, headers=None): + method_doc = doc.get(method, {}) + if 'headers' in doc or 'headers' in method_doc or headers: + response['headers'] = dict( + (k, _clean_header(v)) for k, v + in itertools.chain( + iteritems(doc.get('headers', {})), + iteritems(method_doc.get('headers', {})), + iteritems(headers or {}) + ) + ) + return response + + def serialize_definitions(self): + return dict( + (name, model.__schema__) + for name, model in iteritems(self._registered_models) + ) + + def serialize_schema(self, model): + if isinstance(model, (list, tuple)): + model = model[0] + return { + 'type': 'array', + 'items': self.serialize_schema(model), + } + + elif isinstance(model, ModelBase): + self.register_model(model) + return ref(model) + + elif isinstance(model, string_types): + self.register_model(model) + return ref(model) + + elif isclass(model) and issubclass(model, fields.Raw): + return self.serialize_schema(model()) + + elif isinstance(model, fields.Raw): + return model.__schema__ + + elif isinstance(model, (type, type(None))) and model in PY_TYPES: + return {'type': PY_TYPES[model]} + + raise ValueError('Model {0} not registered'.format(model)) + + def register_model(self, model): + name = model.name if isinstance(model, ModelBase) else model + if name not in self.api.models: + raise ValueError('Model {0} not registered'.format(name)) + specs = self.api.models[name] + self._registered_models[name] = specs + if isinstance(specs, ModelBase): + for parent in specs.__parents__: + self.register_model(parent) + if isinstance(specs, Model): + for field in itervalues(specs): + self.register_field(field) + return ref(model) + + def register_field(self, field): + if isinstance(field, fields.Polymorph): + for model in itervalues(field.mapping): + self.register_model(model) + elif isinstance(field, fields.Nested): + self.register_model(field.nested) + elif isinstance(field, fields.List): + self.register_field(field.container) + + def security_for(self, doc, method): + security = None + if 'security' in doc: + auth = doc['security'] + security = self.security_requirements(auth) + + if 'security' in doc[method]: + auth = doc[method]['security'] + security = self.security_requirements(auth) + + return security + + def security_requirements(self, value): + if isinstance(value, (list, tuple)): + return [self.security_requirement(v) for v in value] + elif value: + requirement = self.security_requirement(value) + return [requirement] if requirement else None + else: + return [] + + def security_requirement(self, value): + if isinstance(value, (string_types)): + return {value: []} + elif isinstance(value, dict): + return dict( + (k, v if isinstance(v, (list, tuple)) else [v]) + for k, v in iteritems(value) + ) + else: + return None diff --git a/flask_restx/__init__.py b/flask_restx/__init__.py index c02b76a3..47110997 100644 --- a/flask_restx/__init__.py +++ b/flask_restx/__init__.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - from . import fields, reqparse, apidoc, inputs, cors from .api import Api # noqa from .marshalling import marshal, marshal_with, marshal_with_field # noqa diff --git a/flask_restx/api.py b/flask_restx/api.py index 46b24241..efb78340 100644 --- a/flask_restx/api.py +++ b/flask_restx/api.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import difflib import inspect from itertools import chain diff --git a/flask_restx/apidoc.py b/flask_restx/apidoc.py index 23c753ec..baa308fc 100644 --- a/flask_restx/apidoc.py +++ b/flask_restx/apidoc.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from flask import url_for, Blueprint, render_template diff --git a/flask_restx/cors.py b/flask_restx/cors.py index 95412b3b..9fc96981 100644 --- a/flask_restx/cors.py +++ b/flask_restx/cors.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from datetime import timedelta from flask import make_response, request, current_app from functools import update_wrapper diff --git a/flask_restx/errors.py b/flask_restx/errors.py index fbe6e21a..36d15a99 100644 --- a/flask_restx/errors.py +++ b/flask_restx/errors.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import flask from werkzeug.exceptions import HTTPException diff --git a/flask_restx/fields.py b/flask_restx/fields.py index 00764d01..950551cf 100644 --- a/flask_restx/fields.py +++ b/flask_restx/fields.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import re import fnmatch import inspect diff --git a/flask_restx/inputs.py b/flask_restx/inputs.py index 664388e2..1c4ac0ca 100644 --- a/flask_restx/inputs.py +++ b/flask_restx/inputs.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module provide some helpers for advanced types parsing. @@ -16,7 +15,6 @@ def my_type(value): The last line allows you to document properly the type in the Swagger documentation. """ -from __future__ import unicode_literals import re import socket diff --git a/flask_restx/marshalling.py b/flask_restx/marshalling.py index 8e899f7f..f2278af1 100644 --- a/flask_restx/marshalling.py +++ b/flask_restx/marshalling.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from collections import OrderedDict +from collections.abc import OrderedDict from functools import wraps from six import iteritems diff --git a/flask_restx/mask.py b/flask_restx/mask.py index 1784d4ec..02d35d54 100644 --- a/flask_restx/mask.py +++ b/flask_restx/mask.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - import logging import re import six diff --git a/flask_restx/model.py b/flask_restx/model.py index a273f1a8..b660deb7 100644 --- a/flask_restx/model.py +++ b/flask_restx/model.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import copy import re import warnings diff --git a/flask_restx/namespace.py b/flask_restx/namespace.py index 48067776..48cc859f 100644 --- a/flask_restx/namespace.py +++ b/flask_restx/namespace.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import inspect import warnings import logging diff --git a/flask_restx/representations.py b/flask_restx/representations.py index f250d0f6..123b7864 100644 --- a/flask_restx/representations.py +++ b/flask_restx/representations.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - try: from ujson import dumps except ImportError: diff --git a/flask_restx/reqparse.py b/flask_restx/reqparse.py index 18ce6cf9..188f4770 100644 --- a/flask_restx/reqparse.py +++ b/flask_restx/reqparse.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import decimal import six diff --git a/flask_restx/resource.py b/flask_restx/resource.py index 69b83319..dd23d0b5 100644 --- a/flask_restx/resource.py +++ b/flask_restx/resource.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from flask import request from flask.views import MethodView from werkzeug import __version__ as werkzeug_version diff --git a/flask_restx/utils.py b/flask_restx/utils.py index 5ba79f7b..88a0b78c 100644 --- a/flask_restx/utils.py +++ b/flask_restx/utils.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import re from collections import OrderedDict diff --git a/tests/conftest.py b/tests/conftest.py index 220c7b96..66b7eaec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import json import pytest diff --git a/tests/test_accept.py b/tests/test_accept.py index 69578366..6785913f 100644 --- a/tests/test_accept.py +++ b/tests/test_accept.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import flask_restx as restx diff --git a/tests/test_api.py b/tests/test_api.py index 1dd6452a..2b40b235 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import copy from flask import url_for, Blueprint diff --git a/tests/test_apidoc.py b/tests/test_apidoc.py index b1e7bc03..afb4c703 100644 --- a/tests/test_apidoc.py +++ b/tests/test_apidoc.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import pytest from flask import url_for, Blueprint diff --git a/tests/test_cors.py b/tests/test_cors.py index 898daedf..b24a610f 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from flask_restx import Api, Resource, cors diff --git a/tests/test_errors.py b/tests/test_errors.py index 1a753f80..67b646ca 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import json import logging diff --git a/tests/test_fields.py b/tests/test_fields.py index 0351022d..c365bd8b 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from collections import OrderedDict +from collections.abc import OrderedDict from datetime import date, datetime from decimal import Decimal from functools import partial diff --git a/tests/test_fields_mask.py b/tests/test_fields_mask.py index 026f9f17..ce84dcae 100644 --- a/tests/test_fields_mask.py +++ b/tests/test_fields_mask.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import json import pytest diff --git a/tests/test_inputs.py b/tests/test_inputs.py index be2f63a6..93e5b923 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import re import pytz import pytest diff --git a/tests/test_marshalling.py b/tests/test_marshalling.py index 2abb5b7e..d6846ea9 100644 --- a/tests/test_marshalling.py +++ b/tests/test_marshalling.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import pytest from flask_restx import marshal, marshal_with, marshal_with_field, fields, Api, Resource diff --git a/tests/test_model.py b/tests/test_model.py index 824f7100..30219dd2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import copy import pytest diff --git a/tests/test_namespace.py b/tests/test_namespace.py index ce97d284..dfc51067 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals import re import flask_restx as restx diff --git a/tests/test_payload.py b/tests/test_payload.py index 64185ece..0efc72bf 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import flask_restx as restx diff --git a/tests/test_postman.py b/tests/test_postman.py index b5f131dc..e6d5f0dd 100644 --- a/tests/test_postman.py +++ b/tests/test_postman.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - import json from os.path import join, dirname diff --git a/tests/test_reqparse.py b/tests/test_reqparse.py index 3ac89a7a..094c8e94 100644 --- a/tests/test_reqparse.py +++ b/tests/test_reqparse.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import decimal import json import six diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 2edaab72..a0a5c3b5 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - import pytest from jsonschema import ValidationError diff --git a/tests/test_swagger.py b/tests/test_swagger.py index 78b6ce7e..ad8556ff 100644 --- a/tests/test_swagger.py +++ b/tests/test_swagger.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import pytest from textwrap import dedent diff --git a/tests/test_swagger_utils.py b/tests/test_swagger_utils.py index eda8cfbb..86826223 100644 --- a/tests/test_swagger_utils.py +++ b/tests/test_swagger_utils.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from flask_restx.swagger import extract_path, extract_path_params, parse_docstring diff --git a/tests/test_utils.py b/tests/test_utils.py index 20ec93a3..d98d68d0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import pytest from flask_restx import utils From 947d10d5e8d573a521d5bf479f94555709b36996 Mon Sep 17 00:00:00 2001 From: Sam Pegler Date: Thu, 9 Jan 2020 23:01:08 +0000 Subject: [PATCH 02/14] Partial: Remove six. --- flask_restplus/postman.py | 19 ++++++------ flask_restplus/swagger.py | 54 ++++++++++++++------------------- flask_restx/api.py | 7 ++--- flask_restx/fields.py | 27 ++++++++--------- flask_restx/inputs.py | 2 +- flask_restx/marshalling.py | 3 +- flask_restx/mask.py | 9 +++--- flask_restx/model.py | 13 +++----- flask_restx/namespace.py | 7 ++--- flask_restx/reqparse.py | 20 +++++------- flask_restx/utils.py | 7 ++--- requirements/install.pip | 2 -- tests/legacy/test_api_legacy.py | 3 +- tests/test_inputs.py | 11 +++---- tests/test_postman.py | 2 +- tests/test_reqparse.py | 14 ++++----- 16 files changed, 83 insertions(+), 117 deletions(-) diff --git a/flask_restplus/postman.py b/flask_restplus/postman.py index 38d48245..0ab2487d 100644 --- a/flask_restplus/postman.py +++ b/flask_restplus/postman.py @@ -1,13 +1,12 @@ from time import time from uuid import uuid5, NAMESPACE_URL -from six import iteritems -from six.moves.urllib.parse import urlencode +from urllib.parse import urlencode def clean(data): '''Remove all keys where value is None''' - return dict((k, v) for k, v in iteritems(data) if v is not None) + return dict((k, v) for k, v in data.items() if v is not None) DEFAULT_VARS = { @@ -52,15 +51,15 @@ def headers(self): # Add security headers if needed (global then local) for security in self.collection.api.__schema__.get('security', []): - for key, header in iteritems(self.collection.apikeys): + for key, header in self.collection.apikeys.items(): if key in security: headers[header] = '' for security in self.operation.get('security', []): - for key, header in iteritems(self.collection.apikeys): + for key, header in self.collection.apikeys.items(): if key in security: headers[header] = '' - lines = [':'.join(line) for line in iteritems(headers)] + lines = [':'.join(line) for line in headers.items()] return '\n'.join(lines) @property @@ -95,7 +94,7 @@ def process_url(self, urlvars=False): params.update(dict((p['name'], p) for p in self.operation.get('parameters', []))) if not params: return url, None - for name, param in iteritems(params): + for name, param in params.items(): if param['in'] == 'path': url = url.replace('{%s}' % name, ':%s' % name) path_vars[name] = DEFAULT_VARS.get(param['type'], '') @@ -157,10 +156,10 @@ def requests(self): 'summary': 'The API Swagger specifications as JSON', }) # Then iter over API paths and methods - for path, operations in iteritems(self.api.__schema__['paths']): + for path, operations in self.api.__schema__['paths'].items(): path_params = operations.get('parameters', []) - for method, operation in iteritems(operations): + for method, operation in operations.items(): if method != 'parameters': yield Request(self, path, path_params, method, operation) @@ -173,7 +172,7 @@ def folders(self): def apikeys(self): return dict( (name, secdef['name']) - for name, secdef in iteritems(self.api.__schema__.get('securityDefinitions')) + for name, secdef in self.api.__schema__.get('securityDefinitions').items() if secdef.get('in') == 'header' and secdef.get('type') == 'apiKey' ) diff --git a/flask_restplus/swagger.py b/flask_restplus/swagger.py index 43c3a391..4cbd2474 100644 --- a/flask_restplus/swagger.py +++ b/flask_restplus/swagger.py @@ -2,12 +2,7 @@ import re from inspect import isclass, getdoc -try: - from collections.abc import OrderedDict, Hashable -except ImportError: - # TODO Remove this to drop Python2 support - from collections import OrderedDict, Hashable -from six import string_types, itervalues, iteritems, iterkeys +from collections import OrderedDict, Hashable from flask import current_app from werkzeug.routing import parse_rule @@ -18,10 +13,7 @@ from .utils import merge, not_none, not_none_sorted from ._http import HTTPStatus -try: - from urllib.parse import quote -except ImportError: - from urllib import quote +from urllib.parse import quote #: Maps Flask/Werkzeug rooting types to Swagger ones PATH_TYPES = { @@ -98,7 +90,7 @@ def _param_to_header(param): def _clean_header(header): - if isinstance(header, string_types): + if isinstance(header, str): header = {'description': header} typedef = header.get('type', 'string') if isinstance(typedef, Hashable) and typedef in PY_TYPES: @@ -212,7 +204,7 @@ def as_dict(self): 'basePath': basepath, 'paths': not_none_sorted(paths), 'info': infos, - 'produces': list(iterkeys(self.api.representations)), + 'produces': list(self.api.representations.keys()), 'consumes': ['application/json'], 'securityDefinitions': self.api.authorizations or None, 'security': self.security_requirements(self.api.security) or None, @@ -233,7 +225,7 @@ def extract_tags(self, api): tags = [] by_name = {} for tag in api.tags: - if isinstance(tag, string_types): + if isinstance(tag, str): tag = {'name': tag} elif isinstance(tag, (list, tuple)): tag = {'name': tag[0], 'description': tag[1]} @@ -296,7 +288,7 @@ def extract_resource_doc(self, resource, url, route_doc=None): method_doc['docstring'] = parse_docstring(method_impl) method_params = self.expected_params(method_doc) method_params = merge(method_params, method_doc.get('params', {})) - inherited_params = OrderedDict((k, v) for k, v in iteritems(params) if k in method_params) + inherited_params = OrderedDict((k, v) for k, v in params.items() if k in method_params) method_doc['params'] = merge(inherited_params, method_params) for name, param in method_doc['params'].items(): key = (name, param.get('in', 'query')) @@ -361,7 +353,7 @@ def expected_params(self, doc): def register_errors(self): responses = {} - for exception, handler in iteritems(self.api.error_handlers): + for exception, handler in self.api.error_handlers.items(): doc = parse_docstring(handler) response = { 'description': doc['summary'] @@ -423,7 +415,7 @@ def vendor_fields(self, doc, method): ''' return dict( (k if k.startswith('x-') else 'x-{0}'.format(k), v) - for k, v in iteritems(doc[method].get('vendor', {})) + for k, v in doc[method].get('vendor', {}).items() ) def description_for(self, doc, method): @@ -444,7 +436,7 @@ def operation_id_for(self, doc, method): def parameters_for(self, doc): params = [] - for name, param in iteritems(doc['params']): + for name, param in doc['params'].items(): param['name'] = name if 'type' not in param and 'schema' not in param: param['type'] = 'string' @@ -473,7 +465,7 @@ def parameters_for(self, doc): 'format': 'mask', 'description': 'An optional fields mask', } - if isinstance(mask, string_types): + if isinstance(mask, str): param['default'] = mask params.append(param) @@ -485,9 +477,9 @@ def responses_for(self, doc, method): for d in doc, doc[method]: if 'responses' in d: - for code, response in iteritems(d['responses']): + for code, response in d['responses'].items(): code = str(code) - if isinstance(response, string_types): + if isinstance(response, str): description = response model = None kwargs = {} @@ -517,8 +509,8 @@ def responses_for(self, doc, method): responses[code]['schema'] = self.serialize_schema(d['model']) if 'docstring' in d: - for name, description in iteritems(d['docstring']['raises']): - for exception, handler in iteritems(self.api.error_handlers): + for name, description in d['docstring']['raises'].items(): + for exception, handler in self.api.error_handlers.items(): error_responses = getattr(handler, '__apidoc__', {}).get('responses', {}) code = str(list(error_responses.keys())[0]) if error_responses else None if code and exception.__name__ == name: @@ -535,9 +527,9 @@ def process_headers(self, response, doc, method=None, headers=None): response['headers'] = dict( (k, _clean_header(v)) for k, v in itertools.chain( - iteritems(doc.get('headers', {})), - iteritems(method_doc.get('headers', {})), - iteritems(headers or {}) + doc.get('headers', {}).items(), + method_doc.get('headers', {}).items(), + headers.items() if headers else dict().items() ) ) return response @@ -545,7 +537,7 @@ def process_headers(self, response, doc, method=None, headers=None): def serialize_definitions(self): return dict( (name, model.__schema__) - for name, model in iteritems(self._registered_models) + for name, model in self._registered_models.items() ) def serialize_schema(self, model): @@ -560,7 +552,7 @@ def serialize_schema(self, model): self.register_model(model) return ref(model) - elif isinstance(model, string_types): + elif isinstance(model, str): self.register_model(model) return ref(model) @@ -585,13 +577,13 @@ def register_model(self, model): for parent in specs.__parents__: self.register_model(parent) if isinstance(specs, Model): - for field in itervalues(specs): + for field in specs.values(): self.register_field(field) return ref(model) def register_field(self, field): if isinstance(field, fields.Polymorph): - for model in itervalues(field.mapping): + for model in field.mapping.values(): self.register_model(model) elif isinstance(field, fields.Nested): self.register_model(field.nested) @@ -620,12 +612,12 @@ def security_requirements(self, value): return [] def security_requirement(self, value): - if isinstance(value, (string_types)): + if isinstance(value, (str)): return {value: []} elif isinstance(value, dict): return dict( (k, v if isinstance(v, (list, tuple)) else [v]) - for k, v in iteritems(value) + for k, v in value.items() ) else: return None diff --git a/flask_restx/api.py b/flask_restx/api.py index efb78340..450cbaa9 100644 --- a/flask_restx/api.py +++ b/flask_restx/api.py @@ -4,7 +4,6 @@ import logging import operator import re -import six import sys import warnings @@ -509,7 +508,7 @@ def add_namespace(self, ns, path=None): urls = self.ns_urls(ns, r.urls) self.register_resource(ns, r.resource, *urls, **r.kwargs) # Register models - for name, definition in six.iteritems(ns.models): + for name, definition in ns.models.items(): self.models[name] = definition if not self.blueprint and self.app is not None: self._configure_namespace_logger(self.app, ns) @@ -586,7 +585,7 @@ def _own_and_child_error_handlers(self): rv = OrderedDict() rv.update(self.error_handlers) for ns in self.namespaces: - for exception, handler in six.iteritems(ns.error_handlers): + for exception, handler in ns.error_handlers.items(): rv[exception] = handler return rv @@ -704,7 +703,7 @@ def handle_error(self, e): headers = Headers() - for typecheck, handler in six.iteritems(self._own_and_child_error_handlers): + for typecheck, handler in self._own_and_child_error_handlers.items(): if isinstance(e, typecheck): result = handler(e) default_data, code, headers = unpack( diff --git a/flask_restx/fields.py b/flask_restx/fields.py index 950551cf..97957f7f 100644 --- a/flask_restx/fields.py +++ b/flask_restx/fields.py @@ -7,8 +7,7 @@ from decimal import Decimal, ROUND_HALF_EVEN from email.utils import formatdate -from six import iteritems, itervalues, text_type, string_types -from six.moves.urllib.parse import urlparse, urlunparse +from urllib.parse import urlparse, urlunparse from flask import url_for, request from werkzeug.utils import cached_property @@ -56,7 +55,7 @@ class MarshallingError(RestError): def __init__(self, underlying_exception): # just put the contextual representation of the error to hint on what # went wrong without exposing internals - super(MarshallingError, self).__init__(text_type(underlying_exception)) + super(MarshallingError, self).__init__(str(underlying_exception)) def is_indexable_but_not_string(obj): @@ -424,9 +423,7 @@ def schema(self): class String(StringMixin, Raw): """ - Marshal a value as a string. Uses ``six.text_type`` so values will - be converted to :class:`unicode` in python2 and :class:`str` in - python3. + Marshal a value as a string. """ def __init__(self, *args, **kwargs): @@ -437,7 +434,7 @@ def __init__(self, *args, **kwargs): def format(self, value): try: - return text_type(value) + return str(value) except ValueError as ve: raise MarshallingError(ve) @@ -493,7 +490,7 @@ class Arbitrary(NumberMixin, Raw): """ def format(self, value): - return text_type(Decimal(value)) + return str(Decimal(value)) ZERO = Decimal() @@ -512,7 +509,7 @@ def format(self, value): dvalue = Decimal(value) if not dvalue.is_normal() and dvalue != ZERO: raise MarshallingError("Invalid Fixed precision number.") - return text_type(dvalue.quantize(self.precision, rounding=ROUND_HALF_EVEN)) + return str(dvalue.quantize(self.precision, rounding=ROUND_HALF_EVEN)) class Boolean(Raw): @@ -549,7 +546,7 @@ def __init__(self, dt_format="iso8601", **kwargs): def parse(self, value): if value is None: return None - elif isinstance(value, string_types): + elif isinstance(value, str): parser = ( datetime_from_iso8601 if self.dt_format == "iso8601" @@ -621,7 +618,7 @@ def __init__(self, **kwargs): def parse(self, value): if value is None: return None - elif isinstance(value, string_types): + elif isinstance(value, str): return date_from_iso8601(value) elif isinstance(value, datetime): return value.date() @@ -682,7 +679,7 @@ class FormattedString(StringMixin, Raw): def __init__(self, src_str, **kwargs): super(FormattedString, self).__init__(**kwargs) - self.src_str = text_type(src_str) + self.src_str = str(src_str) def output(self, key, obj, **kwargs): try: @@ -732,7 +729,7 @@ class Polymorph(Nested): def __init__(self, mapping, required=False, **kwargs): self.mapping = mapping - parent = self.resolve_ancestor(list(itervalues(mapping))) + parent = self.resolve_ancestor(list(mapping.values())) super(Polymorph, self).__init__(parent, allow_null=not required, **kwargs) def output(self, key, obj, ordered=False, **kwargs): @@ -749,7 +746,7 @@ def output(self, key, obj, ordered=False, **kwargs): raise ValueError("Polymorph field only accept class instances") candidates = [ - fields for cls, fields in iteritems(self.mapping) if type(value) == cls + fields for cls, fields in self.mapping.items() if type(value) == cls ] if len(candidates) <= 0: @@ -822,7 +819,7 @@ def _flatten(self, obj): if obj == self._obj and self._flat is not None: return self._flat if isinstance(obj, dict): - self._flat = [x for x in iteritems(obj)] + self._flat = [x for x in obj.items()] else: def __match_attributes(attribute): diff --git a/flask_restx/inputs.py b/flask_restx/inputs.py index 1c4ac0ca..912ae164 100644 --- a/flask_restx/inputs.py +++ b/flask_restx/inputs.py @@ -21,7 +21,7 @@ def my_type(value): from datetime import datetime, time, timedelta from email.utils import parsedate_tz, mktime_tz -from six.moves.urllib.parse import urlparse +from urllib.parse import urlparse import aniso8601 import pytz diff --git a/flask_restx/marshalling.py b/flask_restx/marshalling.py index f2278af1..0b530e12 100644 --- a/flask_restx/marshalling.py +++ b/flask_restx/marshalling.py @@ -1,6 +1,5 @@ from collections.abc import OrderedDict from functools import wraps -from six import iteritems from flask import request, current_app, has_app_context @@ -175,7 +174,7 @@ def __format_field(key, val): (k, marshal(data, v, skip_none=skip_none, ordered=ordered)) if isinstance(v, dict) else __format_field(k, v) - for k, v in iteritems(fields) + for k, v in fields.items() ) if skip_none: diff --git a/flask_restx/mask.py b/flask_restx/mask.py index 02d35d54..f2204459 100644 --- a/flask_restx/mask.py +++ b/flask_restx/mask.py @@ -1,6 +1,5 @@ import logging import re -import six from collections import OrderedDict from inspect import isclass @@ -34,7 +33,7 @@ class Mask(OrderedDict): def __init__(self, mask=None, skip=False, **kwargs): self.skip = skip - if isinstance(mask, six.string_types): + if isinstance(mask, str): super(Mask, self).__init__() self.parse(mask) elif isinstance(mask, (dict, OrderedDict)): @@ -142,7 +141,7 @@ def filter_data(self, data): """ out = {} - for field, content in six.iteritems(self): + for field, content in self.items(): if field == "*": continue elif isinstance(content, Mask): @@ -159,7 +158,7 @@ def filter_data(self, data): out[field] = data.get(field, None) if "*" in self.keys(): - for key, value in six.iteritems(data): + for key, value in data.items(): if key not in out: out[key] = value return out @@ -169,7 +168,7 @@ def __str__(self): ",".join( [ "".join((k, str(v))) if isinstance(v, Mask) else k - for k, v in six.iteritems(self) + for k, v in self.items() ] ) ) diff --git a/flask_restx/model.py b/flask_restx/model.py index b660deb7..9552cedd 100644 --- a/flask_restx/model.py +++ b/flask_restx/model.py @@ -4,12 +4,7 @@ from collections import OrderedDict -try: - from collections.abc import MutableMapping -except ImportError: - # TODO Remove this to drop Python2 support - from collections import MutableMapping -from six import iteritems, itervalues +from collections.abc import MutableMapping from werkzeug.utils import cached_property from .mask import Mask @@ -151,7 +146,7 @@ def _schema(self): properties = self.wrapper() required = set() discriminator = None - for name, field in iteritems(self): + for name, field in self.items(): field = instance(field) properties[name] = field.__schema__ if field.required: @@ -186,7 +181,7 @@ def resolved(self): # Handle discriminator candidates = [ - f for f in itervalues(resolved) if getattr(f, "discriminator", None) + f for f in resolved.values() if getattr(f, "discriminator", None) ] # Ensure the is only one discriminator if len(candidates) > 1: @@ -240,7 +235,7 @@ def clone(cls, name, *parents): def __deepcopy__(self, memo): obj = self.__class__( self.name, - [(key, copy.deepcopy(value, memo)) for key, value in iteritems(self)], + [(key, copy.deepcopy(value, memo)) for key, value in self.items()], mask=self.__mask__, strict=self.__strict__, ) diff --git a/flask_restx/namespace.py b/flask_restx/namespace.py index 48cc859f..86b4b337 100644 --- a/flask_restx/namespace.py +++ b/flask_restx/namespace.py @@ -3,7 +3,6 @@ import logging from collections import namedtuple, OrderedDict -import six from flask import request from flask.views import http_method_funcs @@ -129,7 +128,7 @@ def _build_doc(self, cls, doc): def doc(self, shortcut=None, **kwargs): """A decorator to add some api documentation to the decorated object""" - if isinstance(shortcut, six.text_type): + if isinstance(shortcut, str): kwargs["id"] = shortcut show = shortcut if isinstance(shortcut, bool) else True @@ -354,8 +353,8 @@ def payload(self): def unshortcut_params_description(data): if "params" in data: - for name, description in six.iteritems(data["params"]): - if isinstance(description, six.string_types): + for name, description in data["params"].items(): + if isinstance(description, str): data["params"][name] = {"description": description} diff --git a/flask_restx/reqparse.py b/flask_restx/reqparse.py index 188f4770..c5827c83 100644 --- a/flask_restx/reqparse.py +++ b/flask_restx/reqparse.py @@ -1,10 +1,6 @@ import decimal -import six -try: - from collections.abc import Hashable -except ImportError: - from collections import Hashable +from collections import Hashable from copy import deepcopy from flask import current_app, request @@ -63,8 +59,6 @@ def __setattr__(self, name, value): SPLIT_CHAR = "," -text_type = lambda x: six.text_type(x) # noqa - class Argument(object): """ @@ -78,7 +72,7 @@ class Argument(object): :param bool ignore: Whether to ignore cases where the argument fails type conversion :param type: The type to which the request argument should be converted. If a type raises an exception, the message in the error will be returned in the response. - Defaults to :class:`unicode` in python2 and :class:`str` in python3. + Defaults to :class:`str`. :param location: The attributes of the :class:`flask.Request` object to source the arguments from (ex: headers, args, etc.), can be an iterator. The last item listed takes precedence in the result set. @@ -102,7 +96,7 @@ def __init__( dest=None, required=False, ignore=False, - type=text_type, + type=str, location=( "json", "values", @@ -137,7 +131,7 @@ def source(self, request): Pulls values off the request in the provided location :param request: The flask request object to parse arguments from """ - if isinstance(self.location, six.string_types): + if isinstance(self.location, str): if self.location in {"json", "get_json"}: value = request.get_json(silent=True) else: @@ -197,9 +191,9 @@ def handle_validation_error(self, error, bundle_errors): dict with the name of the argument and the error message to be bundled """ - error_str = six.text_type(error) + error_str = str(error) error_msg = ( - " ".join([six.text_type(self.help), error_str]) if self.help else error_str + " ".join([str(self.help), error_str]) if self.help else error_str ) errors = {self.name: error_msg} @@ -268,7 +262,7 @@ def parse(self, request, bundle_errors=False): results.append(value) if not results and self.required: - if isinstance(self.location, six.string_types): + if isinstance(self.location, str): location = _friendly_location.get(self.location, self.location) else: locations = [_friendly_location.get(loc, loc) for loc in self.location] diff --git a/flask_restx/utils.py b/flask_restx/utils.py index 88a0b78c..809a29b3 100644 --- a/flask_restx/utils.py +++ b/flask_restx/utils.py @@ -2,7 +2,6 @@ from collections import OrderedDict from copy import deepcopy -from six import iteritems from ._http import HTTPStatus @@ -36,7 +35,7 @@ def merge(first, second): if not isinstance(second, dict): return second result = deepcopy(first) - for key, value in iteritems(second): + for key, value in second.items(): if key in result and isinstance(result[key], dict): result[key] = merge(result[key], value) else: @@ -69,7 +68,7 @@ def not_none(data): :return: The same dictionary without the keys with values to ``None`` :rtype: dict """ - return dict((k, v) for k, v in iteritems(data) if v is not None) + return dict((k, v) for k, v in data.items() if v is not None) def not_none_sorted(data): @@ -80,7 +79,7 @@ def not_none_sorted(data): :return: The same dictionary without the keys with values to ``None`` :rtype: OrderedDict """ - return OrderedDict((k, v) for k, v in sorted(iteritems(data)) if v is not None) + return OrderedDict((k, v) for k, v in sorted(data.items()) if v is not None) def unpack(response, default_code=HTTPStatus.OK): diff --git a/requirements/install.pip b/requirements/install.pip index e7a7cb13..7a1d0345 100644 --- a/requirements/install.pip +++ b/requirements/install.pip @@ -4,5 +4,3 @@ jsonschema Flask>=0.8, !=2.0.0 werkzeug !=2.0.0 pytz -six>=1.3.0 -enum34; python_version < '3.4' diff --git a/tests/legacy/test_api_legacy.py b/tests/legacy/test_api_legacy.py index 5d6649c8..b9ae717c 100644 --- a/tests/legacy/test_api_legacy.py +++ b/tests/legacy/test_api_legacy.py @@ -3,7 +3,6 @@ import flask import pytest -import six from json import dumps, JSONEncoder @@ -255,7 +254,7 @@ def test_resource_resp(self, app, mocker): def test_resource_text_plain(self, app): def text(data, code, headers=None): - return flask.make_response(six.text_type(data)) + return flask.make_response(str(data)) class Foo(restx.Resource): representations = { diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 93e5b923..e6de7b5b 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -3,7 +3,6 @@ import pytest from datetime import date, datetime -from six import text_type from flask_restx import inputs @@ -146,9 +145,9 @@ def assert_bad_url(self, validator, value, details=None): with pytest.raises(ValueError) as cm: validator(value) if details: - assert text_type(cm.value) == ". ".join((msg, details)).format(value) + assert str(cm.value) == '. '.join((msg, details)).format(value) else: - assert text_type(cm.value).startswith(msg.format(value)) + assert str(cm.value).startswith(msg.format(value)) @pytest.mark.parametrize( "url", @@ -197,7 +196,7 @@ def test_bad_urls(self, url): # msg = '{0} is not a valid URL'.format(url) # with pytest.raises(ValueError) as cm: # validator(url) - # assert text_type(cm.exception).startswith(msg) + # assert str(cm.exception).startswith(msg) @pytest.mark.parametrize( "url", @@ -455,7 +454,7 @@ def test_valid_url(self, url): def test_bad_url(self, url): with pytest.raises(ValueError) as cm: inputs.url(url) - assert text_type(cm.value).startswith("{0} is not a valid URL".format(url)) + assert str(cm.value).startswith('{0} is not a valid URL'.format(url)) @pytest.mark.parametrize( "url", @@ -469,7 +468,7 @@ def test_bad_url(self, url): def test_bad_url_with_suggestion(self, url): with pytest.raises(ValueError) as cm: inputs.url(url) - assert text_type( + assert str( cm.value ) == "{0} is not a valid URL. Did you mean: http://{0}".format(url) diff --git a/tests/test_postman.py b/tests/test_postman.py index e6d5f0dd..0317bf64 100644 --- a/tests/test_postman.py +++ b/tests/test_postman.py @@ -7,7 +7,7 @@ import flask_restx as restx -from six.moves.urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urlparse with open(join(dirname(__file__), "postman-v1.schema.json")) as f: diff --git a/tests/test_reqparse.py b/tests/test_reqparse.py index 094c8e94..2b319ed9 100644 --- a/tests/test_reqparse.py +++ b/tests/test_reqparse.py @@ -1,6 +1,6 @@ import decimal import json -import six +import io import pytest from werkzeug.exceptions import BadRequest @@ -510,7 +510,7 @@ def test_type_filestorage(self, app): fdata = "foo bar baz qux".encode("utf-8") with app.test_request_context( - "/bubble", method="POST", data={"foo": (six.BytesIO(fdata), "baz.txt")} + "/bubble", method="POST", data={"foo": (io.BytesIO(fdata), "baz.txt")} ): args = parser.parse_args() @@ -531,7 +531,7 @@ def _custom_type(f): fdata = "foo bar baz qux".encode("utf-8") with app.test_request_context( - "/bubble", method="POST", data={"foo": (six.BytesIO(fdata), "baz.txt")} + "/bubble", method="POST", data={"foo": (io.BytesIO(fdata), "baz.txt")} ): args = parser.parse_args() @@ -805,12 +805,10 @@ def test_default_operators(self): assert arg.operators[0] == "=" assert len(arg.operators) == 1 - def test_default_type(self, mocker): - mock_six = mocker.patch("flask_restx.reqparse.six") + def test_default_type(self): arg = Argument("foo") - sentinel = object() - arg.type(sentinel) - mock_six.text_type.assert_called_with(sentinel) + sentinel = 666 + assert arg.type(sentinel) == "666" def test_default_default(self): arg = Argument("foo") From 36a9d42ad8fa2796e608bdca6fd2b93fc1b51e76 Mon Sep 17 00:00:00 2001 From: Sam Pegler Date: Thu, 9 Jan 2020 23:01:19 +0000 Subject: [PATCH 03/14] Partial: Clean up setup/tox. --- setup.py | 2 -- tox.ini | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 004c50cd..6cc57e5a 100644 --- a/setup.py +++ b/setup.py @@ -101,8 +101,6 @@ def pip(filename): "Intended Audience :: Developers", "Topic :: System :: Software Distribution", "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", diff --git a/tox.ini b/tox.ini index 93cc40f9..9de56daf 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py{27,35,36,37,38}, pypy, pypy3, doc +envlist = py{35,36,37,38}, pypy, pypy3, doc [testenv] commands = {posargs:inv test qa} From fbdc6967d94766a89e1dcc2fd2d69f17fc2648ef Mon Sep 17 00:00:00 2001 From: Sam Pegler Date: Fri, 10 Jan 2020 12:18:02 +0000 Subject: [PATCH 04/14] Partial: Remove a few stragglers. --- doc/parsing.rst | 2 +- flask_restx/schemas/__init__.py | 9 +-------- tasks.py | 3 --- tests/legacy/test_api_legacy.py | 3 --- tests/legacy/test_api_with_blueprint.py | 3 --- 5 files changed, 2 insertions(+), 18 deletions(-) diff --git a/doc/parsing.rst b/doc/parsing.rst index 49bcb7fe..5ceab636 100644 --- a/doc/parsing.rst +++ b/doc/parsing.rst @@ -38,7 +38,7 @@ It looks for two arguments in the :attr:`flask.Request.values` dict: an integer .. note :: The default argument type is a unicode string. - This will be ``str`` in python3 and ``unicode`` in python2. + This will be ``str``. If you specify the ``help`` value, it will be rendered as the error message when a type error is raised while parsing it. diff --git a/flask_restx/schemas/__init__.py b/flask_restx/schemas/__init__.py index d6dc2ac0..9a0b7a60 100644 --- a/flask_restx/schemas/__init__.py +++ b/flask_restx/schemas/__init__.py @@ -1,21 +1,14 @@ -# -*- coding: utf-8 -*- """ This module give access to OpenAPI specifications schemas and allows to validate specs against them. .. versionadded:: 0.12.1 """ -from __future__ import unicode_literals - import io import json import pkg_resources -try: - from collections.abc import Mapping -except ImportError: - # TODO Remove this to drop Python2 support - from collections import Mapping +from collections.abc import Mapping from jsonschema import Draft4Validator from flask_restx import errors diff --git a/tasks.py b/tasks.py index cd3ef330..4f14832a 100644 --- a/tasks.py +++ b/tasks.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - import os import sys diff --git a/tests/legacy/test_api_legacy.py b/tests/legacy/test_api_legacy.py index b9ae717c..09973933 100644 --- a/tests/legacy/test_api_legacy.py +++ b/tests/legacy/test_api_legacy.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import flask import pytest diff --git a/tests/legacy/test_api_with_blueprint.py b/tests/legacy/test_api_with_blueprint.py index a59ab54e..995d606a 100644 --- a/tests/legacy/test_api_with_blueprint.py +++ b/tests/legacy/test_api_with_blueprint.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import flask from flask import Blueprint, request From 1794980a77d77911d6cdf287564afd6d48c3fdb3 Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 12 Apr 2020 15:54:43 +0200 Subject: [PATCH 05/14] chore: remove python < 3.7 support --- .github/workflows/test.yml | 2 +- README.rst | 2 +- doc/index.rst | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89c7f4b5..5154b1d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, pypy2, pypy3] + python-version: [3.5, 3.6, 3.7, 3.8, pypy2, pypy3] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 diff --git a/README.rst b/README.rst index a8b64b7e..1b01fe62 100644 --- a/README.rst +++ b/README.rst @@ -38,7 +38,7 @@ and expose its documentation properly using `Swagger`_. Compatibility ============= -Flask-RESTX requires Python 2.7 or 3.4+. +Flask-RESTX requires Python 3.5+. On Flask Compatibility ====================== diff --git a/doc/index.rst b/doc/index.rst index 6e9aafb1..25367d46 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -33,13 +33,13 @@ development and to support our users. Compatibility ============= -flask-restx requires Python 2.7+ or 3.4+. +Flask-RESTX requires Python 3.7+. Installation ============ -You can install flask-restx with pip: +You can install Flask-RESTX with pip: .. code-block:: console From f30ae90ffdd23d9c3e93fe96f4097180befa9efb Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 12 Apr 2020 16:26:06 +0200 Subject: [PATCH 06/14] fix: wrong OrderedDict import --- flask_restx/marshalling.py | 2 +- tests/test_fields.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flask_restx/marshalling.py b/flask_restx/marshalling.py index 0b530e12..6648f66f 100644 --- a/flask_restx/marshalling.py +++ b/flask_restx/marshalling.py @@ -1,4 +1,4 @@ -from collections.abc import OrderedDict +from collections import OrderedDict from functools import wraps from flask import request, current_app, has_app_context diff --git a/tests/test_fields.py b/tests/test_fields.py index c365bd8b..8b449887 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,4 +1,4 @@ -from collections.abc import OrderedDict +from collections import OrderedDict from datetime import date, datetime from decimal import Decimal from functools import partial From 9f8003daca2a835b66bd397680ca6b276de9d71e Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 12 Apr 2020 16:32:59 +0200 Subject: [PATCH 07/14] fix: also remove pypy2 tests --- .github/workflows/test.yml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5154b1d0..e2f1f285 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, 3.6, 3.7, 3.8, pypy2, pypy3] + python-version: [3.5, 3.6, 3.7, 3.8, pypy3] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 diff --git a/tox.ini b/tox.ini index 9de56daf..a8899c46 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py{35,36,37,38}, pypy, pypy3, doc +envlist = py{35,36,37,38}, pypy3, doc [testenv] commands = {posargs:inv test qa} From 849a5fa06416cefbc42f4506de3cc48875d95d2f Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 16 Oct 2022 12:30:18 +0200 Subject: [PATCH 08/14] chore: black formatting --- .github/workflows/test.yml | 2 +- flask_restplus/__about__.py | 6 +- flask_restplus/_http.py | 245 +++++++++++------- flask_restplus/postman.py | 167 ++++++------ flask_restplus/swagger.py | 501 +++++++++++++++++++----------------- flask_restx/model.py | 4 +- flask_restx/reqparse.py | 4 +- tests/test_inputs.py | 4 +- tox.ini | 2 +- 9 files changed, 506 insertions(+), 429 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2f1f285..16404306 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, 3.6, 3.7, 3.8, pypy3] + python-version: [3.7, 3.8, 3.9, 3.10, pypy3] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 diff --git a/flask_restplus/__about__.py b/flask_restplus/__about__.py index 7ab99eaf..76264044 100644 --- a/flask_restplus/__about__.py +++ b/flask_restplus/__about__.py @@ -1,2 +1,4 @@ -__version__ = '0.13.1.dev' -__description__ = 'Fully featured framework for fast, easy and documented API development with Flask' +__version__ = "0.13.1.dev" +__description__ = ( + "Fully featured framework for fast, easy and documented API development with Flask" +) diff --git a/flask_restplus/_http.py b/flask_restplus/_http.py index 8fee4765..993b266d 100644 --- a/flask_restplus/_http.py +++ b/flask_restplus/_http.py @@ -19,7 +19,8 @@ class HTTPStatus(IntEnum): * RFC 2295: Transparent Content Negotiation in HTTP * RFC 2774: An HTTP Extension Framework """ - def __new__(cls, value, phrase, description=''): + + def __new__(cls, value, phrase, description=""): obj = int.__new__(cls, value) obj._value_ = value @@ -31,110 +32,154 @@ def __str__(self): return str(self.value) # informational - CONTINUE = 100, 'Continue', 'Request received, please continue' - SWITCHING_PROTOCOLS = (101, 'Switching Protocols', - 'Switching to new protocol; obey Upgrade header') - PROCESSING = 102, 'Processing' + CONTINUE = 100, "Continue", "Request received, please continue" + SWITCHING_PROTOCOLS = ( + 101, + "Switching Protocols", + "Switching to new protocol; obey Upgrade header", + ) + PROCESSING = 102, "Processing" # success - OK = 200, 'OK', 'Request fulfilled, document follows' - CREATED = 201, 'Created', 'Document created, URL follows' - ACCEPTED = (202, 'Accepted', - 'Request accepted, processing continues off-line') - NON_AUTHORITATIVE_INFORMATION = (203, - 'Non-Authoritative Information', 'Request fulfilled from cache') - NO_CONTENT = 204, 'No Content', 'Request fulfilled, nothing follows' - RESET_CONTENT = 205, 'Reset Content', 'Clear input form for further input' - PARTIAL_CONTENT = 206, 'Partial Content', 'Partial content follows' - MULTI_STATUS = 207, 'Multi-Status' - ALREADY_REPORTED = 208, 'Already Reported' - IM_USED = 226, 'IM Used' + OK = 200, "OK", "Request fulfilled, document follows" + CREATED = 201, "Created", "Document created, URL follows" + ACCEPTED = (202, "Accepted", "Request accepted, processing continues off-line") + NON_AUTHORITATIVE_INFORMATION = ( + 203, + "Non-Authoritative Information", + "Request fulfilled from cache", + ) + NO_CONTENT = 204, "No Content", "Request fulfilled, nothing follows" + RESET_CONTENT = 205, "Reset Content", "Clear input form for further input" + PARTIAL_CONTENT = 206, "Partial Content", "Partial content follows" + MULTI_STATUS = 207, "Multi-Status" + ALREADY_REPORTED = 208, "Already Reported" + IM_USED = 226, "IM Used" # redirection - MULTIPLE_CHOICES = (300, 'Multiple Choices', - 'Object has several resources -- see URI list') - MOVED_PERMANENTLY = (301, 'Moved Permanently', - 'Object moved permanently -- see URI list') - FOUND = 302, 'Found', 'Object moved temporarily -- see URI list' - SEE_OTHER = 303, 'See Other', 'Object moved -- see Method and URL list' - NOT_MODIFIED = (304, 'Not Modified', - 'Document has not changed since given time') - USE_PROXY = (305, 'Use Proxy', - 'You must use proxy specified in Location to access this resource') - TEMPORARY_REDIRECT = (307, 'Temporary Redirect', - 'Object moved temporarily -- see URI list') - PERMANENT_REDIRECT = (308, 'Permanent Redirect', - 'Object moved temporarily -- see URI list') + MULTIPLE_CHOICES = ( + 300, + "Multiple Choices", + "Object has several resources -- see URI list", + ) + MOVED_PERMANENTLY = ( + 301, + "Moved Permanently", + "Object moved permanently -- see URI list", + ) + FOUND = 302, "Found", "Object moved temporarily -- see URI list" + SEE_OTHER = 303, "See Other", "Object moved -- see Method and URL list" + NOT_MODIFIED = (304, "Not Modified", "Document has not changed since given time") + USE_PROXY = ( + 305, + "Use Proxy", + "You must use proxy specified in Location to access this resource", + ) + TEMPORARY_REDIRECT = ( + 307, + "Temporary Redirect", + "Object moved temporarily -- see URI list", + ) + PERMANENT_REDIRECT = ( + 308, + "Permanent Redirect", + "Object moved temporarily -- see URI list", + ) # client error - BAD_REQUEST = (400, 'Bad Request', - 'Bad request syntax or unsupported method') - UNAUTHORIZED = (401, 'Unauthorized', - 'No permission -- see authorization schemes') - PAYMENT_REQUIRED = (402, 'Payment Required', - 'No payment -- see charging schemes') - FORBIDDEN = (403, 'Forbidden', - 'Request forbidden -- authorization will not help') - NOT_FOUND = (404, 'Not Found', - 'Nothing matches the given URI') - METHOD_NOT_ALLOWED = (405, 'Method Not Allowed', - 'Specified method is invalid for this resource') - NOT_ACCEPTABLE = (406, 'Not Acceptable', - 'URI not available in preferred format') - PROXY_AUTHENTICATION_REQUIRED = (407, - 'Proxy Authentication Required', - 'You must authenticate with this proxy before proceeding') - REQUEST_TIMEOUT = (408, 'Request Timeout', - 'Request timed out; try again later') - CONFLICT = 409, 'Conflict', 'Request conflict' - GONE = (410, 'Gone', - 'URI no longer exists and has been permanently removed') - LENGTH_REQUIRED = (411, 'Length Required', - 'Client must specify Content-Length') - PRECONDITION_FAILED = (412, 'Precondition Failed', - 'Precondition in headers is false') - REQUEST_ENTITY_TOO_LARGE = (413, 'Request Entity Too Large', - 'Entity is too large') - REQUEST_URI_TOO_LONG = (414, 'Request-URI Too Long', - 'URI is too long') - UNSUPPORTED_MEDIA_TYPE = (415, 'Unsupported Media Type', - 'Entity body in unsupported format') - REQUESTED_RANGE_NOT_SATISFIABLE = (416, - 'Requested Range Not Satisfiable', - 'Cannot satisfy request range') - EXPECTATION_FAILED = (417, 'Expectation Failed', - 'Expect condition could not be satisfied') - UNPROCESSABLE_ENTITY = 422, 'Unprocessable Entity' - LOCKED = 423, 'Locked' - FAILED_DEPENDENCY = 424, 'Failed Dependency' - UPGRADE_REQUIRED = 426, 'Upgrade Required' - PRECONDITION_REQUIRED = (428, 'Precondition Required', - 'The origin server requires the request to be conditional') - TOO_MANY_REQUESTS = (429, 'Too Many Requests', - 'The user has sent too many requests in ' - 'a given amount of time ("rate limiting")') - REQUEST_HEADER_FIELDS_TOO_LARGE = (431, - 'Request Header Fields Too Large', - 'The server is unwilling to process the request because its header ' - 'fields are too large') + BAD_REQUEST = (400, "Bad Request", "Bad request syntax or unsupported method") + UNAUTHORIZED = (401, "Unauthorized", "No permission -- see authorization schemes") + PAYMENT_REQUIRED = (402, "Payment Required", "No payment -- see charging schemes") + FORBIDDEN = (403, "Forbidden", "Request forbidden -- authorization will not help") + NOT_FOUND = (404, "Not Found", "Nothing matches the given URI") + METHOD_NOT_ALLOWED = ( + 405, + "Method Not Allowed", + "Specified method is invalid for this resource", + ) + NOT_ACCEPTABLE = (406, "Not Acceptable", "URI not available in preferred format") + PROXY_AUTHENTICATION_REQUIRED = ( + 407, + "Proxy Authentication Required", + "You must authenticate with this proxy before proceeding", + ) + REQUEST_TIMEOUT = (408, "Request Timeout", "Request timed out; try again later") + CONFLICT = 409, "Conflict", "Request conflict" + GONE = (410, "Gone", "URI no longer exists and has been permanently removed") + LENGTH_REQUIRED = (411, "Length Required", "Client must specify Content-Length") + PRECONDITION_FAILED = ( + 412, + "Precondition Failed", + "Precondition in headers is false", + ) + REQUEST_ENTITY_TOO_LARGE = (413, "Request Entity Too Large", "Entity is too large") + REQUEST_URI_TOO_LONG = (414, "Request-URI Too Long", "URI is too long") + UNSUPPORTED_MEDIA_TYPE = ( + 415, + "Unsupported Media Type", + "Entity body in unsupported format", + ) + REQUESTED_RANGE_NOT_SATISFIABLE = ( + 416, + "Requested Range Not Satisfiable", + "Cannot satisfy request range", + ) + EXPECTATION_FAILED = ( + 417, + "Expectation Failed", + "Expect condition could not be satisfied", + ) + UNPROCESSABLE_ENTITY = 422, "Unprocessable Entity" + LOCKED = 423, "Locked" + FAILED_DEPENDENCY = 424, "Failed Dependency" + UPGRADE_REQUIRED = 426, "Upgrade Required" + PRECONDITION_REQUIRED = ( + 428, + "Precondition Required", + "The origin server requires the request to be conditional", + ) + TOO_MANY_REQUESTS = ( + 429, + "Too Many Requests", + "The user has sent too many requests in " + 'a given amount of time ("rate limiting")', + ) + REQUEST_HEADER_FIELDS_TOO_LARGE = ( + 431, + "Request Header Fields Too Large", + "The server is unwilling to process the request because its header " + "fields are too large", + ) # server errors - INTERNAL_SERVER_ERROR = (500, 'Internal Server Error', - 'Server got itself in trouble') - NOT_IMPLEMENTED = (501, 'Not Implemented', - 'Server does not support this operation') - BAD_GATEWAY = (502, 'Bad Gateway', - 'Invalid responses from another server/proxy') - SERVICE_UNAVAILABLE = (503, 'Service Unavailable', - 'The server cannot process the request due to a high load') - GATEWAY_TIMEOUT = (504, 'Gateway Timeout', - 'The gateway server did not receive a timely response') - HTTP_VERSION_NOT_SUPPORTED = (505, 'HTTP Version Not Supported', - 'Cannot fulfill request') - VARIANT_ALSO_NEGOTIATES = 506, 'Variant Also Negotiates' - INSUFFICIENT_STORAGE = 507, 'Insufficient Storage' - LOOP_DETECTED = 508, 'Loop Detected' - NOT_EXTENDED = 510, 'Not Extended' - NETWORK_AUTHENTICATION_REQUIRED = (511, - 'Network Authentication Required', - 'The client needs to authenticate to gain network access') + INTERNAL_SERVER_ERROR = ( + 500, + "Internal Server Error", + "Server got itself in trouble", + ) + NOT_IMPLEMENTED = (501, "Not Implemented", "Server does not support this operation") + BAD_GATEWAY = (502, "Bad Gateway", "Invalid responses from another server/proxy") + SERVICE_UNAVAILABLE = ( + 503, + "Service Unavailable", + "The server cannot process the request due to a high load", + ) + GATEWAY_TIMEOUT = ( + 504, + "Gateway Timeout", + "The gateway server did not receive a timely response", + ) + HTTP_VERSION_NOT_SUPPORTED = ( + 505, + "HTTP Version Not Supported", + "Cannot fulfill request", + ) + VARIANT_ALSO_NEGOTIATES = 506, "Variant Also Negotiates" + INSUFFICIENT_STORAGE = 507, "Insufficient Storage" + LOOP_DETECTED = 508, "Loop Detected" + NOT_EXTENDED = 510, "Not Extended" + NETWORK_AUTHENTICATION_REQUIRED = ( + 511, + "Network Authentication Required", + "The client needs to authenticate to gain network access", + ) diff --git a/flask_restplus/postman.py b/flask_restplus/postman.py index 0ab2487d..ee0e8229 100644 --- a/flask_restplus/postman.py +++ b/flask_restplus/postman.py @@ -5,19 +5,20 @@ def clean(data): - '''Remove all keys where value is None''' + """Remove all keys where value is None""" return dict((k, v) for k, v in data.items() if v is not None) DEFAULT_VARS = { - 'string': '', - 'integer': 0, - 'number': 0, + "string": "", + "integer": 0, + "number": 0, } class Request(object): - '''Wraps a Swagger operation into a Postman Request''' + """Wraps a Swagger operation into a Postman Request""" + def __init__(self, collection, path, params, method, operation): self.collection = collection self.path = path @@ -27,90 +28,94 @@ def __init__(self, collection, path, params, method, operation): @property def id(self): - seed = str(' '.join((self.method, self.url))) + seed = str(" ".join((self.method, self.url))) return str(uuid5(self.collection.uuid, seed)) @property def url(self): - return self.collection.api.base_url.rstrip('/') + self.path + return self.collection.api.base_url.rstrip("/") + self.path @property def headers(self): headers = {} # Handle content-type - if self.method != 'GET': - consumes = self.collection.api.__schema__.get('consumes', []) - consumes = self.operation.get('consumes', consumes) + if self.method != "GET": + consumes = self.collection.api.__schema__.get("consumes", []) + consumes = self.operation.get("consumes", consumes) if len(consumes): - headers['Content-Type'] = consumes[-1] + headers["Content-Type"] = consumes[-1] # Add all parameters headers - for param in self.operation.get('parameters', []): - if param['in'] == 'header': - headers[param['name']] = param.get('default', '') + for param in self.operation.get("parameters", []): + if param["in"] == "header": + headers[param["name"]] = param.get("default", "") # Add security headers if needed (global then local) - for security in self.collection.api.__schema__.get('security', []): + for security in self.collection.api.__schema__.get("security", []): for key, header in self.collection.apikeys.items(): if key in security: - headers[header] = '' - for security in self.operation.get('security', []): + headers[header] = "" + for security in self.operation.get("security", []): for key, header in self.collection.apikeys.items(): if key in security: - headers[header] = '' + headers[header] = "" - lines = [':'.join(line) for line in headers.items()] - return '\n'.join(lines) + lines = [":".join(line) for line in headers.items()] + return "\n".join(lines) @property def folder(self): - if 'tags' not in self.operation or len(self.operation['tags']) == 0: + if "tags" not in self.operation or len(self.operation["tags"]) == 0: return - tag = self.operation['tags'][0] + tag = self.operation["tags"][0] for folder in self.collection.folders: if folder.tag == tag: return folder.id def as_dict(self, urlvars=False): url, variables = self.process_url(urlvars) - return clean({ - 'id': self.id, - 'method': self.method, - 'name': self.operation['operationId'], - 'description': self.operation.get('summary'), - 'url': url, - 'headers': self.headers, - 'collectionId': self.collection.id, - 'folder': self.folder, - 'pathVariables': variables, - 'time': int(time()), - }) + return clean( + { + "id": self.id, + "method": self.method, + "name": self.operation["operationId"], + "description": self.operation.get("summary"), + "url": url, + "headers": self.headers, + "collectionId": self.collection.id, + "folder": self.folder, + "pathVariables": variables, + "time": int(time()), + } + ) def process_url(self, urlvars=False): url = self.url path_vars = {} url_vars = {} - params = dict((p['name'], p) for p in self.params) - params.update(dict((p['name'], p) for p in self.operation.get('parameters', []))) + params = dict((p["name"], p) for p in self.params) + params.update( + dict((p["name"], p) for p in self.operation.get("parameters", [])) + ) if not params: return url, None for name, param in params.items(): - if param['in'] == 'path': - url = url.replace('{%s}' % name, ':%s' % name) - path_vars[name] = DEFAULT_VARS.get(param['type'], '') - elif param['in'] == 'query' and urlvars: - default = DEFAULT_VARS.get(param['type'], '') - url_vars[name] = param.get('default', default) + if param["in"] == "path": + url = url.replace("{%s}" % name, ":%s" % name) + path_vars[name] = DEFAULT_VARS.get(param["type"], "") + elif param["in"] == "query" and urlvars: + default = DEFAULT_VARS.get(param["type"], "") + url_vars[name] = param.get("default", default) if url_vars: - url = '?'.join((url, urlencode(url_vars))) + url = "?".join((url, urlencode(url_vars))) return url, path_vars class Folder(object): def __init__(self, collection, tag): self.collection = collection - self.tag = tag['name'] - self.description = tag['description'] + self.tag = tag["name"] + self.description = tag["description"] @property def id(self): @@ -118,23 +123,23 @@ def id(self): @property def order(self): - return [ - r.id for r in self.collection.requests - if r.folder == self.id - ] + return [r.id for r in self.collection.requests if r.folder == self.id] def as_dict(self): - return clean({ - 'id': self.id, - 'name': self.tag, - 'description': self.description, - 'order': self.order, - 'collectionId': self.collection.id - }) + return clean( + { + "id": self.id, + "name": self.tag, + "description": self.description, + "order": self.order, + "collectionId": self.collection.id, + } + ) class PostmanCollectionV1(object): - '''Postman Collection (V1 format) serializer''' + """Postman Collection (V1 format) serializer""" + def __init__(self, api, swagger=False): self.api = api self.swagger = swagger @@ -151,38 +156,46 @@ def id(self): def requests(self): if self.swagger: # First request is Swagger specifications - yield Request(self, '/swagger.json', {}, 'get', { - 'operationId': 'Swagger specifications', - 'summary': 'The API Swagger specifications as JSON', - }) + yield Request( + self, + "/swagger.json", + {}, + "get", + { + "operationId": "Swagger specifications", + "summary": "The API Swagger specifications as JSON", + }, + ) # Then iter over API paths and methods - for path, operations in self.api.__schema__['paths'].items(): - path_params = operations.get('parameters', []) + for path, operations in self.api.__schema__["paths"].items(): + path_params = operations.get("parameters", []) for method, operation in operations.items(): - if method != 'parameters': + if method != "parameters": yield Request(self, path, path_params, method, operation) @property def folders(self): - for tag in self.api.__schema__['tags']: + for tag in self.api.__schema__["tags"]: yield Folder(self, tag) @property def apikeys(self): return dict( - (name, secdef['name']) - for name, secdef in self.api.__schema__.get('securityDefinitions').items() - if secdef.get('in') == 'header' and secdef.get('type') == 'apiKey' + (name, secdef["name"]) + for name, secdef in self.api.__schema__.get("securityDefinitions").items() + if secdef.get("in") == "header" and secdef.get("type") == "apiKey" ) def as_dict(self, urlvars=False): - return clean({ - 'id': self.id, - 'name': ' '.join((self.api.title, self.api.version)), - 'description': self.api.description, - 'order': [r.id for r in self.requests if not r.folder], - 'requests': [r.as_dict(urlvars=urlvars) for r in self.requests], - 'folders': [f.as_dict() for f in self.folders], - 'timestamp': int(time()), - }) + return clean( + { + "id": self.id, + "name": " ".join((self.api.title, self.api.version)), + "description": self.api.description, + "order": [r.id for r in self.requests if not r.folder], + "requests": [r.as_dict(urlvars=urlvars) for r in self.requests], + "folders": [f.as_dict() for f in self.folders], + "timestamp": int(time()), + } + ) diff --git a/flask_restplus/swagger.py b/flask_restplus/swagger.py index 4cbd2474..d9959aa7 100644 --- a/flask_restplus/swagger.py +++ b/flask_restplus/swagger.py @@ -17,119 +17,121 @@ #: Maps Flask/Werkzeug rooting types to Swagger ones PATH_TYPES = { - 'int': 'integer', - 'float': 'number', - 'string': 'string', - 'default': 'string', + "int": "integer", + "float": "number", + "string": "string", + "default": "string", } #: Maps Python primitives types to Swagger ones PY_TYPES = { - int: 'integer', - float: 'number', - str: 'string', - bool: 'boolean', - None: 'void' + int: "integer", + float: "number", + str: "string", + bool: "boolean", + None: "void", } -RE_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>') +RE_URL = re.compile(r"<(?:[^:<>]+:)?([^<>]+)>") -DEFAULT_RESPONSE_DESCRIPTION = 'Success' -DEFAULT_RESPONSE = {'description': DEFAULT_RESPONSE_DESCRIPTION} +DEFAULT_RESPONSE_DESCRIPTION = "Success" +DEFAULT_RESPONSE = {"description": DEFAULT_RESPONSE_DESCRIPTION} -RE_RAISES = re.compile(r'^:raises\s+(?P[\w\d_]+)\s*:\s*(?P.*)$', re.MULTILINE) +RE_RAISES = re.compile( + r"^:raises\s+(?P[\w\d_]+)\s*:\s*(?P.*)$", re.MULTILINE +) def ref(model): - '''Return a reference to model in definitions''' + """Return a reference to model in definitions""" name = model.name if isinstance(model, ModelBase) else model - return {'$ref': '#/definitions/{0}'.format(quote(name, safe=''))} + return {"$ref": "#/definitions/{0}".format(quote(name, safe=""))} def _v(value): - '''Dereference values (callable)''' + """Dereference values (callable)""" return value() if callable(value) else value def extract_path(path): - ''' + """ Transform a Flask/Werkzeug URL pattern in a Swagger one. - ''' - return RE_URL.sub(r'{\1}', path) + """ + return RE_URL.sub(r"{\1}", path) def extract_path_params(path): - ''' + """ Extract Flask-style parameters from an URL pattern as Swagger ones. - ''' + """ params = OrderedDict() for converter, arguments, variable in parse_rule(path): if not converter: continue - param = { - 'name': variable, - 'in': 'path', - 'required': True - } + param = {"name": variable, "in": "path", "required": True} if converter in PATH_TYPES: - param['type'] = PATH_TYPES[converter] + param["type"] = PATH_TYPES[converter] elif converter in current_app.url_map.converters: - param['type'] = 'string' + param["type"] = "string" else: - raise ValueError('Unsupported type converter: %s' % converter) + raise ValueError("Unsupported type converter: %s" % converter) params[variable] = param return params def _param_to_header(param): - param.pop('in', None) - param.pop('name', None) + param.pop("in", None) + param.pop("name", None) return _clean_header(param) def _clean_header(header): if isinstance(header, str): - header = {'description': header} - typedef = header.get('type', 'string') + header = {"description": header} + typedef = header.get("type", "string") if isinstance(typedef, Hashable) and typedef in PY_TYPES: - header['type'] = PY_TYPES[typedef] - elif isinstance(typedef, (list, tuple)) and len(typedef) == 1 and typedef[0] in PY_TYPES: - header['type'] = 'array' - header['items'] = {'type': PY_TYPES[typedef[0]]} - elif hasattr(typedef, '__schema__'): + header["type"] = PY_TYPES[typedef] + elif ( + isinstance(typedef, (list, tuple)) + and len(typedef) == 1 + and typedef[0] in PY_TYPES + ): + header["type"] = "array" + header["items"] = {"type": PY_TYPES[typedef[0]]} + elif hasattr(typedef, "__schema__"): header.update(typedef.__schema__) else: - header['type'] = typedef + header["type"] = typedef return not_none(header) def parse_docstring(obj): raw = getdoc(obj) - summary = raw.strip(' \n').split('\n')[0].split('.')[0] if raw else None + summary = raw.strip(" \n").split("\n")[0].split(".")[0] if raw else None raises = {} - details = raw.replace(summary, '').lstrip('. \n').strip(' \n') if raw else None - for match in RE_RAISES.finditer(raw or ''): - raises[match.group('name')] = match.group('description') + details = raw.replace(summary, "").lstrip(". \n").strip(" \n") if raw else None + for match in RE_RAISES.finditer(raw or ""): + raises[match.group("name")] = match.group("description") if details: - details = details.replace(match.group(0), '') + details = details.replace(match.group(0), "") parsed = { - 'raw': raw, - 'summary': summary or None, - 'details': details or None, - 'returns': None, - 'params': [], - 'raises': raises, + "raw": raw, + "summary": summary or None, + "details": details or None, + "returns": None, + "params": [], + "raises": raises, } return parsed def is_hidden(resource, route_doc=None): - ''' + """ Determine whether a Resource has been hidden from Swagger documentation i.e. by using Api.doc(False) decorator - ''' + """ if route_doc is False: return True else: @@ -137,41 +139,42 @@ def is_hidden(resource, route_doc=None): class Swagger(object): - ''' + """ A Swagger documentation wrapper for an API instance. - ''' + """ + def __init__(self, api): self.api = api self._registered_models = {} def as_dict(self): - ''' + """ Output the specification as a serializable ``dict``. :returns: the full Swagger specification in a serializable format :rtype: dict - ''' + """ basepath = self.api.base_path - if len(basepath) > 1 and basepath.endswith('/'): + if len(basepath) > 1 and basepath.endswith("/"): basepath = basepath[:-1] infos = { - 'title': _v(self.api.title), - 'version': _v(self.api.version), + "title": _v(self.api.title), + "version": _v(self.api.version), } if self.api.description: - infos['description'] = _v(self.api.description) + infos["description"] = _v(self.api.description) if self.api.terms_url: - infos['termsOfService'] = _v(self.api.terms_url) + infos["termsOfService"] = _v(self.api.terms_url) if self.api.contact and (self.api.contact_email or self.api.contact_url): - infos['contact'] = { - 'name': _v(self.api.contact), - 'email': _v(self.api.contact_email), - 'url': _v(self.api.contact_url), + infos["contact"] = { + "name": _v(self.api.contact), + "email": _v(self.api.contact_email), + "url": _v(self.api.contact_url), } if self.api.license: - infos['license'] = {'name': _v(self.api.license)} + infos["license"] = {"name": _v(self.api.license)} if self.api.license_url: - infos['license']['url'] = _v(self.api.license_url) + infos["license"]["url"] = _v(self.api.license_url) paths = {} tags = self.extract_tags(self.api) @@ -184,11 +187,7 @@ def as_dict(self): for url in self.api.ns_urls(ns, urls): path = extract_path(url) serialized = self.serialize_resource( - ns, - resource, - url, - route_doc=route_doc, - **kwargs + ns, resource, url, route_doc=route_doc, **kwargs ) paths[path] = serialized @@ -197,28 +196,30 @@ def as_dict(self): if ns.authorizations: if self.api.authorizations is None: self.api.authorizations = {} - self.api.authorizations = merge(self.api.authorizations, ns.authorizations) + self.api.authorizations = merge( + self.api.authorizations, ns.authorizations + ) specs = { - 'swagger': '2.0', - 'basePath': basepath, - 'paths': not_none_sorted(paths), - 'info': infos, - 'produces': list(self.api.representations.keys()), - 'consumes': ['application/json'], - 'securityDefinitions': self.api.authorizations or None, - 'security': self.security_requirements(self.api.security) or None, - 'tags': tags, - 'definitions': self.serialize_definitions() or None, - 'responses': responses or None, - 'host': self.get_host(), + "swagger": "2.0", + "basePath": basepath, + "paths": not_none_sorted(paths), + "info": infos, + "produces": list(self.api.representations.keys()), + "consumes": ["application/json"], + "securityDefinitions": self.api.authorizations or None, + "security": self.security_requirements(self.api.security) or None, + "tags": tags, + "definitions": self.serialize_definitions() or None, + "responses": responses or None, + "host": self.get_host(), } return not_none(specs) def get_host(self): - hostname = current_app.config.get('SERVER_NAME', None) or None + hostname = current_app.config.get("SERVER_NAME", None) or None if hostname and self.api.blueprint and self.api.blueprint.subdomain: - hostname = '.'.join((self.api.blueprint.subdomain, hostname)) + hostname = ".".join((self.api.blueprint.subdomain, hostname)) return hostname def extract_tags(self, api): @@ -226,72 +227,72 @@ def extract_tags(self, api): by_name = {} for tag in api.tags: if isinstance(tag, str): - tag = {'name': tag} + tag = {"name": tag} elif isinstance(tag, (list, tuple)): - tag = {'name': tag[0], 'description': tag[1]} - elif isinstance(tag, dict) and 'name' in tag: + tag = {"name": tag[0], "description": tag[1]} + elif isinstance(tag, dict) and "name" in tag: pass else: - raise ValueError('Unsupported tag format for {0}'.format(tag)) + raise ValueError("Unsupported tag format for {0}".format(tag)) tags.append(tag) - by_name[tag['name']] = tag + by_name[tag["name"]] = tag for ns in api.namespaces: # hide namespaces without any Resources if not ns.resources: continue # hide namespaces with all Resources hidden from Swagger documentation - if all( - is_hidden(r.resource, route_doc=r.route_doc) - for r in ns.resources - ): + if all(is_hidden(r.resource, route_doc=r.route_doc) for r in ns.resources): continue if ns.name not in by_name: - tags.append({ - 'name': ns.name, - 'description': ns.description - } if ns.description else {'name': ns.name}) + tags.append( + {"name": ns.name, "description": ns.description} + if ns.description + else {"name": ns.name} + ) elif ns.description: - by_name[ns.name]['description'] = ns.description + by_name[ns.name]["description"] = ns.description return tags def extract_resource_doc(self, resource, url, route_doc=None): route_doc = {} if route_doc is None else route_doc if route_doc is False: return False - doc = merge(getattr(resource, '__apidoc__', {}), route_doc) + doc = merge(getattr(resource, "__apidoc__", {}), route_doc) if doc is False: return False # ensure unique names for multiple routes to the same resource # provides different Swagger operationId's doc["name"] = ( - "{}_{}".format(resource.__name__, url) - if route_doc - else resource.__name__ + "{}_{}".format(resource.__name__, url) if route_doc else resource.__name__ ) - params = merge(self.expected_params(doc), doc.get('params', OrderedDict())) + params = merge(self.expected_params(doc), doc.get("params", OrderedDict())) params = merge(params, extract_path_params(url)) # Track parameters for late deduplication - up_params = {(n, p.get('in', 'query')): p for n, p in params.items()} + up_params = {(n, p.get("in", "query")): p for n, p in params.items()} need_to_go_down = set() methods = [m.lower() for m in resource.methods or []] for method in methods: method_doc = doc.get(method, OrderedDict()) method_impl = getattr(resource, method) - if hasattr(method_impl, 'im_func'): + if hasattr(method_impl, "im_func"): method_impl = method_impl.im_func - elif hasattr(method_impl, '__func__'): + elif hasattr(method_impl, "__func__"): method_impl = method_impl.__func__ - method_doc = merge(method_doc, getattr(method_impl, '__apidoc__', OrderedDict())) + method_doc = merge( + method_doc, getattr(method_impl, "__apidoc__", OrderedDict()) + ) if method_doc is not False: - method_doc['docstring'] = parse_docstring(method_impl) + method_doc["docstring"] = parse_docstring(method_impl) method_params = self.expected_params(method_doc) - method_params = merge(method_params, method_doc.get('params', {})) - inherited_params = OrderedDict((k, v) for k, v in params.items() if k in method_params) - method_doc['params'] = merge(inherited_params, method_params) - for name, param in method_doc['params'].items(): - key = (name, param.get('in', 'query')) + method_params = merge(method_params, method_doc.get("params", {})) + inherited_params = OrderedDict( + (k, v) for k, v in params.items() if k in method_params + ) + method_doc["params"] = merge(inherited_params, method_params) + for name, param in method_doc["params"].items(): + key = (name, param.get("in", "query")) if key in up_params: need_to_go_down.add(key) doc[method] = method_doc @@ -304,65 +305,69 @@ def extract_resource_doc(self, resource, url, route_doc=None): if not method_doc: continue params = { - (n, p.get('in', 'query')): p - for n, p in (method_doc['params'] or {}).items() + (n, p.get("in", "query")): p + for n, p in (method_doc["params"] or {}).items() } for key in need_to_go_down: if key not in params: - method_doc['params'][key[0]] = up_params[key] - doc['params'] = OrderedDict( + method_doc["params"][key[0]] = up_params[key] + doc["params"] = OrderedDict( (k[0], p) for k, p in up_params.items() if k not in need_to_go_down ) return doc def expected_params(self, doc): params = OrderedDict() - if 'expect' not in doc: + if "expect" not in doc: return params - for expect in doc.get('expect', []): + for expect in doc.get("expect", []): if isinstance(expect, RequestParser): - parser_params = OrderedDict((p['name'], p) for p in expect.__schema__) + parser_params = OrderedDict((p["name"], p) for p in expect.__schema__) params.update(parser_params) elif isinstance(expect, ModelBase): - params['payload'] = not_none({ - 'name': 'payload', - 'required': True, - 'in': 'body', - 'schema': self.serialize_schema(expect), - }) + params["payload"] = not_none( + { + "name": "payload", + "required": True, + "in": "body", + "schema": self.serialize_schema(expect), + } + ) elif isinstance(expect, (list, tuple)): if len(expect) == 2: # this is (payload, description) shortcut model, description = expect - params['payload'] = not_none({ - 'name': 'payload', - 'required': True, - 'in': 'body', - 'schema': self.serialize_schema(model), - 'description': description - }) + params["payload"] = not_none( + { + "name": "payload", + "required": True, + "in": "body", + "schema": self.serialize_schema(model), + "description": description, + } + ) else: - params['payload'] = not_none({ - 'name': 'payload', - 'required': True, - 'in': 'body', - 'schema': self.serialize_schema(expect), - }) + params["payload"] = not_none( + { + "name": "payload", + "required": True, + "in": "body", + "schema": self.serialize_schema(expect), + } + ) return params def register_errors(self): responses = {} for exception, handler in self.api.error_handlers.items(): doc = parse_docstring(handler) - response = { - 'description': doc['summary'] - } - apidoc = getattr(handler, '__apidoc__', {}) + response = {"description": doc["summary"]} + apidoc = getattr(handler, "__apidoc__", {}) self.process_headers(response, apidoc) - if 'responses' in apidoc: - _, model, _ = list(apidoc['responses'].values())[0] - response['schema'] = self.serialize_schema(model) + if "responses" in apidoc: + _, model, _ = list(apidoc["responses"].values())[0] + response["schema"] = self.serialize_schema(model) responses[exception.__name__] = not_none(response) return responses @@ -370,103 +375,108 @@ def serialize_resource(self, ns, resource, url, route_doc=None, **kwargs): doc = self.extract_resource_doc(resource, url, route_doc=route_doc) if doc is False: return - path = { - 'parameters': self.parameters_for(doc) or None - } + path = {"parameters": self.parameters_for(doc) or None} for method in [m.lower() for m in resource.methods or []]: - methods = [m.lower() for m in kwargs.get('methods', [])] + methods = [m.lower() for m in kwargs.get("methods", [])] if doc[method] is False or methods and method not in methods: continue path[method] = self.serialize_operation(doc, method) - path[method]['tags'] = [ns.name] + path[method]["tags"] = [ns.name] return not_none(path) def serialize_operation(self, doc, method): operation = { - 'responses': self.responses_for(doc, method) or None, - 'summary': doc[method]['docstring']['summary'], - 'description': self.description_for(doc, method) or None, - 'operationId': self.operation_id_for(doc, method), - 'parameters': self.parameters_for(doc[method]) or None, - 'security': self.security_for(doc, method), + "responses": self.responses_for(doc, method) or None, + "summary": doc[method]["docstring"]["summary"], + "description": self.description_for(doc, method) or None, + "operationId": self.operation_id_for(doc, method), + "parameters": self.parameters_for(doc[method]) or None, + "security": self.security_for(doc, method), } # Handle 'produces' mimetypes documentation - if 'produces' in doc[method]: - operation['produces'] = doc[method]['produces'] + if "produces" in doc[method]: + operation["produces"] = doc[method]["produces"] # Handle deprecated annotation - if doc.get('deprecated') or doc[method].get('deprecated'): - operation['deprecated'] = True + if doc.get("deprecated") or doc[method].get("deprecated"): + operation["deprecated"] = True # Handle form exceptions: - doc_params = list(doc.get('params', {}).values()) - all_params = doc_params + (operation['parameters'] or []) - if all_params and any(p['in'] == 'formData' for p in all_params): - if any(p['type'] == 'file' for p in all_params): - operation['consumes'] = ['multipart/form-data'] + doc_params = list(doc.get("params", {}).values()) + all_params = doc_params + (operation["parameters"] or []) + if all_params and any(p["in"] == "formData" for p in all_params): + if any(p["type"] == "file" for p in all_params): + operation["consumes"] = ["multipart/form-data"] else: - operation['consumes'] = ['application/x-www-form-urlencoded', 'multipart/form-data'] + operation["consumes"] = [ + "application/x-www-form-urlencoded", + "multipart/form-data", + ] operation.update(self.vendor_fields(doc, method)) return not_none(operation) def vendor_fields(self, doc, method): - ''' + """ Extract custom 3rd party Vendor fields prefixed with ``x-`` See: http://swagger.io/specification/#specification-extensions-128 - ''' + """ return dict( - (k if k.startswith('x-') else 'x-{0}'.format(k), v) - for k, v in doc[method].get('vendor', {}).items() + (k if k.startswith("x-") else "x-{0}".format(k), v) + for k, v in doc[method].get("vendor", {}).items() ) def description_for(self, doc, method): - '''Extract the description metadata and fallback on the whole docstring''' + """Extract the description metadata and fallback on the whole docstring""" parts = [] - if 'description' in doc: - parts.append(doc['description'] or "") - if method in doc and 'description' in doc[method]: - parts.append(doc[method]['description']) - if doc[method]['docstring']['details']: - parts.append(doc[method]['docstring']['details']) + if "description" in doc: + parts.append(doc["description"] or "") + if method in doc and "description" in doc[method]: + parts.append(doc[method]["description"]) + if doc[method]["docstring"]["details"]: + parts.append(doc[method]["docstring"]["details"]) - return '\n'.join(parts).strip() + return "\n".join(parts).strip() def operation_id_for(self, doc, method): - '''Extract the operation id''' - return doc[method]['id'] if 'id' in doc[method] else self.api.default_id(doc['name'], method) + """Extract the operation id""" + return ( + doc[method]["id"] + if "id" in doc[method] + else self.api.default_id(doc["name"], method) + ) def parameters_for(self, doc): params = [] - for name, param in doc['params'].items(): - param['name'] = name - if 'type' not in param and 'schema' not in param: - param['type'] = 'string' - if 'in' not in param: - param['in'] = 'query' - - if 'type' in param and 'schema' not in param: - ptype = param.get('type', None) + for name, param in doc["params"].items(): + param["name"] = name + if "type" not in param and "schema" not in param: + param["type"] = "string" + if "in" not in param: + param["in"] = "query" + + if "type" in param and "schema" not in param: + ptype = param.get("type", None) if isinstance(ptype, (list, tuple)): typ = ptype[0] - param['type'] = 'array' - param['items'] = {'type': PY_TYPES.get(typ, typ)} + param["type"] = "array" + param["items"] = {"type": PY_TYPES.get(typ, typ)} elif isinstance(ptype, (type, type(None))) and ptype in PY_TYPES: - param['type'] = PY_TYPES[ptype] + param["type"] = PY_TYPES[ptype] params.append(param) # Handle fields mask - mask = doc.get('__mask__') - if (mask and current_app.config['RESTPLUS_MASK_SWAGGER']): + mask = doc.get("__mask__") + if mask and current_app.config["RESTPLUS_MASK_SWAGGER"]: param = { - 'name': current_app.config['RESTPLUS_MASK_HEADER'], - 'in': 'header', - 'type': 'string', - 'format': 'mask', - 'description': 'An optional fields mask', + "name": current_app.config["RESTPLUS_MASK_HEADER"], + "in": "header", + "type": "string", + "format": "mask", + "description": "An optional fields mask", } if isinstance(mask, str): - param['default'] = mask + param["default"] = mask params.append(param) return params @@ -476,8 +486,8 @@ def responses_for(self, doc, method): responses = {} for d in doc, doc[method]: - if 'responses' in d: - for code, response in d['responses'].items(): + if "responses" in d: + for code, response in d["responses"].items(): code = str(code) if isinstance(response, str): description = response @@ -489,63 +499,74 @@ def responses_for(self, doc, method): description, model = response kwargs = {} else: - raise ValueError('Unsupported response specification') + raise ValueError("Unsupported response specification") description = description or DEFAULT_RESPONSE_DESCRIPTION if code in responses: responses[code].update(description=description) else: - responses[code] = {'description': description} + responses[code] = {"description": description} if model: schema = self.serialize_schema(model) - envelope = kwargs.get('envelope') + envelope = kwargs.get("envelope") if envelope: - schema = {'properties': {envelope: schema}} - responses[code]['schema'] = schema - self.process_headers(responses[code], doc, method, kwargs.get('headers')) - if 'model' in d: - code = str(d.get('default_code', HTTPStatus.OK)) + schema = {"properties": {envelope: schema}} + responses[code]["schema"] = schema + self.process_headers( + responses[code], doc, method, kwargs.get("headers") + ) + if "model" in d: + code = str(d.get("default_code", HTTPStatus.OK)) if code not in responses: - responses[code] = self.process_headers(DEFAULT_RESPONSE.copy(), doc, method) - responses[code]['schema'] = self.serialize_schema(d['model']) + responses[code] = self.process_headers( + DEFAULT_RESPONSE.copy(), doc, method + ) + responses[code]["schema"] = self.serialize_schema(d["model"]) - if 'docstring' in d: - for name, description in d['docstring']['raises'].items(): + if "docstring" in d: + for name, description in d["docstring"]["raises"].items(): for exception, handler in self.api.error_handlers.items(): - error_responses = getattr(handler, '__apidoc__', {}).get('responses', {}) - code = str(list(error_responses.keys())[0]) if error_responses else None + error_responses = getattr(handler, "__apidoc__", {}).get( + "responses", {} + ) + code = ( + str(list(error_responses.keys())[0]) + if error_responses + else None + ) if code and exception.__name__ == name: - responses[code] = {'$ref': '#/responses/{0}'.format(name)} + responses[code] = {"$ref": "#/responses/{0}".format(name)} break if not responses: - responses[str(HTTPStatus.OK.value)] = self.process_headers(DEFAULT_RESPONSE.copy(), doc, method) + responses[str(HTTPStatus.OK.value)] = self.process_headers( + DEFAULT_RESPONSE.copy(), doc, method + ) return responses def process_headers(self, response, doc, method=None, headers=None): method_doc = doc.get(method, {}) - if 'headers' in doc or 'headers' in method_doc or headers: - response['headers'] = dict( - (k, _clean_header(v)) for k, v - in itertools.chain( - doc.get('headers', {}).items(), - method_doc.get('headers', {}).items(), - headers.items() if headers else dict().items() + if "headers" in doc or "headers" in method_doc or headers: + response["headers"] = dict( + (k, _clean_header(v)) + for k, v in itertools.chain( + doc.get("headers", {}).items(), + method_doc.get("headers", {}).items(), + headers.items() if headers else dict().items(), ) ) return response def serialize_definitions(self): return dict( - (name, model.__schema__) - for name, model in self._registered_models.items() + (name, model.__schema__) for name, model in self._registered_models.items() ) def serialize_schema(self, model): if isinstance(model, (list, tuple)): model = model[0] return { - 'type': 'array', - 'items': self.serialize_schema(model), + "type": "array", + "items": self.serialize_schema(model), } elif isinstance(model, ModelBase): @@ -563,14 +584,14 @@ def serialize_schema(self, model): return model.__schema__ elif isinstance(model, (type, type(None))) and model in PY_TYPES: - return {'type': PY_TYPES[model]} + return {"type": PY_TYPES[model]} - raise ValueError('Model {0} not registered'.format(model)) + raise ValueError("Model {0} not registered".format(model)) def register_model(self, model): name = model.name if isinstance(model, ModelBase) else model if name not in self.api.models: - raise ValueError('Model {0} not registered'.format(name)) + raise ValueError("Model {0} not registered".format(name)) specs = self.api.models[name] self._registered_models[name] = specs if isinstance(specs, ModelBase): @@ -592,12 +613,12 @@ def register_field(self, field): def security_for(self, doc, method): security = None - if 'security' in doc: - auth = doc['security'] + if "security" in doc: + auth = doc["security"] security = self.security_requirements(auth) - if 'security' in doc[method]: - auth = doc[method]['security'] + if "security" in doc[method]: + auth = doc[method]["security"] security = self.security_requirements(auth) return security diff --git a/flask_restx/model.py b/flask_restx/model.py index 9552cedd..dea43c6a 100644 --- a/flask_restx/model.py +++ b/flask_restx/model.py @@ -180,9 +180,7 @@ def resolved(self): resolved.update(parent.resolved) # Handle discriminator - candidates = [ - f for f in resolved.values() if getattr(f, "discriminator", None) - ] + candidates = [f for f in resolved.values() if getattr(f, "discriminator", None)] # Ensure the is only one discriminator if len(candidates) > 1: raise ValueError("There can only be one discriminator by schema") diff --git a/flask_restx/reqparse.py b/flask_restx/reqparse.py index c5827c83..c09c429e 100644 --- a/flask_restx/reqparse.py +++ b/flask_restx/reqparse.py @@ -192,9 +192,7 @@ def handle_validation_error(self, error, bundle_errors): bundled """ error_str = str(error) - error_msg = ( - " ".join([str(self.help), error_str]) if self.help else error_str - ) + error_msg = " ".join([str(self.help), error_str]) if self.help else error_str errors = {self.name: error_msg} if bundle_errors: diff --git a/tests/test_inputs.py b/tests/test_inputs.py index e6de7b5b..c6539b8c 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -145,7 +145,7 @@ def assert_bad_url(self, validator, value, details=None): with pytest.raises(ValueError) as cm: validator(value) if details: - assert str(cm.value) == '. '.join((msg, details)).format(value) + assert str(cm.value) == ". ".join((msg, details)).format(value) else: assert str(cm.value).startswith(msg.format(value)) @@ -454,7 +454,7 @@ def test_valid_url(self, url): def test_bad_url(self, url): with pytest.raises(ValueError) as cm: inputs.url(url) - assert str(cm.value).startswith('{0} is not a valid URL'.format(url)) + assert str(cm.value).startswith("{0} is not a valid URL".format(url)) @pytest.mark.parametrize( "url", diff --git a/tox.ini b/tox.ini index a8899c46..5da0c5a8 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py{35,36,37,38}, pypy3, doc +envlist = py{37, 38, 39, 310}, pypy3, doc [testenv] commands = {posargs:inv test qa} From a38bd216a2b46cc331ba6603da4a552bfa12f2b9 Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 16 Oct 2022 12:33:56 +0200 Subject: [PATCH 09/14] fix: quote python 3.10 in CI --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 16404306..5e2371df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, 3.10, pypy3] + python-version: ["3.7", "3.8", "3.9", "3.10", "pypy3"] steps: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 @@ -40,7 +40,7 @@ jobs: - name: Set up Python 3.8 uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: "3.8" - name: Checkout ${{ github.base_ref }} uses: actions/checkout@v2 with: From 28402a993bc1a689071f0f114ed2137eb4ed4437 Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 16 Oct 2022 12:39:29 +0200 Subject: [PATCH 10/14] fix: changed redirect behavior with latest Flask versions --- tests/legacy/test_api_legacy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/legacy/test_api_legacy.py b/tests/legacy/test_api_legacy.py index 09973933..8c4c8625 100644 --- a/tests/legacy/test_api_legacy.py +++ b/tests/legacy/test_api_legacy.py @@ -375,7 +375,7 @@ def get(self): resp = client.get("/api") assert resp.status_code == 302 - assert resp.headers["Location"] == "http://localhost/" + assert resp.headers["Location"] == "/" def test_calling_owns_endpoint_before_api_init(self): api = restx.Api() From fcc51f1633d795ddda60d37399401e9c3df80db4 Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 16 Oct 2022 12:52:40 +0200 Subject: [PATCH 11/14] fix: python 3.10 compatibility --- flask_restx/reqparse.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flask_restx/reqparse.py b/flask_restx/reqparse.py index c09c429e..de5b9bd5 100644 --- a/flask_restx/reqparse.py +++ b/flask_restx/reqparse.py @@ -1,6 +1,9 @@ import decimal -from collections import Hashable +try: + from collections.abc import Hashable +except ImportError: + from collections import Hashable from copy import deepcopy from flask import current_app, request From 7d1923290e867b269202f5edf3ef7058bb3b2154 Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 16 Oct 2022 12:53:05 +0200 Subject: [PATCH 12/14] chore: update test requirements to make the CI run from python 3.7 to 3.10 --- requirements/test.pip | 12 +++++------- tests/test_logging.py | 3 ++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/requirements/test.pip b/requirements/test.pip index 6945712b..aba77961 100644 --- a/requirements/test.pip +++ b/requirements/test.pip @@ -1,15 +1,13 @@ blinker Faker==2.0.0 mock==3.0.5 -pytest==4.6.5; python_version < '3.5' -pytest==5.4.1; python_version >= '3.5' -pytest-benchmark==3.2.2 -pytest-cov==2.7.1 -pytest-flask==0.15.1 -pytest-mock==1.10.4 +pytest==7.1.3 +pytest-benchmark==3.4.1 +pytest-cov==4.0.0 +pytest-flask==1.2.0 +pytest-mock==3.10.0 pytest-profiling==1.7.0 tzlocal invoke==1.3.0 readme-renderer==24.0 twine==1.15.0 -ossaudit; python_version >= '3.5' diff --git a/tests/test_logging.py b/tests/test_logging.py index 4b366d91..79669aae 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -64,12 +64,13 @@ def get(self): assert len(matching) == 1 def test_override_app_level(self, app, client, caplog): - caplog.set_level(logging.INFO, logger=app.logger.name) + caplog.set_level(logging.DEBUG, logger=app.logger.name) api = restx.Api(app) ns1 = api.namespace("ns1", path="/ns1") ns1.logger.setLevel(logging.DEBUG) ns2 = api.namespace("ns2", path="/ns2") + ns2.logger.setLevel(logging.INFO) @ns1.route("/") class Ns1(restx.Resource): From 51f3917bb28bfb954bb82b589e1d39921cc1efc6 Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 16 Oct 2022 12:55:37 +0200 Subject: [PATCH 13/14] fix: pypy3 CI --- requirements/test.pip | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/requirements/test.pip b/requirements/test.pip index aba77961..c38a570f 100644 --- a/requirements/test.pip +++ b/requirements/test.pip @@ -1,13 +1,12 @@ blinker Faker==2.0.0 mock==3.0.5 -pytest==7.1.3 +pytest==7.0.1 pytest-benchmark==3.4.1 pytest-cov==4.0.0 pytest-flask==1.2.0 -pytest-mock==3.10.0 +pytest-mock==3.6.1 pytest-profiling==1.7.0 tzlocal invoke==1.3.0 -readme-renderer==24.0 -twine==1.15.0 +twine==3.8.0 From 045b8a4587579056d2d76124705eba5939bc9ebb Mon Sep 17 00:00:00 2001 From: ziirish Date: Sun, 16 Oct 2022 20:19:18 +0200 Subject: [PATCH 14/14] fix: make the CI run on every supported python version --- tests/legacy/test_api_legacy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/legacy/test_api_legacy.py b/tests/legacy/test_api_legacy.py index 8c4c8625..a27a454a 100644 --- a/tests/legacy/test_api_legacy.py +++ b/tests/legacy/test_api_legacy.py @@ -375,7 +375,8 @@ def get(self): resp = client.get("/api") assert resp.status_code == 302 - assert resp.headers["Location"] == "/" + # FIXME: The behavior changed somewhere between Flask 2.0.3 and 2.2.x + assert resp.headers["Location"].endswith("/") def test_calling_owns_endpoint_before_api_init(self): api = restx.Api()