From 457bd27580275a2d817716a230c23265a59474fc Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 20:58:18 +0200 Subject: [PATCH 1/9] chore: remove Python 2 compatibility code --- debian/rules | 3 ++- httoop/meta.py | 5 ----- httoop/uri/uri.py | 7 +------ httoop/util.py | 3 ++- httoop/version.py | 8 ++------ 5 files changed, 7 insertions(+), 19 deletions(-) diff --git a/debian/rules b/debian/rules index 50e65073..c424f98d 100755 --- a/debian/rules +++ b/debian/rules @@ -1,6 +1,7 @@ #!/usr/bin/make -f export PYBUILD_NAME=httoop +export PYBUILD_SYSTEM=pyproject %: - dh $@ --with python2,python3 --buildsystem=pybuild + dh $@ --with python3 --buildsystem=pybuild diff --git a/httoop/meta.py b/httoop/meta.py index efa527b5..1e610c3a 100644 --- a/httoop/meta.py +++ b/httoop/meta.py @@ -54,9 +54,4 @@ def __new__(mcs: type, name: str, bases: Any, dict_: dict[str, Any]) -> Any: bases.remove(object) bases.append(Semantic) - # python 2/3 unifying - if '__bool__' in dict_ or '__nonzero__' in dict_: - dict_.setdefault('__bool__', dict_.get('__nonzero__')) - dict_.setdefault('__nonzero__', dict_.get('__bool__')) - return super().__new__(mcs, name, tuple(bases), dict_) diff --git a/httoop/uri/uri.py b/httoop/uri/uri.py index fe61d76a..591cef40 100644 --- a/httoop/uri/uri.py +++ b/httoop/uri/uri.py @@ -261,8 +261,6 @@ def _unquote_host(self, host: bytes) -> str: host = host[1:-1] try: host = inet_ntop(AF_INET6, inet_pton(AF_INET6, host.decode('ascii'))) - if isinstance(host, bytes): # Python 2 - host = host.decode('ascii') return '[%s]' % (host, ) except (SocketError, UnicodeDecodeError): # IPvFuture @@ -275,10 +273,7 @@ def _unquote_host(self, host: bytes) -> str: # IPv4 if all(x.isdigit() for x in host.split(b'.')): try: - host = inet_ntop(AF_INET, inet_pton(AF_INET, host.decode('ascii'))) - if isinstance(host, bytes): # Python 2 - host = host.decode('ascii') - return host + return inet_ntop(AF_INET, inet_pton(AF_INET, host.decode('ascii'))) except (SocketError, UnicodeDecodeError): raise InvalidURI(_('Invalid IPv4 address in URI.')) diff --git a/httoop/util.py b/httoop/util.py index 5aa8f428..d171bc38 100644 --- a/httoop/util.py +++ b/httoop/util.py @@ -1,10 +1,11 @@ -"""Utilities for python2/3 compatibility.""" +"""Utilities for Python compatibility.""" from __future__ import annotations import codecs from typing import Any, Callable + try: from typing import Self except ImportError: # < Py 3.11 diff --git a/httoop/version.py b/httoop/version.py index bcc869fb..b761eac3 100644 --- a/httoop/version.py +++ b/httoop/version.py @@ -1,11 +1,7 @@ +from httoop.header import Server as __Server, UserAgent as __UserAgent +from httoop.messages import Protocol -if __import__('sys').version_info < (3, 5) and __import__('sys').version_info > (2, 8): # pragma: no cover - raise RuntimeError('httoop only supports >= python2.7 and >= python3.5!') - -from httoop.header import Server as __Server, UserAgent as __UserAgent # noqa -from httoop.messages import Protocol # noqa - __version__ = '0.1.1' UserAgentHeader = __UserAgent.parse(b'httoop/%s' % (__version__.encode(), )) ServerHeader = __Server.parse(b'httoop/%s' % (__version__.encode(), )) From 723c87dd0c505e5a96d74cafc40d9b2a4d505a60 Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 21:07:49 +0200 Subject: [PATCH 2/9] test(authentication): add test for AuthRequestElement properties --- tests/authentication/test_basic.py | 12 ++++++++++-- tests/authentication/test_digest.py | 10 +++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/authentication/test_basic.py b/tests/authentication/test_basic.py index 020830c8..40d69397 100644 --- a/tests/authentication/test_basic.py +++ b/tests/authentication/test_basic.py @@ -16,8 +16,16 @@ def test_basic_authorization(headers): auth = Authorization('Basic', {'username': 'admin', 'password': '12345'}) assert bytes(auth) == b'Basic YWRtaW46MTIzNDU=' headers.parse(b'Authorization: %s' % auth) - assert headers.element('Authorization').params['username'] == b'admin' - assert headers.element('Authorization').params['password'] == b'12345' + elem = headers.element('Authorization') + assert elem.params['username'] == b'admin' + assert elem.params['password'] == b'12345' + assert elem.scheme == 'basic' + assert elem.username == 'admin' + assert elem.password == '12345' + elem.username = 'test' + elem.password = 'test' + assert elem.username == 'test' + assert elem.password == 'test' @pytest.mark.parametrize('invalid', (b'foo', b'Zm9v', 'föo'.encode('latin1'))) diff --git a/tests/authentication/test_digest.py b/tests/authentication/test_digest.py index 0679e88d..f52f7113 100644 --- a/tests/authentication/test_digest.py +++ b/tests/authentication/test_digest.py @@ -53,7 +53,15 @@ def test_digest_authorization(headers): qop="auth", nc="00000001"''' headers.parse(auth_bytes) - assert auth.params == headers.element('Authorization').params + elem = headers.element('Authorization') + assert elem.params == auth.params + assert elem.scheme == 'digest' + assert elem.username == 'Mufasa' + assert elem.password is None + elem.username = 'test' + elem.password = 'test' + assert elem.username == 'test' + assert elem.password is None def test_digest_authorization_no_qop(headers): From 454fd1a59dd0aa5e8238e926c9cbf6a2d6ced20b Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 21:12:55 +0200 Subject: [PATCH 3/9] test(wsgi): add tests for illegal WSGI return values --- tests/api/test_wsgi.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/api/test_wsgi.py b/tests/api/test_wsgi.py index a7bf51ce..30166677 100644 --- a/tests/api/test_wsgi.py +++ b/tests/api/test_wsgi.py @@ -71,6 +71,19 @@ def application9(environ, start_response): return [] +def application_string_write(environ, start_response): + # it's probably invalid but we support it currently + write = start_response(OK, response_headers) + write(output.decode('ASCII')) + return [] + + +def application_string_result(environ, start_response): + # it's probably invalid but we support it currently + start_response(OK, response_headers) + return output.decode('ASCII') + + @pytest.mark.parametrize('application,output', [ (application1, output), (application2, output), @@ -81,6 +94,8 @@ def application9(environ, start_response): (application7, output), (application8, output + output), (application9, b''), + (application_string_write, output), + (application_string_result, output), ]) def test_wsgi_success(application, output): client = WSGIClient() From a3bf3a61df5187736583f2454bd06e0eabcdf0f9 Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 21:37:31 +0200 Subject: [PATCH 4/9] chore: exclude impossible line from coverage --- httoop/messages/body.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httoop/messages/body.py b/httoop/messages/body.py index 0cd36f1f..da3ae751 100644 --- a/httoop/messages/body.py +++ b/httoop/messages/body.py @@ -218,7 +218,7 @@ def __iter__(self) -> Iterator[Any]: def __compose_chunked_iter(self, iterable): for data in iterable: - if not data: + if not data: # pragma: no cover continue yield b'%x\r\n%s\r\n' % (len(data), data) if self.trailer: From 152550222434f71207fe425a121a8a0f3ae6cb6f Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 22:02:33 +0200 Subject: [PATCH 5/9] test(cli): add test for CLI parsing of messages Fixes: 72684e357d824d57a4d6d17738b59e52c5a309ab --- tests/test_cli.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 312d00c0..a4f8b426 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import re import subprocess import sys import tempfile @@ -21,5 +22,17 @@ def test_cli_compose(): assert b'GET / HTTP/1.1\r\n\r\ntest' == stdout +def test_cli_parse(): + with tempfile.NamedTemporaryFile() as fd: + fd.write(b'PUT /foo HTTP/1.1\r\nHost: foo\r\n\r\n') + fd.flush() + stdout = subprocess.check_output([sys.executable, '-m', 'httoop', 'parse', 'request', '--file', fd.name]) + assert re.match(br"^\n\n\n$", stdout) + + p = subprocess.Popen([sys.executable, '-m', 'httoop', 'parse', 'response'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) + stdout, stderr = p.communicate(b'HTTP/1.1 400 Evil Request\r\n\r\n') + assert re.match(br"^\n\n\nb''\n$", stdout), stdout + + def test_invalid_input(): assert b'GET / HTTP/1.1\r\n\r\n' == subprocess.check_output([sys.executable, '-m', 'httoop', 'compose', 'request', '--protocol', '1:0', '-H', 'foo']) From a72d10fc9ddb00d090c51152e9f05d9da9036cd9 Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 22:18:44 +0200 Subject: [PATCH 6/9] test(message): test format method Fixes: 208501e4d1575fa4a6f273ba7c8576c8c457883a --- tests/messaging/test_request_method.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/messaging/test_request_method.py b/tests/messaging/test_request_method.py index 827d7fbc..c4eb4a56 100644 --- a/tests/messaging/test_request_method.py +++ b/tests/messaging/test_request_method.py @@ -4,6 +4,10 @@ from httoop.exceptions import InvalidLine +def test_method_format(request_): + assert f'{request_.method}' == 'GET' + + def test_method_maxlength(request_): with pytest.raises(InvalidLine): request_.method.parse(b'A' * 21) From 60089445ef0a7e61f9123cb3f8349e8040a3358a Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 22:30:50 +0200 Subject: [PATCH 7/9] test(utils): test translateable exceptions can be transformed to bytes --- tests/uri/test_uri_parsing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/uri/test_uri_parsing.py b/tests/uri/test_uri_parsing.py index b1917e69..bc7b5381 100644 --- a/tests/uri/test_uri_parsing.py +++ b/tests/uri/test_uri_parsing.py @@ -193,6 +193,7 @@ def test_invalid_uri_characters(char): with pytest.raises(InvalidURI) as exc: URI().parse(b'/foo%sbar' % (char,)) assert 'must consist of printable ASCII characters without whitespace.' in str(exc.value) + assert b'must consist of printable ASCII characters without whitespace.' in bytes(exc.value) @pytest.mark.parametrize('char', [ From 8a3bc48bb45b91dfec8c32d6da02d7ef46e916d6 Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 21:24:10 +0200 Subject: [PATCH 8/9] test(headers): test If-Match conditionals Fixes: 34e47399cbfbd6c0b5ac493ce7ee0867197955db --- tests/headers/test_conditional.py | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/headers/test_conditional.py b/tests/headers/test_conditional.py index 12567b54..6ed58f33 100644 --- a/tests/headers/test_conditional.py +++ b/tests/headers/test_conditional.py @@ -1,3 +1,6 @@ +import pytest + + def test_conditional_header(headers): headers.parse(b'Last-Modified: Mon, 15 Jun 2020 21:18:41 GMT') headers.parse(b'If-Modified-Since: Mon, 15 Jun 2020 21:18:40 GMT') @@ -16,3 +19,54 @@ def test_etag_header(headers): assert headers.element('ETag') == 'foo' assert headers.element('ETag') == '*' assert headers.element('ETag') != 'bar' + + +def test_if_match_header(headers): + headers.parse(b'If-Match: W/"foo"') + assert headers.element('If-Match') == 'W/"foo"' + assert headers.element('If-Match').matches('W/"foo"') + assert headers.element('If-Match') != '"foo"' + assert headers.element('If-Match') != 'foo' + assert headers.element('If-Match') != '*' + assert headers.element('If-Match').matches_etag('foo', strong=False) + assert not headers.element('If-Match').matches_etag('foo') + + +def test_if_match_header_star(headers): + headers.parse(b'If-Match: *') + assert headers.element('If-Match') == 'W/"foo"' + + +@pytest.mark.parametrize('match', [ + '"def456"', + '"abc123", "def456", "ghi789"', + '*', +]) +def test_if_match_header_raw(headers, match): + etag = 'def456' + headers.parse(f'If-Match: {match}'.encode()) + assert any(condition.matches_etag(etag) for condition in headers.elements('If-Match')) + + +@pytest.mark.parametrize('match', [ + '"def456"', + 'W/"def456"', + '"abc123", "def456", "ghi789"', + '*', +]) +def test_if_none_match_header_raw(headers, match): + etag = 'def456' + headers.parse(f'If-Match: {match}'.encode()) + assert any(condition.matches_etag(etag, strong=False) for condition in headers.elements('If-Match')) + + +@pytest.mark.parametrize('match', [ + 'def456', + 'w/"def456"', + 'W/"def456"', + '"abc123", "ghi789"', +]) +def test_if_mismatch_header_raw(headers, match): + etag = 'def456' + headers.parse(f'If-Match: {match}'.encode()) + assert not any(condition.matches_etag(etag) for condition in headers.elements('If-Match')) From ba668a4535a8361dd9b747acd1ea7930545c9f7b Mon Sep 17 00:00:00 2001 From: Space One Date: Mon, 27 Apr 2026 22:54:10 +0200 Subject: [PATCH 9/9] feat(header.conditional): allow to compare If-Match with raw E-Tags Fixes: 34e47399cbfbd6c0b5ac493ce7ee0867197955db --- httoop/header/conditional.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/httoop/header/conditional.py b/httoop/header/conditional.py index 842e3200..1c14db70 100644 --- a/httoop/header/conditional.py +++ b/httoop/header/conditional.py @@ -30,8 +30,28 @@ def __int__(self) -> int: class _MatchElement: - def matches(self, value): - return self == value or self == '*' + def __eq__(self, other: Any) -> bool: + return self.value == other or self.value == '*' + + def matches(self, etag): + return self == etag + + def matches_etag(self, etag, *, strong: bool = True): + value = self.value + + if value == '*': + return True + + is_weak = value.startswith('W/') + if is_weak: + if strong: + return False + value = value[2:] + + if not (value.startswith('"') and value.endswith('"')): + return False + + return value[1:-1] == etag class ETag(HeaderElement):