diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89c7f4b5..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: [2.7, 3.5, 3.6, 3.7, 3.8, pypy2, 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: 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 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_restplus/__about__.py b/flask_restplus/__about__.py new file mode 100644 index 00000000..76264044 --- /dev/null +++ b/flask_restplus/__about__.py @@ -0,0 +1,4 @@ +__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..993b266d --- /dev/null +++ b/flask_restplus/_http.py @@ -0,0 +1,185 @@ +""" +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..ee0e8229 --- /dev/null +++ b/flask_restplus/postman.py @@ -0,0 +1,201 @@ +from time import time +from uuid import uuid5, NAMESPACE_URL + +from urllib.parse import urlencode + + +def clean(data): + """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, +} + + +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 self.collection.apikeys.items(): + if key in security: + headers[header] = "" + for security in self.operation.get("security", []): + for key, header in self.collection.apikeys.items(): + if key in security: + headers[header] = "" + + 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: + 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 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 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 self.api.__schema__["paths"].items(): + path_params = operations.get("parameters", []) + + for method, operation in operations.items(): + 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 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()), + } + ) diff --git a/flask_restplus/swagger.py b/flask_restplus/swagger.py new file mode 100644 index 00000000..d9959aa7 --- /dev/null +++ b/flask_restplus/swagger.py @@ -0,0 +1,644 @@ +import itertools +import re + +from inspect import isclass, getdoc +from collections import OrderedDict, Hashable + +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 + +from urllib.parse 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, str): + 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(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 + 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, str): + 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 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 + # 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 self.api.error_handlers.items(): + 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 doc[method].get("vendor", {}).items() + ) + + 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 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)} + + 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, str): + 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 d["responses"].items(): + code = str(code) + if isinstance(response, str): + 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 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: + 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( + 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() + ) + + 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, str): + 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 specs.values(): + self.register_field(field) + return ref(model) + + def register_field(self, field): + if isinstance(field, fields.Polymorph): + for model in field.mapping.values(): + 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, (str)): + return {value: []} + elif isinstance(value, dict): + return dict( + (k, v if isinstance(v, (list, tuple)) else [v]) + for k, v in value.items() + ) + 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..450cbaa9 100644 --- a/flask_restx/api.py +++ b/flask_restx/api.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import difflib import inspect from itertools import chain import logging import operator import re -import six import sys import warnings @@ -512,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) @@ -589,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 @@ -707,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/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..97957f7f 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 @@ -10,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 @@ -59,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): @@ -427,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): @@ -440,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) @@ -496,7 +490,7 @@ class Arbitrary(NumberMixin, Raw): """ def format(self, value): - return text_type(Decimal(value)) + return str(Decimal(value)) ZERO = Decimal() @@ -515,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): @@ -552,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" @@ -624,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() @@ -685,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: @@ -735,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): @@ -752,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: @@ -825,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 664388e2..912ae164 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,14 +15,13 @@ 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 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 8e899f7f..6648f66f 100644 --- a/flask_restx/marshalling.py +++ b/flask_restx/marshalling.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from collections import OrderedDict from functools import wraps -from six import iteritems from flask import request, current_app, has_app_context @@ -178,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 1784d4ec..f2204459 100644 --- a/flask_restx/mask.py +++ b/flask_restx/mask.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - import logging import re -import six from collections import OrderedDict from inspect import isclass @@ -37,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)): @@ -145,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): @@ -162,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 @@ -172,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 a273f1a8..dea43c6a 100644 --- a/flask_restx/model.py +++ b/flask_restx/model.py @@ -1,18 +1,10 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import copy import re import warnings 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 @@ -154,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: @@ -188,9 +180,7 @@ def resolved(self): resolved.update(parent.resolved) # Handle discriminator - candidates = [ - f for f in itervalues(resolved) 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") @@ -243,7 +233,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 48067776..86b4b337 100644 --- a/flask_restx/namespace.py +++ b/flask_restx/namespace.py @@ -1,12 +1,8 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import inspect import warnings import logging from collections import namedtuple, OrderedDict -import six from flask import request from flask.views import http_method_funcs @@ -132,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 @@ -357,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/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..de5b9bd5 100644 --- a/flask_restx/reqparse.py +++ b/flask_restx/reqparse.py @@ -1,8 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import decimal -import six try: from collections.abc import Hashable @@ -66,8 +62,6 @@ def __setattr__(self, name, value): SPLIT_CHAR = "," -text_type = lambda x: six.text_type(x) # noqa - class Argument(object): """ @@ -81,7 +75,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. @@ -105,7 +99,7 @@ def __init__( dest=None, required=False, ignore=False, - type=text_type, + type=str, location=( "json", "values", @@ -140,7 +134,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: @@ -200,10 +194,8 @@ 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_msg = ( - " ".join([six.text_type(self.help), error_str]) if self.help else error_str - ) + error_str = str(error) + error_msg = " ".join([str(self.help), error_str]) if self.help else error_str errors = {self.name: error_msg} if bundle_errors: @@ -271,7 +263,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/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/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/flask_restx/utils.py b/flask_restx/utils.py index 5ba79f7b..809a29b3 100644 --- a/flask_restx/utils.py +++ b/flask_restx/utils.py @@ -1,11 +1,7 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import re from collections import OrderedDict from copy import deepcopy -from six import iteritems from ._http import HTTPStatus @@ -39,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: @@ -72,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): @@ -83,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/requirements/test.pip b/requirements/test.pip index 6945712b..c38a570f 100644 --- a/requirements/test.pip +++ b/requirements/test.pip @@ -1,15 +1,12 @@ 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.0.1 +pytest-benchmark==3.4.1 +pytest-cov==4.0.0 +pytest-flask==1.2.0 +pytest-mock==3.6.1 pytest-profiling==1.7.0 tzlocal invoke==1.3.0 -readme-renderer==24.0 -twine==1.15.0 -ossaudit; python_version >= '3.5' +twine==3.8.0 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/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/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/legacy/test_api_legacy.py b/tests/legacy/test_api_legacy.py index 5d6649c8..a27a454a 100644 --- a/tests/legacy/test_api_legacy.py +++ b/tests/legacy/test_api_legacy.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import flask import pytest -import six from json import dumps, JSONEncoder @@ -255,7 +251,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 = { @@ -379,7 +375,8 @@ def get(self): resp = client.get("/api") assert resp.status_code == 302 - assert resp.headers["Location"] == "http://localhost/" + # 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() 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 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..8b449887 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from collections import OrderedDict from datetime import date, datetime from decimal import Decimal 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..c6539b8c 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -1,12 +1,8 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import re import pytz import pytest from datetime import date, datetime -from six import text_type from flask_restx import inputs @@ -149,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", @@ -200,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", @@ -458,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", @@ -472,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_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): 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..0317bf64 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 @@ -10,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 3ac89a7a..2b319ed9 100644 --- a/tests/test_reqparse.py +++ b/tests/test_reqparse.py @@ -1,9 +1,6 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import decimal import json -import six +import io import pytest from werkzeug.exceptions import BadRequest @@ -513,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() @@ -534,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() @@ -808,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") 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 diff --git a/tox.ini b/tox.ini index 93cc40f9..5da0c5a8 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{37, 38, 39, 310}, pypy3, doc [testenv] commands = {posargs:inv test qa}