From 9ec463b9719260ad324f4a45ecc2e566aca5ad71 Mon Sep 17 00:00:00 2001 From: Kurt Griffiths Date: Mon, 11 Feb 2019 22:43:04 -0700 Subject: [PATCH] chore: Vendor python-mimeparse Closes #1420 --- .coveragerc | 2 +- .travis.yml | 2 + CHANGES.rst | 3 +- README.rst | 13 +- docs/changes/2.0.0.rst | 2 +- docs/index.rst | 2 +- docs/user/install.rst | 9 +- docs/user/intro.rst | 4 +- falcon/media/handlers.py | 3 +- falcon/request.py | 3 +- falcon/vendor/__init__.py | 0 falcon/vendor/mimeparse/LICENSE | 17 +++ falcon/vendor/mimeparse/__init__.py | 10 ++ falcon/vendor/mimeparse/mimeparse.py | 191 +++++++++++++++++++++++++++ setup.py | 3 +- tools/check-vendored.sh | 11 ++ tox.ini | 8 ++ 17 files changed, 257 insertions(+), 26 deletions(-) create mode 100644 falcon/vendor/__init__.py create mode 100755 falcon/vendor/mimeparse/LICENSE create mode 100644 falcon/vendor/mimeparse/__init__.py create mode 100755 falcon/vendor/mimeparse/mimeparse.py create mode 100755 tools/check-vendored.sh diff --git a/.coveragerc b/.coveragerc index f32e65e38..9860ca9e3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] branch = True source = falcon -omit = falcon/tests*,falcon/cmd*,falcon/bench* +omit = falcon/tests*,falcon/cmd*,falcon/bench*,falcon/vendor/* parallel = True diff --git a/.travis.yml b/.travis.yml index 48b8aa090..063588aab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -43,6 +43,8 @@ matrix: env: TOXENV=docs - python: 3.6 env: TOXENV=hug + - python: 3.6 + env: TOXENV=check_vendored script: tox diff --git a/CHANGES.rst b/CHANGES.rst index b5572f2f9..16cd6eec3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -32,7 +32,7 @@ New & Improved -------------- - Added a new ``headers`` property to the ``Response`` class. -- Removed ``six`` as a dependency. +- Removed the ``six`` and ``python-mimeparse`` dependencies. - ``Request.context_type`` now defaults to a bare class allowing to set attributes on the request context object:: @@ -67,6 +67,7 @@ New & Improved ``dumps()`` and ``loads()`` functions. This enables support not only for using any of a number of third-party JSON libraries, but also for customizing the keyword arguments used when (de)serializing objects. +- Fixed ----- diff --git a/README.rst b/README.rst index 0ce4a6f1c..2bea78431 100644 --- a/README.rst +++ b/README.rst @@ -113,8 +113,8 @@ breaking changes, and when we do they are fully documented and only introduced (in the spirit of `SemVer `__) with a major version increment. The code is rigorously tested with numerous inputs and we -require 100% coverage at all times. Mimeparse is the only -third-party dependency. +require 100% coverage at all times. Falcon does not depend on any +external Python packages. **Flexible.** Falcon leaves a lot of decisions and implementation details to you, the API developer. This gives you a lot of freedom to @@ -277,12 +277,9 @@ these issues by setting additional Clang C compiler flags as follows: Dependencies ^^^^^^^^^^^^ -Falcon depends on `python-mimeparse`. `python-mimeparse` is a -better-maintained fork of the similarly named `mimeparse` project. -Normally the correct package will be selected by Falcon's ``setup.py``. -However, if you are using an alternate strategy to manage dependencies, -please take care to install the correct package in order to avoid -errors. +Falcon does not require the installation of any other packages, although if +Cython has been installed into the environment, it will be used to optimize +the framework as explained above. WSGI Server ----------- diff --git a/docs/changes/2.0.0.rst b/docs/changes/2.0.0.rst index e6d43fd1f..2f3a44539 100644 --- a/docs/changes/2.0.0.rst +++ b/docs/changes/2.0.0.rst @@ -34,7 +34,7 @@ New & Improved -------------- - Added a new :attr:`~.Response.headers` property to the :class:`~.Response` class. -- Removed :py:mod:`six` from deps. +- Removed the :py:mod:`six` and :py:mod:`python-mimeparse` dependencies. - Request :attr:`~.Request.context_type` now defaults to a bare class allowing to set attributes on the request context object:: diff --git a/docs/index.rst b/docs/index.rst index d412f6c7d..63bb7a356 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -73,7 +73,7 @@ Falcon tries to do as little as possible while remaining highly effective. - Works great with async libraries like gevent - Minimal attack surface for writing secure APIs - 100% code coverage with a comprehensive test suite -- Only depends on mimeparse +- No dependencies on other Python packages - Supports Python 2.7, 3.4+ - Compatible with PyPy diff --git a/docs/user/install.rst b/docs/user/install.rst index 7bb23cd15..87770f27c 100644 --- a/docs/user/install.rst +++ b/docs/user/install.rst @@ -90,12 +90,9 @@ these issues by setting additional Clang C compiler flags as follows: Dependencies ------------ -Falcon depends on `python-mimeparse`. `python-mimeparse` is a -better-maintained fork of the similarly named `mimeparse` project. -Normally the correct package will be selected by Falcon's ``setup.py``. -However, if you are using an alternate strategy to manage dependencies, -please take care to install the correct package in order to avoid -errors. +Falcon does not require the installation of any other packages, although if +Cython has been installed into the environment, it will be used to optimize +the framework as explained above. WSGI Server ----------- diff --git a/docs/user/intro.rst b/docs/user/intro.rst index 0e372035b..e8f47222b 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -37,8 +37,8 @@ breaking changes, and when we do they are fully documented and only introduced (in the spirit of `SemVer `__) with a major version increment. The code is rigorously tested with numerous inputs and we -require 100% coverage at all times. python-mimeparse is the only -third-party dependency. +require 100% coverage at all times. Falcon does not depend on any +external Python packages. **Flexible.** Falcon leaves a lot of decisions and implementation details to you, the API developer. This gives you a lot of freedom to diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index 0f30f64f4..2aaa06f20 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -1,8 +1,7 @@ -import mimeparse - from falcon import errors from falcon.media import JSONHandler from falcon.util.compat import UserDict +from falcon.vendor import mimeparse class Handlers(UserDict): diff --git a/falcon/request.py b/falcon/request.py index a21406c18..a4ed8d77b 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -29,8 +29,6 @@ from uuid import UUID # NOQA: I202 from wsgiref.validate import InputWrapper -import mimeparse - from falcon import DEFAULT_MEDIA_TYPE from falcon import errors from falcon import request_helpers as helpers @@ -41,6 +39,7 @@ from falcon.util import compat from falcon.util import json from falcon.util.uri import parse_host, parse_query_string +from falcon.vendor import mimeparse # NOTE(tbug): In some cases, compat.http_cookies is not a module # but a dict-like structure. This fixes that issue. diff --git a/falcon/vendor/__init__.py b/falcon/vendor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/falcon/vendor/mimeparse/LICENSE b/falcon/vendor/mimeparse/LICENSE new file mode 100755 index 000000000..89de35479 --- /dev/null +++ b/falcon/vendor/mimeparse/LICENSE @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/falcon/vendor/mimeparse/__init__.py b/falcon/vendor/mimeparse/__init__.py new file mode 100644 index 000000000..95d45056b --- /dev/null +++ b/falcon/vendor/mimeparse/__init__.py @@ -0,0 +1,10 @@ +""" +This module wraps code from the MIT-licensed python-mimeparse project. The +original project source code may be obtained from GitHub by visiting the +following URL: + + https://github.com/dbtsai/python-mimeparse + +""" + +from .mimeparse import * # NOQA \ No newline at end of file diff --git a/falcon/vendor/mimeparse/mimeparse.py b/falcon/vendor/mimeparse/mimeparse.py new file mode 100755 index 000000000..0218553cf --- /dev/null +++ b/falcon/vendor/mimeparse/mimeparse.py @@ -0,0 +1,191 @@ +import cgi + +__version__ = '1.6.0' +__author__ = 'Joe Gregorio' +__email__ = 'joe@bitworking.org' +__license__ = 'MIT License' +__credits__ = '' + + +class MimeTypeParseException(ValueError): + pass + + +def parse_mime_type(mime_type): + """Parses a mime-type into its component parts. + + Carves up a mime-type and returns a tuple of the (type, subtype, params) + where 'params' is a dictionary of all the parameters for the media range. + For example, the media range 'application/xhtml;q=0.5' would get parsed + into: + + ('application', 'xhtml', {'q', '0.5'}) + + :rtype: (str,str,dict) + """ + full_type, params = cgi.parse_header(mime_type) + # Java URLConnection class sends an Accept header that includes a + # single '*'. Turn it into a legal wildcard. + if full_type == '*': + full_type = '*/*' + + type_parts = full_type.split('/') if '/' in full_type else None + if not type_parts or len(type_parts) > 2: + raise MimeTypeParseException( + "Can't parse type \"{}\"".format(full_type)) + + (type, subtype) = type_parts + + return (type.strip(), subtype.strip(), params) + + +def parse_media_range(range): + """Parse a media-range into its component parts. + + Carves up a media range and returns a tuple of the (type, subtype, + params) where 'params' is a dictionary of all the parameters for the media + range. For example, the media range 'application/*;q=0.5' would get parsed + into: + + ('application', '*', {'q', '0.5'}) + + In addition this function also guarantees that there is a value for 'q' + in the params dictionary, filling it in with a proper default if + necessary. + + :rtype: (str,str,dict) + """ + (type, subtype, params) = parse_mime_type(range) + params.setdefault('q', params.pop('Q', None)) # q is case insensitive + try: + if not params['q'] or not 0 <= float(params['q']) <= 1: + params['q'] = '1' + except ValueError: # from float() + params['q'] = '1' + + return (type, subtype, params) + + +def quality_and_fitness_parsed(mime_type, parsed_ranges): + """Find the best match for a mime-type amongst parsed media-ranges. + + Find the best match for a given mime-type against a list of media_ranges + that have already been parsed by parse_media_range(). Returns a tuple of + the fitness value and the value of the 'q' quality parameter of the best + match, or (-1, 0) if no match was found. Just as for quality_parsed(), + 'parsed_ranges' must be a list of parsed media ranges. + + :rtype: (float,int) + """ + best_fitness = -1 + best_fit_q = 0 + (target_type, target_subtype, target_params) = \ + parse_media_range(mime_type) + + for (type, subtype, params) in parsed_ranges: + + # check if the type and the subtype match + type_match = ( + type in (target_type, '*') or + target_type == '*' + ) + subtype_match = ( + subtype in (target_subtype, '*') or + target_subtype == '*' + ) + + # if they do, assess the "fitness" of this mime_type + if type_match and subtype_match: + + # 100 points if the type matches w/o a wildcard + fitness = type == target_type and 100 or 0 + + # 10 points if the subtype matches w/o a wildcard + fitness += subtype == target_subtype and 10 or 0 + + # 1 bonus point for each matching param besides "q" + param_matches = sum([ + 1 for (key, value) in target_params.items() + if key != 'q' and key in params and value == params[key] + ]) + fitness += param_matches + + # finally, add the target's "q" param (between 0 and 1) + fitness += float(target_params.get('q', 1)) + + if fitness > best_fitness: + best_fitness = fitness + best_fit_q = params['q'] + + return float(best_fit_q), best_fitness + + +def quality_parsed(mime_type, parsed_ranges): + """Find the best match for a mime-type amongst parsed media-ranges. + + Find the best match for a given mime-type against a list of media_ranges + that have already been parsed by parse_media_range(). Returns the 'q' + quality parameter of the best match, 0 if no match was found. This function + behaves the same as quality() except that 'parsed_ranges' must be a list of + parsed media ranges. + + :rtype: float + """ + + return quality_and_fitness_parsed(mime_type, parsed_ranges)[0] + + +def quality(mime_type, ranges): + """Return the quality ('q') of a mime-type against a list of media-ranges. + + Returns the quality 'q' of a mime-type when compared against the + media-ranges in ranges. For example: + + >>> quality('text/html','text/*;q=0.3, text/html;q=0.7, + text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5') + 0.7 + + :rtype: float + """ + parsed_ranges = [parse_media_range(r) for r in ranges.split(',')] + + return quality_parsed(mime_type, parsed_ranges) + + +def best_match(supported, header): + """Return mime-type with the highest quality ('q') from list of candidates. + + Takes a list of supported mime-types and finds the best match for all the + media-ranges listed in header. The value of header must be a string that + conforms to the format of the HTTP Accept: header. The value of 'supported' + is a list of mime-types. The list of supported mime-types should be sorted + in order of increasing desirability, in case of a situation where there is + a tie. + + >>> best_match(['application/xbel+xml', 'text/xml'], + 'text/*;q=0.5,*/*; q=0.1') + 'text/xml' + + :rtype: str + """ + split_header = _filter_blank(header.split(',')) + parsed_header = [parse_media_range(r) for r in split_header] + weighted_matches = [] + pos = 0 + for mime_type in supported: + weighted_matches.append(( + quality_and_fitness_parsed(mime_type, parsed_header), + pos, + mime_type + )) + pos += 1 + weighted_matches.sort() + + return weighted_matches[-1][0][0] and weighted_matches[-1][2] or '' + + +def _filter_blank(i): + """Return all non-empty items in the list.""" + for s in i: + if s.strip(): + yield s diff --git a/setup.py b/setup.py index b39155f2c..50853afd6 100644 --- a/setup.py +++ b/setup.py @@ -13,8 +13,7 @@ VERSION = imp.load_source('version', path.join('.', 'falcon', 'version.py')) VERSION = VERSION.__version__ -# NOTE(kgriffs): python-mimeparse is better-maintained fork of mimeparse -REQUIRES = ['python-mimeparse>=1.5.2'] +REQUIRES = [] try: sys.pypy_version_info diff --git a/tools/check-vendored.sh b/tools/check-vendored.sh new file mode 100755 index 000000000..0821ad8aa --- /dev/null +++ b/tools/check-vendored.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +_VERSION_OUTPUT=$(pip install python-mimeparse== 2>&1) + +if [[ $_VERSION_OUTPUT == *", 1.6.0)"* ]]; then + echo "Latest version of python-mimeparse has not changed (1.6.0)" + exit 0 +fi + +echo "Latest version of python-mimeparse is newer than expected." +exit 1 \ No newline at end of file diff --git a/tox.ini b/tox.ini index 8e71f7086..4b8b852cf 100644 --- a/tox.ini +++ b/tox.ini @@ -300,6 +300,14 @@ basepython = pypy3 deps = -r{toxinidir}/requirements/bench commands = falcon-bench [] +# -------------------------------------------------------------------- +# Check for new versions of vendored packages +# -------------------------------------------------------------------- + +[testenv:check_vendored] +basepython = python3.6 +commands = {toxinidir}/tools/check-vendored.sh + # -------------------------------------------------------------------- # Documentation # --------------------------------------------------------------------