diff --git a/mimeparse/__init__.py b/mimeparse/__init__.py index d4c3683..0fb7f67 100644 --- a/mimeparse/__init__.py +++ b/mimeparse/__init__.py @@ -175,7 +175,7 @@ def quality(mime_type: str, ranges: str) -> float: text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5') 0.7 """ - parsed_ranges = [parse_media_range(r) for r in ranges.split(',')] + parsed_ranges = [parse_media_range(r) for r in _split_media_ranges(ranges)] return quality_parsed(mime_type, parsed_ranges) @@ -194,7 +194,7 @@ def best_match(supported: Iterable[str], header: str) -> str: 'text/*;q=0.5,*/*; q=0.1') 'text/xml' """ - split_header = _filter_blank(header.split(',')) + split_header = _filter_blank(_split_media_ranges(header)) parsed_header = [parse_media_range(r) for r in split_header] weighted_matches = [] pos = 0 @@ -210,6 +210,28 @@ def best_match(supported: Iterable[str], header: str) -> str: return weighted_matches[-1][0][0] and weighted_matches[-1][2] or '' +def _split_media_ranges(header: str) -> Generator[str, None, None]: + """Split an Accept list without splitting quoted parameter values.""" + if '"' not in header: + yield from header.split(',') + return + + start = 0 + quoted = False + escaped = False + for index, char in enumerate(header): + if escaped: + escaped = False + elif quoted and char == '\\': + escaped = True + elif char == '"': + quoted = not quoted + elif char == ',' and not quoted: + yield header[start:index] + start = index + 1 + yield header[start:] + + def _filter_blank(i: Iterable[str]) -> Generator[str, None, None]: """Return all non-empty items in the list.""" for s in i: diff --git a/mimeparse_test.py b/mimeparse_test.py index 5e413e3..3c9bc31 100755 --- a/mimeparse_test.py +++ b/mimeparse_test.py @@ -68,6 +68,30 @@ def test_best_match(self): for args, expected, description in self.test_data['best_match']: self._test_best_match(args, expected, description) + def test_quoted_commas(self): + values = ( + r'"a,b"', + r'"a\",b"', + r'"a\\,b"', + r'"a,b\\"', + r'"a;b,c"', + r'"a,\"b,c\""', + ) + for value in values: + with self.subTest(value=value): + media_type = f'text/plain;note={value}' + header = f'{media_type},application/json;q=0.2' + self.assertEqual(mimeparse.quality(media_type, header), 1) + self.assertEqual( + mimeparse.best_match([media_type, 'application/json'], header), + media_type, + ) + + def test_quoted_commas_with_blank_ranges(self): + media_type = 'text/plain;note="a,b"' + header = f', {media_type}, , ' + self.assertEqual(mimeparse.best_match([media_type], header), media_type) + def test_parse_mime_type(self): for args, expected in self.test_data['parse_mime_type']: self._test_parse_mime_type(args, expected) diff --git a/testdata.json b/testdata.json index 2ceee39..ff0b4f9 100644 --- a/testdata.json +++ b/testdata.json @@ -14,6 +14,20 @@ ], "quality": [ + [ + [ + "text/plain;profile=\"a,b\"", + "text/plain;profile=\"a,b\";q=0.8, application/json;q=0.2" + ], + 0.8 + ], + [ + [ + "application/json", + "text/plain;profile=\"a,b\";q=0.8, application/json;q=0.2" + ], + 0.2 + ], [ [ "text/html;level=1", @@ -66,6 +80,22 @@ ], "best_match": [ + [ + [ + ["text/plain;profile=\"a,b\"", "application/json"], + "text/plain;profile=\"a,b\";q=0.8, application/json;q=0.2" + ], + "text/plain;profile=\"a,b\"", + "preserve a comma within a quoted parameter value" + ], + [ + [ + ["text/plain;profile=\"a,b\"", "application/json"], + "text/plain;profile=\"a,b\";q=0.2, application/json;q=0.8" + ], + "application/json", + "compare quality factors after a quoted parameter containing a comma" + ], [ [ ["application/json", "text/html"],