Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions src/ahttpx/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def build_request(
return Request(
method=method,
url=self.url.join(url),
headers=self.headers.copy_update(headers),
headers=self.headers.copy_update(headers or {}),
content=content,
)

Expand Down Expand Up @@ -138,20 +138,34 @@ class RedirectMiddleware(Transport):
def __init__(self, transport: Transport) -> None:
self._transport = transport

def is_redirect(self, response: Response) -> bool:
return (
response.status_code in (301, 302, 303, 307, 308)
and "Location" in response.headers
)
def build_redirect_request(self, request: Request, response: Response) -> Request | None:
# Redirect status codes...
if response.status_code not in (301, 302, 303, 307, 308):
return None

# Redirects need a valid location header...
try:
location = URL(response.headers['Location'])
except (KeyError, ValueError):
return None

def build_redirect_request(self, request: Request, response: Response) -> Request:
raise NotImplementedError()
# Instantiate a redirect request...
method = request.method
url = request.url.join(location)
headers = request.headers
content = request.content

return Request(method, url, headers, content)

async def send(self, request: Request) -> Response:
while True:
response = await self._transport.send(request)

if not self.is_redirect(response):
# Determine if we have a redirect or not.
redirect = self.build_redirect_request(request, response)

# If we don't have a redirect, we're done.
if redirect is None:
return response

# If we have a redirect, then we read the body of the response.
Expand All @@ -160,8 +174,8 @@ async def send(self, request: Request) -> Response:
async with response as stream:
await stream.read()

# We've made a request-response and now need to issue a redirect request.
request = self.build_redirect_request(request, response)
# Make the next request
request = redirect

async def close(self):
pass
21 changes: 20 additions & 1 deletion src/ahttpx/_content.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import json
import os
import typing
Expand Down Expand Up @@ -339,8 +340,26 @@ async def parse(self, stream: Stream) -> 'Content':
data = json.loads(source)
return JSON(data, source)

# Return the underlying data. Copied to ensure immutability.
@property
def value(self) -> typing.Any:
return copy.deepcopy(self._data)

# dict and list style accessors, eg. for casting.
def keys(self) -> typing.KeysView[str]:
return self._data.keys()

def __len__(self) -> int:
return len(self._data)

def __getitem__(self, key: typing.Any) -> typing.Any:
return self._data[key]
return copy.deepcopy(self._data[key])

# Built-ins.
def __eq__(self, other: typing.Any) -> bool:
if isinstance(other, JSON):
return self._data == other._data
return self._data == other

def __str__(self) -> str:
return self._content.decode('utf-8')
Expand Down
16 changes: 6 additions & 10 deletions src/ahttpx/_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,15 @@ def copy_remove(self, key: str) -> "Headers":
h = {k: v for k, v in self._dict.items() if k.lower() != key.lower()}
return Headers(h)

def copy_update(self, update: "Headers" | typing.Mapping[str, str] | None) -> "Headers":
def copy_update(self, update: "Headers" | typing.Mapping[str, str]) -> "Headers":
"""
Return a new Headers instance, removing the value of a key.

Usage:
Return a new Headers instance, updating the items.
Existing values are overwritten.

h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
h = h.copy_update({"Accept-Encoding": "gzip"})
assert h == httpx.Headers({"Accept": "*/*", "Accept-Encoding": "gzip", "User-Agent": "python/httpx"})
>>> h = hx.Headers({"Accept": "*/*"})
>>> h = h.copy_update({"User-Agent": "python/httpx"})
>>> assert h == {"Accept": "*/*", "User-Agent": "python/httpx"}
"""
if update is None:
return self

new = update if isinstance(update, Headers) else Headers(update)

# Remove updated items using a case-insensitive approach...
Expand Down
36 changes: 25 additions & 11 deletions src/httpx/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def build_request(
return Request(
method=method,
url=self.url.join(url),
headers=self.headers.copy_update(headers),
headers=self.headers.copy_update(headers or {}),
content=content,
)

Expand Down Expand Up @@ -138,20 +138,34 @@ class RedirectMiddleware(Transport):
def __init__(self, transport: Transport) -> None:
self._transport = transport

def is_redirect(self, response: Response) -> bool:
return (
response.status_code in (301, 302, 303, 307, 308)
and "Location" in response.headers
)
def build_redirect_request(self, request: Request, response: Response) -> Request | None:
# Redirect status codes...
if response.status_code not in (301, 302, 303, 307, 308):
return None

# Redirects need a valid location header...
try:
location = URL(response.headers['Location'])
except (KeyError, ValueError):
return None

def build_redirect_request(self, request: Request, response: Response) -> Request:
raise NotImplementedError()
# Instantiate a redirect request...
method = request.method
url = request.url.join(location)
headers = request.headers
content = request.content

return Request(method, url, headers, content)

def send(self, request: Request) -> Response:
while True:
response = self._transport.send(request)

if not self.is_redirect(response):
# Determine if we have a redirect or not.
redirect = self.build_redirect_request(request, response)

# If we don't have a redirect, we're done.
if redirect is None:
return response

# If we have a redirect, then we read the body of the response.
Expand All @@ -160,8 +174,8 @@ def send(self, request: Request) -> Response:
with response as stream:
stream.read()

# We've made a request-response and now need to issue a redirect request.
request = self.build_redirect_request(request, response)
# Make the next request
request = redirect

def close(self):
pass
21 changes: 20 additions & 1 deletion src/httpx/_content.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import json
import os
import typing
Expand Down Expand Up @@ -339,8 +340,26 @@ def parse(self, stream: Stream) -> 'Content':
data = json.loads(source)
return JSON(data, source)

# Return the underlying data. Copied to ensure immutability.
@property
def value(self) -> typing.Any:
return copy.deepcopy(self._data)

# dict and list style accessors, eg. for casting.
def keys(self) -> typing.KeysView[str]:
return self._data.keys()

def __len__(self) -> int:
return len(self._data)

def __getitem__(self, key: typing.Any) -> typing.Any:
return self._data[key]
return copy.deepcopy(self._data[key])

# Built-ins.
def __eq__(self, other: typing.Any) -> bool:
if isinstance(other, JSON):
return self._data == other._data
return self._data == other

def __str__(self) -> str:
return self._content.decode('utf-8')
Expand Down
16 changes: 6 additions & 10 deletions src/httpx/_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,15 @@ def copy_remove(self, key: str) -> "Headers":
h = {k: v for k, v in self._dict.items() if k.lower() != key.lower()}
return Headers(h)

def copy_update(self, update: "Headers" | typing.Mapping[str, str] | None) -> "Headers":
def copy_update(self, update: "Headers" | typing.Mapping[str, str]) -> "Headers":
"""
Return a new Headers instance, removing the value of a key.

Usage:
Return a new Headers instance, updating the items.
Existing values are overwritten.

h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})
h = h.copy_update({"Accept-Encoding": "gzip"})
assert h == httpx.Headers({"Accept": "*/*", "Accept-Encoding": "gzip", "User-Agent": "python/httpx"})
>>> h = hx.Headers({"Accept": "*/*"})
>>> h = h.copy_update({"User-Agent": "python/httpx"})
>>> assert h == {"Accept": "*/*", "User-Agent": "python/httpx"}
"""
if update is None:
return self

new = update if isinstance(update, Headers) else Headers(update)

# Remove updated items using a case-insensitive approach...
Expand Down
26 changes: 15 additions & 11 deletions tests/test_ahttpx/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,24 @@ async def test_client(client):


@pytest.mark.trio
async def test_get(client):
async with ahttpx.serve_http(echo) as server:
r = await client.get(server.url)
assert r.status_code == 200
assert r.body == b'{"method":"GET","query-params":{},"content-type":null,"json":null}'
# assert r.text == '{"method":"GET","query-params":{},"content-type":null,"json":null}'
async def test_get(client, server):
r = await client.get(server.url)
assert r.status_code == 200
assert r.body == b'{"method":"GET","query-params":{},"content-type":null,"json":null}'
assert r.content == {
"method": "GET",
"query-params": {},
"content-type": None,
"json": None
}


@pytest.mark.trio
async def test_post(client, server):
data = ahttpx.JSON({"data": 123})
r = await client.post(server.url, content=data)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'POST',
'query-params': {},
'content-type': 'application/json',
Expand All @@ -57,7 +61,7 @@ async def test_put(client, server):
data = ahttpx.JSON({"data": 123})
r = await client.put(server.url, content=data)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'PUT',
'query-params': {},
'content-type': 'application/json',
Expand All @@ -70,7 +74,7 @@ async def test_patch(client, server):
data = ahttpx.JSON({"data": 123})
r = await client.patch(server.url, content=data)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'PATCH',
'query-params': {},
'content-type': 'application/json',
Expand All @@ -82,7 +86,7 @@ async def test_patch(client, server):
async def test_delete(client, server):
r = await client.delete(server.url)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'DELETE',
'query-params': {},
'content-type': None,
Expand All @@ -94,7 +98,7 @@ async def test_delete(client, server):
async def test_request(client, server):
r = await client.request("GET", server.url)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'GET',
'query-params': {},
'content-type': None,
Expand Down
10 changes: 5 additions & 5 deletions tests/test_ahttpx/test_quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async def server():
async def test_get(server):
r = await ahttpx.get(server.url)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'GET',
'query-params': {},
'content-type': None,
Expand All @@ -36,7 +36,7 @@ async def test_post(server):
data = ahttpx.JSON({"data": 123})
r = await ahttpx.post(server.url, content=data)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'POST',
'query-params': {},
'content-type': 'application/json',
Expand All @@ -49,7 +49,7 @@ async def test_put(server):
data = ahttpx.JSON({"data": 123})
r = await ahttpx.put(server.url, content=data)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'PUT',
'query-params': {},
'content-type': 'application/json',
Expand All @@ -62,7 +62,7 @@ async def test_patch(server):
data = ahttpx.JSON({"data": 123})
r = await ahttpx.patch(server.url, content=data)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'PATCH',
'query-params': {},
'content-type': 'application/json',
Expand All @@ -74,7 +74,7 @@ async def test_patch(server):
async def test_delete(server):
r = await ahttpx.delete(server.url)
assert r.status_code == 200
assert json.loads(r.body) == {
assert r.content == {
'method': 'DELETE',
'query-params': {},
'content-type': None,
Expand Down
Loading
Loading