import base64
import http.server
import json
import socketserver
import threading
import urllib.parse
import httpx
import pytest
import eggfetch
assert httpx.__version__ == "0.28.1", (
f"Expected httpx 0.28.1, got {httpx.__version__}"
)
class _CompatHandler(http.server.BaseHTTPRequestHandler):
def _send_json(self, data, status=200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_text(self, text, status=200):
body = text.encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
port = self.server.server_address[1]
base = f"http://127.0.0.1:{port}"
if path == "/get":
self._send_json({"method": "GET"})
elif path == "/json":
self._send_json({"key": "value", "number": 42})
elif path == "/headers":
headers = {k: v for k, v in self.headers.items()}
self._send_json({"headers": headers})
elif path == "/redirect/302":
self.send_response(302)
self.send_header("Location", f"{base}/get")
self.end_headers()
elif path == "/basic-auth":
auth_header = self.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
decoded = base64.b64decode(auth_header[6:]).decode()
user, _password = decoded.split(":", 1)
self._send_json({"authenticated": True, "user": user})
else:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="test"')
self.end_headers()
elif path == "/status/404":
self._send_text("not found", 404)
elif path == "/status/500":
self._send_text("server error", 500)
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/json":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
data = json.loads(body)
self._send_json({"received": data})
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
class _ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
@pytest.fixture(scope="module")
def compat_server():
server = _ThreadedHTTPServer(("127.0.0.1", 0), _CompatHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
class TestBasicClientShape:
def test_client_context_manager(self, compat_server):
with eggfetch.Client() as client:
resp = client.get(f"{compat_server}/get")
assert resp.status_code == 200
def test_async_client_context_manager(self, compat_server):
import asyncio
async def _test():
async with eggfetch.AsyncClient() as client:
resp = await client.get(f"{compat_server}/get")
assert resp.status_code == 200
asyncio.run(_test())
def test_client_has_expected_methods(self):
client = eggfetch.Client()
for method in (
"get", "post", "put", "patch", "delete", "head",
"options", "request", "stream",
):
assert hasattr(client, method), f"Client missing method: {method}"
def test_async_client_has_expected_methods(self):
client = eggfetch.AsyncClient()
for method in (
"get", "post", "put", "patch", "delete", "head",
"options", "request", "stream",
):
assert hasattr(client, method), f"AsyncClient missing method: {method}"
class TestRequestMethods:
@pytest.mark.parametrize(
"method", ["get", "post", "put", "patch", "delete", "head", "options"]
)
def test_method_exists(self, method):
assert callable(getattr(eggfetch, method))
def test_get_returns_response(self, compat_server):
resp = eggfetch.get(f"{compat_server}/get")
assert resp.status_code == 200
def test_json_response(self, compat_server):
resp = eggfetch.get(f"{compat_server}/json")
assert resp.json() == {"key": "value", "number": 42}
def test_post_json(self, compat_server):
resp = eggfetch.post(f"{compat_server}/json", json={"data": "test"})
assert resp.status_code == 200
assert resp.json()["received"]["data"] == "test"
class TestRedirectBehavior:
def test_no_follow_by_default(self, compat_server):
resp = eggfetch.get(f"{compat_server}/redirect/302")
assert resp.status_code == 302
def test_follow_with_flag(self, compat_server):
resp = eggfetch.get(
f"{compat_server}/redirect/302", follow_redirects=True
)
assert resp.status_code == 200
class _EchoHeadersHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
headers = {k: v for k, v in self.headers.items()}
body = json.dumps({"headers": headers}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
class TestCrossOriginHeaderStripping:
@pytest.fixture(scope="class")
def cross_origin_server(self):
server = _ThreadedHTTPServer(("127.0.0.1", 0), _EchoHeadersHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture(scope="class")
def redirect_server(self, cross_origin_server):
class _RedirectHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/redirect/cross-origin-auth":
self.send_response(302)
self.send_header("Location", f"{cross_origin_server}/echo-headers")
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
server = _ThreadedHTTPServer(("127.0.0.1", 0), _RedirectHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
def test_cross_origin_redirect_strips_authorization(self, redirect_server):
resp = eggfetch.get(
f"{redirect_server}/redirect/cross-origin-auth",
headers={"Authorization": "Bearer secret-token"},
follow_redirects=True,
)
assert resp.status_code == 200
echoed = resp.json()["headers"]
assert "authorization" not in {k.lower() for k in echoed}
def test_cross_origin_redirect_strips_cookie(self, redirect_server):
with eggfetch.Client(cookies={"session": "abc123"}) as client:
resp = client.get(
f"{redirect_server}/redirect/cross-origin-auth",
follow_redirects=True,
)
assert resp.status_code == 200
echoed = resp.json()["headers"]
assert "cookie" not in {k.lower() for k in echoed}
class TestErrorHandling:
def test_4xx_raises_on_status(self, compat_server):
resp = eggfetch.get(f"{compat_server}/status/404")
with pytest.raises(eggfetch.HTTPStatusError):
resp.raise_for_status()
def test_5xx_raises_on_status(self, compat_server):
resp = eggfetch.get(f"{compat_server}/status/500")
with pytest.raises(eggfetch.HTTPStatusError):
resp.raise_for_status()
def test_base_exception_is_eggfetch_error(self):
assert issubclass(eggfetch.HTTPStatusError, eggfetch.EggfetchError)
assert issubclass(eggfetch.TimeoutException, eggfetch.EggfetchError)
assert issubclass(eggfetch.NetworkError, eggfetch.EggfetchError)
class TestTimeout:
def test_timeout_float(self, compat_server):
resp = eggfetch.get(f"{compat_server}/get", timeout=5.0)
assert resp.status_code == 200
def test_timeout_object(self, compat_server):
t = eggfetch.Timeout(5.0)
resp = eggfetch.get(f"{compat_server}/get", timeout=t)
assert resp.status_code == 200
def test_timeout_properties(self):
t = eggfetch.Timeout(3.0)
assert t.pool == 3.0
assert t.connect == 3.0
assert t.read == 3.0
assert t.write == 3.0
class TestAuthShape:
def test_basic_auth_object(self):
auth = eggfetch.BasicAuth("user", "pass")
assert auth is not None
def test_bearer_auth_object(self):
auth = eggfetch.BearerAuth("token")
assert auth is not None
def test_noauth(self):
assert eggfetch.NOAUTH is not None
class TestResponseShape:
def test_status_code(self, compat_server):
resp = eggfetch.get(f"{compat_server}/get")
assert hasattr(resp, "status_code")
assert resp.status_code == 200
def test_headers(self, compat_server):
resp = eggfetch.get(f"{compat_server}/get")
assert hasattr(resp, "headers")
assert isinstance(resp.headers, eggfetch.Headers)
def test_text(self, compat_server):
resp = eggfetch.get(f"{compat_server}/json")
assert hasattr(resp, "text")
assert isinstance(resp.text, str)
def test_json_method(self, compat_server):
resp = eggfetch.get(f"{compat_server}/json")
assert callable(resp.json)
def test_cookies(self, compat_server):
resp = eggfetch.get(f"{compat_server}/get")
assert hasattr(resp, "cookies")
class TestCookieShape:
def test_cookies_object(self):
cookies = eggfetch.Cookies()
assert hasattr(cookies, "set")
assert hasattr(cookies, "get")