From fda65a25b30f087b39fd56832fb658284547b12d Mon Sep 17 00:00:00 2001 From: cdeler Date: Fri, 6 Nov 2020 21:19:32 +0300 Subject: [PATCH 01/15] Added ability to use LF, not only CRLF delimiter --- h11/_readers.py | 2 +- h11/_receivebuffer.py | 55 ++++++++++++++++++++++++++------- h11/tests/test_receivebuffer.py | 39 +++++++++++++++++++---- 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/h11/_readers.py b/h11/_readers.py index cc86bff..5aa2d20 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -153,7 +153,7 @@ def __call__(self, buf): assert self._bytes_to_discard == 0 if self._bytes_in_chunk == 0: # We need to refill our chunk count - chunk_header = buf.maybe_extract_until_next(b"\r\n") + chunk_header = buf.maybe_extract_until_delimiter(b"\r?\n") if chunk_header is None: return None matches = validate( diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index c56749a..a20bca7 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -1,3 +1,4 @@ +import re import sys __all__ = ["ReceiveBuffer"] @@ -38,6 +39,12 @@ # slightly clever thing where we delay calling compress() until we've # processed a whole event, which could in theory be slightly more efficient # than the internal bytearray support.) + +default_delimiter = b"\n\r?\n" +delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) +line_delimiter_regex = re.compile(b"\r?\n", re.MULTILINE) + + class ReceiveBuffer(object): def __init__(self): self._data = bytearray() @@ -46,6 +53,9 @@ def __init__(self): self._looked_at = 0 self._looked_for = b"" + self._delimiter = b"\n\r?\n" + self._delimiter_regex = delimiter_regex + def __bool__(self): return bool(len(self)) @@ -79,21 +89,34 @@ def maybe_extract_at_most(self, count): self._start += len(out) return out - def maybe_extract_until_next(self, needle): + def maybe_extract_until_delimiter(self, delimiter=b"\n\r?\n"): # Returns extracted bytes on success (advancing offset), or None on # failure - if self._looked_for == needle: - search_start = max(self._start, self._looked_at - len(needle) + 1) + if delimiter == self._delimiter: + looked_at = max(self._start, self._looked_at - len(delimiter) + 1) else: - search_start = self._start - offset = self._data.find(needle, search_start) - if offset == -1: + looked_at = self._start + self._delimiter = delimiter + # re.compile operation is more expensive than just byte compare + if delimiter == default_delimiter: + self._delimiter_regex = delimiter_regex + else: + self._delimiter_regex = re.compile(delimiter, re.MULTILINE) + + delimiter_match = next( + self._delimiter_regex.finditer(self._data, looked_at), None + ) + + if delimiter_match is None: self._looked_at = len(self._data) - self._looked_for = needle return None - new_start = offset + len(needle) - out = self._data[self._start : new_start] - self._start = new_start + + _, end = delimiter_match.span(0) + + out = self._data[self._start : end] + + self._start = end + return out # HTTP/1.1 has a number of constructs where you keep reading lines until @@ -102,11 +125,19 @@ def maybe_extract_lines(self): if self._data[self._start : self._start + 2] == b"\r\n": self._start += 2 return [] + elif self._start < len(self._data) and self._data[self._start] == b"\n": + self._start += 1 + return [] else: - data = self.maybe_extract_until_next(b"\r\n\r\n") + data = self.maybe_extract_until_delimiter(b"\n\r?\n") + if data is None: return None - lines = data.split(b"\r\n") + + lines = line_delimiter_regex.split(data) + assert lines[-2] == lines[-1] == b"" + del lines[-2:] + return lines diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index 2be220b..6ccb235 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -1,3 +1,5 @@ +import pytest + from .._receivebuffer import ReceiveBuffer @@ -30,28 +32,28 @@ def test_receivebuffer(): assert not b ################################################################ - # maybe_extract_until_next + # maybe_extract_until_delimiter ################################################################ b += b"12345a6789aa" - assert b.maybe_extract_until_next(b"a") == b"12345a" + assert b.maybe_extract_until_delimiter(b"a") == b"12345a" assert bytes(b) == b"6789aa" - assert b.maybe_extract_until_next(b"aaa") is None + assert b.maybe_extract_until_delimiter(b"aaa") is None assert bytes(b) == b"6789aa" b += b"a12" - assert b.maybe_extract_until_next(b"aaa") == b"6789aaa" + assert b.maybe_extract_until_delimiter(b"aaa") == b"6789aaa" assert bytes(b) == b"12" # check repeated searches for the same needle, triggering the # pickup-where-we-left-off logic b += b"345" - assert b.maybe_extract_until_next(b"aaa") is None + assert b.maybe_extract_until_delimiter(b"aaa") is None b += b"6789aaa123" - assert b.maybe_extract_until_next(b"aaa") == b"123456789aaa" + assert b.maybe_extract_until_delimiter(b"aaa") == b"123456789aaa" assert bytes(b) == b"123" ################################################################ @@ -76,3 +78,28 @@ def test_receivebuffer(): b += b"\r\ntrailing" assert b.maybe_extract_lines() == [] assert bytes(b) == b"trailing" + + +@pytest.mark.parametrize( + "data", + [ + ( + b"HTTP/1.1 200 OK\r\n", + b"Content-type: text/plain\r\n", + b"\r\n", + b"Some body", + ), + (b"HTTP/1.1 200 OK\n", b"Content-type: text/plain\n", b"\n", b"Some body"), + (b"HTTP/1.1 200 OK\r\n", b"Content-type: text/plain\n", b"\n", b"Some body"), + ], +) +def test_receivebuffer_for_invalid_delimiter(data): + b = ReceiveBuffer() + + for line in data: + b += line + + lines = b.maybe_extract_lines() + + assert lines == [b"HTTP/1.1 200 OK", b"Content-type: text/plain"] + assert bytes(b) == b"Some body" From d3b2ef2a022cd541aa5ae8e9d541429709974e6c Mon Sep 17 00:00:00 2001 From: cdeler Date: Sat, 7 Nov 2020 21:17:19 +0300 Subject: [PATCH 02/15] Fixed some performance issues --- h11/_readers.py | 2 +- h11/_receivebuffer.py | 51 +++++++++++++++++---------------- h11/tests/test_receivebuffer.py | 12 ++++---- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/h11/_readers.py b/h11/_readers.py index 5aa2d20..68a63fd 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -153,7 +153,7 @@ def __call__(self, buf): assert self._bytes_to_discard == 0 if self._bytes_in_chunk == 0: # We need to refill our chunk count - chunk_header = buf.maybe_extract_until_delimiter(b"\r?\n") + chunk_header = buf.maybe_extract_until_next(b"\r?\n") if chunk_header is None: return None matches = validate( diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index a20bca7..76eaaff 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -51,10 +51,8 @@ def __init__(self): # These are both absolute offsets into self._data: self._start = 0 self._looked_at = 0 - self._looked_for = b"" - - self._delimiter = b"\n\r?\n" - self._delimiter_regex = delimiter_regex + self._looked_for = default_delimiter + self._looked_for_regex = delimiter_regex def __bool__(self): return bool(len(self)) @@ -89,22 +87,22 @@ def maybe_extract_at_most(self, count): self._start += len(out) return out - def maybe_extract_until_delimiter(self, delimiter=b"\n\r?\n"): + def maybe_extract_until_next(self, needle): # Returns extracted bytes on success (advancing offset), or None on # failure - if delimiter == self._delimiter: - looked_at = max(self._start, self._looked_at - len(delimiter) + 1) + if self._looked_for == needle: + looked_at = max(self._start, self._looked_at - len(needle) + 1) else: looked_at = self._start - self._delimiter = delimiter + self._looked_for = needle # re.compile operation is more expensive than just byte compare - if delimiter == default_delimiter: - self._delimiter_regex = delimiter_regex + if needle == default_delimiter: + self._looked_for_regex = delimiter_regex else: - self._delimiter_regex = re.compile(delimiter, re.MULTILINE) + self._looked_for_regex = re.compile(needle, re.MULTILINE) delimiter_match = next( - self._delimiter_regex.finditer(self._data, looked_at), None + self._looked_for_regex.finditer(self._data, looked_at), None ) if delimiter_match is None: @@ -119,25 +117,30 @@ def maybe_extract_until_delimiter(self, delimiter=b"\n\r?\n"): return out + def _get_fields_delimiter(self, data, lines_delimiter_regex): + delimiter_match = next(lines_delimiter_regex.finditer(data), None) + + if delimiter_match is not None: + begin, end = delimiter_match.span(0) + result = data[begin:end] + else: + result = b"\r\n" + + return bytes(result) + # HTTP/1.1 has a number of constructs where you keep reading lines until # you see a blank one. This does that, and then returns the lines. def maybe_extract_lines(self): - if self._data[self._start : self._start + 2] == b"\r\n": - self._start += 2 - return [] - elif self._start < len(self._data) and self._data[self._start] == b"\n": - self._start += 1 + start_chunk = self._data[self._start : self._start + 2] + if start_chunk in [b"\r\n", b"\n"]: + self._start += len(start_chunk) return [] else: - data = self.maybe_extract_until_delimiter(b"\n\r?\n") - + data = self.maybe_extract_until_next(default_delimiter) if data is None: return None - lines = line_delimiter_regex.split(data) - - assert lines[-2] == lines[-1] == b"" - - del lines[-2:] + delimiter = self._get_fields_delimiter(data, line_delimiter_regex) + lines = data.rstrip(b"\r\n").split(delimiter) return lines diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index 6ccb235..761baaa 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -32,28 +32,28 @@ def test_receivebuffer(): assert not b ################################################################ - # maybe_extract_until_delimiter + # maybe_extract_until_next ################################################################ b += b"12345a6789aa" - assert b.maybe_extract_until_delimiter(b"a") == b"12345a" + assert b.maybe_extract_until_next(b"a") == b"12345a" assert bytes(b) == b"6789aa" - assert b.maybe_extract_until_delimiter(b"aaa") is None + assert b.maybe_extract_until_next(b"aaa") is None assert bytes(b) == b"6789aa" b += b"a12" - assert b.maybe_extract_until_delimiter(b"aaa") == b"6789aaa" + assert b.maybe_extract_until_next(b"aaa") == b"6789aaa" assert bytes(b) == b"12" # check repeated searches for the same needle, triggering the # pickup-where-we-left-off logic b += b"345" - assert b.maybe_extract_until_delimiter(b"aaa") is None + assert b.maybe_extract_until_next(b"aaa") is None b += b"6789aaa123" - assert b.maybe_extract_until_delimiter(b"aaa") == b"123456789aaa" + assert b.maybe_extract_until_next(b"aaa") == b"123456789aaa" assert bytes(b) == b"123" ################################################################ From b2f70e954112464f650265606de1ece2826fc309 Mon Sep 17 00:00:00 2001 From: cdeler Date: Thu, 19 Nov 2020 11:47:12 +0300 Subject: [PATCH 03/15] Fixing PR remarks - reword comment --- h11/_receivebuffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 76eaaff..53e9347 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -95,7 +95,7 @@ def maybe_extract_until_next(self, needle): else: looked_at = self._start self._looked_for = needle - # re.compile operation is more expensive than just byte compare + # Check if default delimiter to avoid expensive re.compile if needle == default_delimiter: self._looked_for_regex = delimiter_regex else: From ce26dc7d34c6ddb76eb9190b542be5e41d319ce1 Mon Sep 17 00:00:00 2001 From: cdeler Date: Thu, 19 Nov 2020 12:34:57 +0300 Subject: [PATCH 04/15] Fixed PR remark - reworked maybe_extract_until_next --- h11/_readers.py | 3 ++- h11/_receivebuffer.py | 25 +++++++++---------------- h11/tests/test_receivebuffer.py | 12 +++++++----- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/h11/_readers.py b/h11/_readers.py index 68a63fd..25697a1 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -21,6 +21,7 @@ from ._abnf import chunk_header, header_field, request_line, status_line from ._events import * from ._state import * +from ._receivebuffer import line_delimiter_regex from ._util import LocalProtocolError, RemoteProtocolError, validate __all__ = ["READERS"] @@ -153,7 +154,7 @@ def __call__(self, buf): assert self._bytes_to_discard == 0 if self._bytes_in_chunk == 0: # We need to refill our chunk count - chunk_header = buf.maybe_extract_until_next(b"\r?\n") + chunk_header = buf.maybe_extract_until_next(line_delimiter_regex, 2) if chunk_header is None: return None matches = validate( diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 53e9347..3d15d8d 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -40,8 +40,7 @@ # processed a whole event, which could in theory be slightly more efficient # than the internal bytearray support.) -default_delimiter = b"\n\r?\n" -delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) +body_and_headers_delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) line_delimiter_regex = re.compile(b"\r?\n", re.MULTILINE) @@ -51,8 +50,7 @@ def __init__(self): # These are both absolute offsets into self._data: self._start = 0 self._looked_at = 0 - self._looked_for = default_delimiter - self._looked_for_regex = delimiter_regex + self._looked_for_regex = body_and_headers_delimiter_regex def __bool__(self): return bool(len(self)) @@ -87,19 +85,14 @@ def maybe_extract_at_most(self, count): self._start += len(out) return out - def maybe_extract_until_next(self, needle): + def maybe_extract_until_next(self, needle_regex, max_needle_length): # Returns extracted bytes on success (advancing offset), or None on # failure - if self._looked_for == needle: - looked_at = max(self._start, self._looked_at - len(needle) + 1) + if self._looked_for_regex == needle_regex: + looked_at = max(self._start, self._looked_at - max_needle_length) else: looked_at = self._start - self._looked_for = needle - # Check if default delimiter to avoid expensive re.compile - if needle == default_delimiter: - self._looked_for_regex = delimiter_regex - else: - self._looked_for_regex = re.compile(needle, re.MULTILINE) + self._looked_for_regex = needle_regex delimiter_match = next( self._looked_for_regex.finditer(self._data, looked_at), None @@ -136,11 +129,11 @@ def maybe_extract_lines(self): self._start += len(start_chunk) return [] else: - data = self.maybe_extract_until_next(default_delimiter) + data = self.maybe_extract_until_next(body_and_headers_delimiter_regex, 3) if data is None: return None - delimiter = self._get_fields_delimiter(data, line_delimiter_regex) - lines = data.rstrip(b"\r\n").split(delimiter) + real_lines_delimiter = self._get_fields_delimiter(data, line_delimiter_regex) + lines = data.rstrip(b"\r\n").split(real_lines_delimiter) return lines diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index 761baaa..241d921 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -1,3 +1,5 @@ +import re + import pytest from .._receivebuffer import ReceiveBuffer @@ -37,23 +39,23 @@ def test_receivebuffer(): b += b"12345a6789aa" - assert b.maybe_extract_until_next(b"a") == b"12345a" + assert b.maybe_extract_until_next(re.compile(b"a"), 1) == b"12345a" assert bytes(b) == b"6789aa" - assert b.maybe_extract_until_next(b"aaa") is None + assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) is None assert bytes(b) == b"6789aa" b += b"a12" - assert b.maybe_extract_until_next(b"aaa") == b"6789aaa" + assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) == b"6789aaa" assert bytes(b) == b"12" # check repeated searches for the same needle, triggering the # pickup-where-we-left-off logic b += b"345" - assert b.maybe_extract_until_next(b"aaa") is None + assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) is None b += b"6789aaa123" - assert b.maybe_extract_until_next(b"aaa") == b"123456789aaa" + assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) == b"123456789aaa" assert bytes(b) == b"123" ################################################################ From af7b48129b0a5c9592716f4e44369b9385b3e98e Mon Sep 17 00:00:00 2001 From: cdeler Date: Thu, 19 Nov 2020 21:40:37 +0300 Subject: [PATCH 05/15] Small rfg (renamed body_and_headers_delimiter_regex -> blank_line_delimiter_regex) and slightly updated docs --- h11/_receivebuffer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 3d15d8d..cc5ad66 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -5,7 +5,8 @@ # Operations we want to support: -# - find next \r\n or \r\n\r\n, or wait until there is one +# - find next \r\n or \r\n\r\n (\n or \n\n are also acceptable), +# or wait until there is one # - read at-most-N bytes # Goals: # - on average, do this fast @@ -40,7 +41,7 @@ # processed a whole event, which could in theory be slightly more efficient # than the internal bytearray support.) -body_and_headers_delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) +blank_line_delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) line_delimiter_regex = re.compile(b"\r?\n", re.MULTILINE) @@ -50,7 +51,8 @@ def __init__(self): # These are both absolute offsets into self._data: self._start = 0 self._looked_at = 0 - self._looked_for_regex = body_and_headers_delimiter_regex + + self._looked_for_regex = blank_line_delimiter_regex def __bool__(self): return bool(len(self)) @@ -129,7 +131,7 @@ def maybe_extract_lines(self): self._start += len(start_chunk) return [] else: - data = self.maybe_extract_until_next(body_and_headers_delimiter_regex, 3) + data = self.maybe_extract_until_next(blank_line_delimiter_regex, 3) if data is None: return None From 341f632a7d5345c548671dc30f6cee6894ebd9b3 Mon Sep 17 00:00:00 2001 From: cdeler Date: Thu, 19 Nov 2020 21:49:10 +0300 Subject: [PATCH 06/15] Fix CI remarks (run isort and black) --- h11/_readers.py | 2 +- h11/_receivebuffer.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/h11/_readers.py b/h11/_readers.py index 25697a1..3c0d453 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -20,8 +20,8 @@ from ._abnf import chunk_header, header_field, request_line, status_line from ._events import * -from ._state import * from ._receivebuffer import line_delimiter_regex +from ._state import * from ._util import LocalProtocolError, RemoteProtocolError, validate __all__ = ["READERS"] diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index cc5ad66..0c0ba42 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -135,7 +135,9 @@ def maybe_extract_lines(self): if data is None: return None - real_lines_delimiter = self._get_fields_delimiter(data, line_delimiter_regex) + real_lines_delimiter = self._get_fields_delimiter( + data, line_delimiter_regex + ) lines = data.rstrip(b"\r\n").split(real_lines_delimiter) return lines From 74693fc6b67bff9c7c17c421b1abc44f7c92aa17 Mon Sep 17 00:00:00 2001 From: cdeler Date: Fri, 20 Nov 2020 11:49:38 +0300 Subject: [PATCH 07/15] Fixed PR remarks - changed blank_line_delimiter_regex - changed maybe_extract_lines start processing --- h11/_receivebuffer.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 0c0ba42..3a75c39 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -41,7 +41,7 @@ # processed a whole event, which could in theory be slightly more efficient # than the internal bytearray support.) -blank_line_delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) +blank_line_delimiter_regex = re.compile(b"(\r\n\r\n|\n\n)", re.MULTILINE) line_delimiter_regex = re.compile(b"\r?\n", re.MULTILINE) @@ -126,12 +126,14 @@ def _get_fields_delimiter(self, data, lines_delimiter_regex): # HTTP/1.1 has a number of constructs where you keep reading lines until # you see a blank one. This does that, and then returns the lines. def maybe_extract_lines(self): - start_chunk = self._data[self._start : self._start + 2] - if start_chunk in [b"\r\n", b"\n"]: - self._start += len(start_chunk) + if self._data[self._start : self._start + 2] == b"\r\n": + self._start += 2 + return [] + elif self._data[self._start : self._start + 1] == b"\n": + self._start += 1 return [] else: - data = self.maybe_extract_until_next(blank_line_delimiter_regex, 3) + data = self.maybe_extract_until_next(blank_line_delimiter_regex, 4) if data is None: return None From 489de4d932590b1ec3033ab3524eccd3cfd98376 Mon Sep 17 00:00:00 2001 From: cdeler Date: Fri, 20 Nov 2020 14:03:25 +0300 Subject: [PATCH 08/15] Small rfg for tests - added pytest param names to test_receivebuffer_for_invalid_delimiter --- h11/tests/test_receivebuffer.py | 37 ++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index 241d921..ac5f820 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -85,14 +85,37 @@ def test_receivebuffer(): @pytest.mark.parametrize( "data", [ - ( - b"HTTP/1.1 200 OK\r\n", - b"Content-type: text/plain\r\n", - b"\r\n", - b"Some body", + pytest.param( + ( + b"HTTP/1.1 200 OK\r\n", + b"Content-type: text/plain\r\n", + b"\r\n", + b"Some body", + ), + id="with_crlf_delimiter", + ), + pytest.param( + (b"HTTP/1.1 200 OK\n", b"Content-type: text/plain\n", b"\n", b"Some body"), + id="with_lf_only_delimiter", + ), + pytest.param( + ( + b"HTTP/1.1 200 OK\r\n", + b"Content-type: text/plain\n", + b"\n", + b"Some body", + ), + id="with_double_lf_before_body", + ), + pytest.param( + ( + b"HTTP/1.1 200 OK\r\n", + b"Content-type: text/plain\r\n", + b"\n", + b"Some body", + ), + id="with_mixed_crlf", ), - (b"HTTP/1.1 200 OK\n", b"Content-type: text/plain\n", b"\n", b"Some body"), - (b"HTTP/1.1 200 OK\r\n", b"Content-type: text/plain\n", b"\n", b"Some body"), ], ) def test_receivebuffer_for_invalid_delimiter(data): From ef27d8936c0455d7563d139c74ccb080cbc4c2b2 Mon Sep 17 00:00:00 2001 From: cdeler Date: Fri, 20 Nov 2020 20:52:15 +0300 Subject: [PATCH 09/15] Changed the maybe_extract_lines logic according PR review 1. it uses b"\n\r?\n" as a blank line delimiter regex 2. it splits lines using b"\r?\n" regex, so that it's tolerant for mixed line endings 3. for chunked encoding it rewind buffer until b"\r\n" The changes are based on this comment: https://github.com/python-hyper/h11/pull/115#issuecomment-731139500 --- h11/_readers.py | 3 ++- h11/_receivebuffer.py | 20 +++----------------- h11/tests/test_receivebuffer.py | 21 ++++++++++++--------- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/h11/_readers.py b/h11/_readers.py index 3c0d453..602490c 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -31,6 +31,7 @@ # Remember that this has to run in O(n) time -- so e.g. the bytearray cast is # critical. obs_fold_re = re.compile(br"[ \t]+") +strict_line_delimiter_regex = re.compile(b"\r\n", re.MULTILINE) def _obsolete_line_fold(lines): @@ -154,7 +155,7 @@ def __call__(self, buf): assert self._bytes_to_discard == 0 if self._bytes_in_chunk == 0: # We need to refill our chunk count - chunk_header = buf.maybe_extract_until_next(line_delimiter_regex, 2) + chunk_header = buf.maybe_extract_until_next(strict_line_delimiter_regex, 2) if chunk_header is None: return None matches = validate( diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 3a75c39..9177341 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -41,7 +41,7 @@ # processed a whole event, which could in theory be slightly more efficient # than the internal bytearray support.) -blank_line_delimiter_regex = re.compile(b"(\r\n\r\n|\n\n)", re.MULTILINE) +blank_line_delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) line_delimiter_regex = re.compile(b"\r?\n", re.MULTILINE) @@ -112,17 +112,6 @@ def maybe_extract_until_next(self, needle_regex, max_needle_length): return out - def _get_fields_delimiter(self, data, lines_delimiter_regex): - delimiter_match = next(lines_delimiter_regex.finditer(data), None) - - if delimiter_match is not None: - begin, end = delimiter_match.span(0) - result = data[begin:end] - else: - result = b"\r\n" - - return bytes(result) - # HTTP/1.1 has a number of constructs where you keep reading lines until # you see a blank one. This does that, and then returns the lines. def maybe_extract_lines(self): @@ -133,13 +122,10 @@ def maybe_extract_lines(self): self._start += 1 return [] else: - data = self.maybe_extract_until_next(blank_line_delimiter_regex, 4) + data = self.maybe_extract_until_next(blank_line_delimiter_regex, 3) if data is None: return None - real_lines_delimiter = self._get_fields_delimiter( - data, line_delimiter_regex - ) - lines = data.rstrip(b"\r\n").split(real_lines_delimiter) + lines = line_delimiter_regex.split(data.rstrip(b"\r\n")) return lines diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index ac5f820..8b52071 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -89,32 +89,31 @@ def test_receivebuffer(): ( b"HTTP/1.1 200 OK\r\n", b"Content-type: text/plain\r\n", + b"Connection: close\r\n", b"\r\n", b"Some body", ), id="with_crlf_delimiter", ), - pytest.param( - (b"HTTP/1.1 200 OK\n", b"Content-type: text/plain\n", b"\n", b"Some body"), - id="with_lf_only_delimiter", - ), pytest.param( ( - b"HTTP/1.1 200 OK\r\n", + b"HTTP/1.1 200 OK\n", b"Content-type: text/plain\n", + b"Connection: close\n", b"\n", b"Some body", ), - id="with_double_lf_before_body", + id="with_lf_only_delimiter", ), pytest.param( ( - b"HTTP/1.1 200 OK\r\n", + b"HTTP/1.1 200 OK\n", b"Content-type: text/plain\r\n", + b"Connection: close\n", b"\n", b"Some body", ), - id="with_mixed_crlf", + id="with_mixed_crlf_and_lf", ), ], ) @@ -126,5 +125,9 @@ def test_receivebuffer_for_invalid_delimiter(data): lines = b.maybe_extract_lines() - assert lines == [b"HTTP/1.1 200 OK", b"Content-type: text/plain"] + assert lines == [ + b"HTTP/1.1 200 OK", + b"Content-type: text/plain", + b"Connection: close", + ] assert bytes(b) == b"Some body" From a94d7280d4d929d5075e902e7deae926d82559fc Mon Sep 17 00:00:00 2001 From: cdeler Date: Sat, 21 Nov 2020 22:19:37 +0300 Subject: [PATCH 10/15] Speed up maybe_extract_lines and removed unused variables using these test results https://github.com/python-hyper/h11/pull/115#issuecomment-731624909 --- h11/_readers.py | 1 - h11/_receivebuffer.py | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/h11/_readers.py b/h11/_readers.py index 602490c..9449f31 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -20,7 +20,6 @@ from ._abnf import chunk_header, header_field, request_line, status_line from ._events import * -from ._receivebuffer import line_delimiter_regex from ._state import * from ._util import LocalProtocolError, RemoteProtocolError, validate diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 9177341..9d22466 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -42,7 +42,10 @@ # than the internal bytearray support.) blank_line_delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) -line_delimiter_regex = re.compile(b"\r?\n", re.MULTILINE) + + +def rstrip_line(line): + return line.rstrip(b"\r") class ReceiveBuffer(object): @@ -126,6 +129,6 @@ def maybe_extract_lines(self): if data is None: return None - lines = line_delimiter_regex.split(data.rstrip(b"\r\n")) + lines = list(map(rstrip_line, data.rstrip(b"\r\n").split(b"\n"))) return lines From 8aa9e7fdcf102243d2575d9d8c0e8a9665ad08de Mon Sep 17 00:00:00 2001 From: cdeler Date: Tue, 24 Nov 2020 16:39:57 +0300 Subject: [PATCH 11/15] Changed the ReceiveBuffer after @tomchristie's proposal from https://github.com/python-hyper/h11/pull/115#issuecomment-732934203 --- h11/_connection.py | 1 - h11/_readers.py | 3 +- h11/_receivebuffer.py | 124 ++++++++++++++++---------------- h11/tests/test_receivebuffer.py | 31 ++++---- 4 files changed, 80 insertions(+), 79 deletions(-) diff --git a/h11/_connection.py b/h11/_connection.py index 410c4e9..c4baa80 100644 --- a/h11/_connection.py +++ b/h11/_connection.py @@ -425,7 +425,6 @@ def next_event(self): event = self._extract_next_receive_event() if event not in [NEED_DATA, PAUSED]: self._process_event(self.their_role, event) - self._receive_buffer.compress() if event is NEED_DATA: if len(self._receive_buffer) > self._max_incomplete_event_size: # 431 is "Request header fields too large" which is pretty diff --git a/h11/_readers.py b/h11/_readers.py index 9449f31..bafc00a 100644 --- a/h11/_readers.py +++ b/h11/_readers.py @@ -30,7 +30,6 @@ # Remember that this has to run in O(n) time -- so e.g. the bytearray cast is # critical. obs_fold_re = re.compile(br"[ \t]+") -strict_line_delimiter_regex = re.compile(b"\r\n", re.MULTILINE) def _obsolete_line_fold(lines): @@ -154,7 +153,7 @@ def __call__(self, buf): assert self._bytes_to_discard == 0 if self._bytes_in_chunk == 0: # We need to refill our chunk count - chunk_header = buf.maybe_extract_until_next(strict_line_delimiter_regex, 2) + chunk_header = buf.maybe_extract_next_line() if chunk_header is None: return None matches = validate( diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 9d22466..146ddbc 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -41,94 +41,98 @@ # processed a whole event, which could in theory be slightly more efficient # than the internal bytearray support.) -blank_line_delimiter_regex = re.compile(b"\n\r?\n", re.MULTILINE) - - -def rstrip_line(line): - return line.rstrip(b"\r") +blank_line_regex = re.compile(b"\n\r?\n", re.MULTILINE) class ReceiveBuffer(object): def __init__(self): self._data = bytearray() - # These are both absolute offsets into self._data: - self._start = 0 - self._looked_at = 0 + self._next_line_search = 0 + self._multiple_lines_search = 0 - self._looked_for_regex = blank_line_delimiter_regex + def __iadd__(self, byteslike): + self._data += byteslike + return self def __bool__(self): return bool(len(self)) + def __len__(self): + return len(self._data) + # for @property unprocessed_data def __bytes__(self): - return bytes(self._data[self._start :]) + return bytes(self._data) if sys.version_info[0] < 3: # version specific: Python 2 __str__ = __bytes__ __nonzero__ = __bool__ - def __len__(self): - return len(self._data) - self._start - - def compress(self): - # Heuristic: only compress if it lets us reduce size by a factor - # of 2 - if self._start > len(self._data) // 2: - del self._data[: self._start] - self._looked_at -= self._start - self._start -= self._start - - def __iadd__(self, byteslike): - self._data += byteslike - return self - def maybe_extract_at_most(self, count): - out = self._data[self._start : self._start + count] + """ + Extract a fixed number of bytes from the buffer. + """ + out = self._data[:count] if not out: return None - self._start += len(out) + + self._data[:count] = b"" + self._next_line_search = 0 + self._multiple_lines_search = 0 return out - def maybe_extract_until_next(self, needle_regex, max_needle_length): - # Returns extracted bytes on success (advancing offset), or None on - # failure - if self._looked_for_regex == needle_regex: - looked_at = max(self._start, self._looked_at - max_needle_length) - else: - looked_at = self._start - self._looked_for_regex = needle_regex - - delimiter_match = next( - self._looked_for_regex.finditer(self._data, looked_at), None - ) - - if delimiter_match is None: - self._looked_at = len(self._data) + def maybe_extract_next_line(self): + """ + Extract the first line, if it is completed in the buffer. + """ + # Only search in buffer space that we've not already looked at. + partial_buffer = self._data[self._next_line_search :] + partial_idx = partial_buffer.find(b"\n") + if partial_idx == -1: + self._next_line_search = len(self._data) return None - _, end = delimiter_match.span(0) - - out = self._data[self._start : end] - - self._start = end - + # Truncate the buffer and return it. + idx = self._next_line_search + partial_idx + 1 + out = self._data[:idx] + self._data[:idx] = b"" + self._next_line_search = 0 + self._multiple_lines_search = 0 return out - # HTTP/1.1 has a number of constructs where you keep reading lines until - # you see a blank one. This does that, and then returns the lines. def maybe_extract_lines(self): - if self._data[self._start : self._start + 2] == b"\r\n": - self._start += 2 + """ + Extract everything up to the first blank line, and return a list of lines. + """ + # Handle the case where we have an immediate empty line. + if self._data[:1] == b"\n": + self._data[:1] = b"" + self._next_line_search = 0 + self._multiple_lines_search = 0 return [] - elif self._data[self._start : self._start + 1] == b"\n": - self._start += 1 + + if self._data[:2] == b"\r\n": + self._data[:2] = b"" + self._next_line_search = 0 + self._multiple_lines_search = 0 return [] - else: - data = self.maybe_extract_until_next(blank_line_delimiter_regex, 3) - if data is None: - return None - lines = list(map(rstrip_line, data.rstrip(b"\r\n").split(b"\n"))) + # Only search in buffer space that we've not already looked at. + partial_buffer = self._data[self._multiple_lines_search :] + match = blank_line_regex.search(partial_buffer) + if match is None: + self._multiple_lines_search = max(0, len(self._data) - 2) + return None + + # Truncate the buffer and return it. + idx = self._multiple_lines_search + match.span(0)[-1] + out = self._data[:idx] + lines = [line.rstrip(b"\r") for line in out.split(b"\n")] + + self._data[:idx] = b"" + self._next_line_search = 0 + self._multiple_lines_search = 0 + + assert lines[-2] == lines[-1] == b"" - return lines + return lines[:-2] diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index 8b52071..d7d89da 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -16,7 +16,6 @@ def test_receivebuffer(): assert len(b) == 3 assert bytes(b) == b"123" - b.compress() assert bytes(b) == b"123" assert b.maybe_extract_at_most(2) == b"12" @@ -24,7 +23,6 @@ def test_receivebuffer(): assert len(b) == 1 assert bytes(b) == b"3" - b.compress() assert bytes(b) == b"3" assert b.maybe_extract_at_most(10) == b"3" @@ -37,32 +35,33 @@ def test_receivebuffer(): # maybe_extract_until_next ################################################################ - b += b"12345a6789aa" + b += b"12345\n6789\r\n" - assert b.maybe_extract_until_next(re.compile(b"a"), 1) == b"12345a" - assert bytes(b) == b"6789aa" + assert b.maybe_extract_next_line() == b"12345\n" + assert bytes(b) == b"6789\r\n" - assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) is None - assert bytes(b) == b"6789aa" + assert b.maybe_extract_next_line() == b"6789\r\n" + assert bytes(b) == b"" - b += b"a12" - assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) == b"6789aaa" - assert bytes(b) == b"12" + b += b"12\r" + assert b.maybe_extract_next_line() is None + assert bytes(b) == b"12\r" # check repeated searches for the same needle, triggering the # pickup-where-we-left-off logic - b += b"345" - assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) is None + b += b"345\n\r" + assert b.maybe_extract_next_line() == b"12\r345\n" + assert bytes(b) == b"\r" - b += b"6789aaa123" - assert b.maybe_extract_until_next(re.compile(b"aaa"), 3) == b"123456789aaa" - assert bytes(b) == b"123" + b += b"6789aaa123\n" + assert b.maybe_extract_next_line() == b"\r6789aaa123\n" + assert bytes(b) == b"" ################################################################ # maybe_extract_lines ################################################################ - b += b"\r\na: b\r\nfoo:bar\r\n\r\ntrailing" + b += b"123\r\na: b\r\nfoo:bar\r\n\r\ntrailing" lines = b.maybe_extract_lines() assert lines == [b"123", b"a: b", b"foo:bar"] assert bytes(b) == b"trailing" From 1aefea062f8c4094e2f7013715d186a317bb6fe3 Mon Sep 17 00:00:00 2001 From: cdeler Date: Mon, 30 Nov 2020 16:38:01 +0300 Subject: [PATCH 12/15] Tuned maybe_extract_next_line to search only \r\n ref https://github.com/python-hyper/h11/pull/115#issuecomment-733189772 --- h11/_receivebuffer.py | 8 +++++--- h11/tests/test_receivebuffer.py | 22 ++++++++++++---------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index 146ddbc..baff36a 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -86,14 +86,16 @@ def maybe_extract_next_line(self): Extract the first line, if it is completed in the buffer. """ # Only search in buffer space that we've not already looked at. - partial_buffer = self._data[self._next_line_search :] - partial_idx = partial_buffer.find(b"\n") + search_start_index = max(0, self._next_line_search - 1) + partial_buffer = self._data[search_start_index:] + partial_idx = partial_buffer.find(b"\r\n") if partial_idx == -1: self._next_line_search = len(self._data) return None # Truncate the buffer and return it. - idx = self._next_line_search + partial_idx + 1 + # + 2 is to compensate len(b"\r\n") + idx = search_start_index + partial_idx + 2 out = self._data[:idx] self._data[:idx] = b"" self._next_line_search = 0 diff --git a/h11/tests/test_receivebuffer.py b/h11/tests/test_receivebuffer.py index d7d89da..3a61f9d 100644 --- a/h11/tests/test_receivebuffer.py +++ b/h11/tests/test_receivebuffer.py @@ -35,26 +35,28 @@ def test_receivebuffer(): # maybe_extract_until_next ################################################################ - b += b"12345\n6789\r\n" + b += b"123\n456\r\n789\r\n" - assert b.maybe_extract_next_line() == b"12345\n" - assert bytes(b) == b"6789\r\n" + assert b.maybe_extract_next_line() == b"123\n456\r\n" + assert bytes(b) == b"789\r\n" - assert b.maybe_extract_next_line() == b"6789\r\n" + assert b.maybe_extract_next_line() == b"789\r\n" assert bytes(b) == b"" b += b"12\r" assert b.maybe_extract_next_line() is None assert bytes(b) == b"12\r" - # check repeated searches for the same needle, triggering the - # pickup-where-we-left-off logic b += b"345\n\r" - assert b.maybe_extract_next_line() == b"12\r345\n" - assert bytes(b) == b"\r" + assert b.maybe_extract_next_line() is None + assert bytes(b) == b"12\r345\n\r" + + # here we stopped at the middle of b"\r\n" delimiter - b += b"6789aaa123\n" - assert b.maybe_extract_next_line() == b"\r6789aaa123\n" + b += b"\n6789aaa123\r\n" + assert b.maybe_extract_next_line() == b"12\r345\n\r\n" + assert b.maybe_extract_next_line() == b"6789aaa123\r\n" + assert b.maybe_extract_next_line() is None assert bytes(b) == b"" ################################################################ From f68861511ef0b03a45411563a2cd685ee2f4a04d Mon Sep 17 00:00:00 2001 From: cdeler Date: Thu, 10 Dec 2020 16:52:56 +0300 Subject: [PATCH 13/15] Fixed PR remarks from https://github.com/python-hyper/h11/pull/115#issuecomment-742466116 1. added new tests to test_io.py 2. introduced ReceiveBuffer::_extract 3. added a newsfragment --- h11/_receivebuffer.py | 52 +++++++++++++++++-------------------- h11/tests/test_io.py | 37 ++++++++++++++++++++++++++ newsfragments/7.feature.rst | 3 +++ 3 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 newsfragments/7.feature.rst diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index baff36a..bc4b88c 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -68,6 +68,16 @@ def __bytes__(self): __str__ = __bytes__ __nonzero__ = __bool__ + def _extract(self, count): + # extracting an initial slice of the data buffer and return it + out = self._data[:count] + del self._data[:count] + + self._next_line_search = 0 + self._multiple_lines_search = 0 + + return out + def maybe_extract_at_most(self, count): """ Extract a fixed number of bytes from the buffer. @@ -76,10 +86,7 @@ def maybe_extract_at_most(self, count): if not out: return None - self._data[:count] = b"" - self._next_line_search = 0 - self._multiple_lines_search = 0 - return out + return self._extract(count) def maybe_extract_next_line(self): """ @@ -87,20 +94,16 @@ def maybe_extract_next_line(self): """ # Only search in buffer space that we've not already looked at. search_start_index = max(0, self._next_line_search - 1) - partial_buffer = self._data[search_start_index:] - partial_idx = partial_buffer.find(b"\r\n") + partial_idx = self._data.find(b"\r\n", search_start_index) + if partial_idx == -1: self._next_line_search = len(self._data) return None - # Truncate the buffer and return it. # + 2 is to compensate len(b"\r\n") - idx = search_start_index + partial_idx + 2 - out = self._data[:idx] - self._data[:idx] = b"" - self._next_line_search = 0 - self._multiple_lines_search = 0 - return out + idx = partial_idx + 2 + + return self._extract(idx) def maybe_extract_lines(self): """ @@ -108,33 +111,26 @@ def maybe_extract_lines(self): """ # Handle the case where we have an immediate empty line. if self._data[:1] == b"\n": - self._data[:1] = b"" - self._next_line_search = 0 - self._multiple_lines_search = 0 + self._extract(1) return [] if self._data[:2] == b"\r\n": - self._data[:2] = b"" - self._next_line_search = 0 - self._multiple_lines_search = 0 + self._extract(2) return [] # Only search in buffer space that we've not already looked at. - partial_buffer = self._data[self._multiple_lines_search :] - match = blank_line_regex.search(partial_buffer) + match = blank_line_regex.search(self._data, self._multiple_lines_search) if match is None: self._multiple_lines_search = max(0, len(self._data) - 2) return None # Truncate the buffer and return it. - idx = self._multiple_lines_search + match.span(0)[-1] - out = self._data[:idx] + idx = match.span(0)[-1] + out = self._extract(idx) lines = [line.rstrip(b"\r") for line in out.split(b"\n")] - self._data[:idx] = b"" - self._next_line_search = 0 - self._multiple_lines_search = 0 - assert lines[-2] == lines[-1] == b"" - return lines[:-2] + del lines[-2:] + + return lines diff --git a/h11/tests/test_io.py b/h11/tests/test_io.py index 0323b26..459a627 100644 --- a/h11/tests/test_io.py +++ b/h11/tests/test_io.py @@ -215,6 +215,43 @@ def test_readers_unusual(): ), ) + # Tolerate headers line endings (\r\n and \n) + # \n\r\b between headers and body + tr( + READERS[SERVER, SEND_RESPONSE], + b"HTTP/1.1 200 OK\r\nSomeHeader: val\n\r\n", + Response( + status_code=200, + headers=[("SomeHeader", "val")], + http_version="1.1", + reason="OK", + ), + ) + + # delimited only with \n + tr( + READERS[SERVER, SEND_RESPONSE], + b"HTTP/1.1 200 OK\nSomeHeader1: val1\nSomeHeader2: val2\n\n", + Response( + status_code=200, + headers=[("SomeHeader1", "val1"), ("SomeHeader2", "val2")], + http_version="1.1", + reason="OK", + ), + ) + + # mixed \r\n and \n + tr( + READERS[SERVER, SEND_RESPONSE], + b"HTTP/1.1 200 OK\r\nSomeHeader1: val1\nSomeHeader2: val2\n\r\n", + Response( + status_code=200, + headers=[("SomeHeader1", "val1"), ("SomeHeader2", "val2")], + http_version="1.1", + reason="OK", + ), + ) + # obsolete line folding tr( READERS[CLIENT, IDLE], diff --git a/newsfragments/7.feature.rst b/newsfragments/7.feature.rst new file mode 100644 index 0000000..2cc7a8e --- /dev/null +++ b/newsfragments/7.feature.rst @@ -0,0 +1,3 @@ +Added support for servers with broken line endings. + +After this change `h11` accepts both `\r\n` and `\n` as a headers delimiter \ No newline at end of file From ca534f7255080c14abb969884f98077ba0389da5 Mon Sep 17 00:00:00 2001 From: cdeler Date: Mon, 14 Dec 2020 21:12:11 +0300 Subject: [PATCH 14/15] Fixed PR remarks Replaced lines.rstrip(...) with `del line[-1]` to avoid extra allocations --- h11/_receivebuffer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/h11/_receivebuffer.py b/h11/_receivebuffer.py index bc4b88c..b32f20e 100644 --- a/h11/_receivebuffer.py +++ b/h11/_receivebuffer.py @@ -127,7 +127,11 @@ def maybe_extract_lines(self): # Truncate the buffer and return it. idx = match.span(0)[-1] out = self._extract(idx) - lines = [line.rstrip(b"\r") for line in out.split(b"\n")] + lines = out.split(b"\n") + + for line in lines: + if line.endswith(b"\r"): + del line[-1] assert lines[-2] == lines[-1] == b"" From a1197d68630bc37a941b516c7cc74833460fc97c Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Mon, 21 Dec 2020 15:52:45 -0800 Subject: [PATCH 15/15] Fix ReST formatting --- newsfragments/7.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/7.feature.rst b/newsfragments/7.feature.rst index 2cc7a8e..01d6784 100644 --- a/newsfragments/7.feature.rst +++ b/newsfragments/7.feature.rst @@ -1,3 +1,3 @@ Added support for servers with broken line endings. -After this change `h11` accepts both `\r\n` and `\n` as a headers delimiter \ No newline at end of file +After this change h11 accepts both ``\r\n`` and ``\n`` as a headers delimiter.