Skip to content

Commit addb8a0

Browse files
committed
splits tooling
1 parent d6f49e2 commit addb8a0

File tree

5 files changed

+240
-119
lines changed

5 files changed

+240
-119
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import logging
2+
3+
from fastapi import FastAPI
4+
5+
_logger = logging.getLogger(__name__)
6+
7+
8+
class AppStateMixin:
9+
"""
10+
Mixin to get, set and delete an instance of 'self' from/to app.state
11+
"""
12+
13+
app_state_name: str # Name used in app.state.$(app_state_name)
14+
frozen: bool = True # Will raise if set multiple times
15+
16+
@classmethod
17+
def get_from_app_state(cls, app: FastAPI):
18+
return getattr(app.state, cls.app_state_name)
19+
20+
def set_to_app_state(self, app: FastAPI):
21+
if (exists := getattr(app.state, self.app_state_name, None)) and self.frozen:
22+
msg = f"An instance of {type(self)} already in app.state.{self.app_state_name}={exists}"
23+
raise ValueError(msg)
24+
25+
setattr(app.state, self.app_state_name, self)
26+
return self.get_from_app_state(app)
27+
28+
@classmethod
29+
def pop_from_app_state(cls, app: FastAPI):
30+
"""
31+
Raises:
32+
AttributeError: if instance is not in app.state
33+
"""
34+
old = getattr(app.state, cls.app_state_name)
35+
delattr(app.state, cls.app_state_name)
36+
return old

packages/service-library/src/servicelib/fastapi/http_client.py

Lines changed: 0 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -58,73 +58,3 @@ async def check_liveness(self) -> LivenessResult:
5858
return IsResponsive(elapsed=response.elapsed)
5959
except httpx.RequestError as err:
6060
return IsNonResponsive(reason=f"{err}")
61-
62-
63-
class AppStateMixin:
64-
"""
65-
Mixin to get, set and delete an instance of 'self' from/to app.state
66-
"""
67-
68-
app_state_name: str # Name used in app.state.$(app_state_name)
69-
frozen: bool = True # Will raise if set multiple times
70-
71-
@classmethod
72-
def get_from_app_state(cls, app: FastAPI):
73-
return getattr(app.state, cls.app_state_name)
74-
75-
def set_to_app_state(self, app: FastAPI):
76-
if (exists := getattr(app.state, self.app_state_name, None)) and self.frozen:
77-
msg = f"An instance of {type(self)} already in app.state.{self.app_state_name}={exists}"
78-
raise ValueError(msg)
79-
80-
setattr(app.state, self.app_state_name, self)
81-
return self.get_from_app_state(app)
82-
83-
@classmethod
84-
def pop_from_app_state(cls, app: FastAPI):
85-
"""
86-
Raises:
87-
AttributeError: if instance is not in app.state
88-
"""
89-
old = getattr(app.state, cls.app_state_name)
90-
delattr(app.state, cls.app_state_name)
91-
return old
92-
93-
94-
def to_curl_command(request: httpx.Request, *, use_short_options: bool = True) -> str:
95-
"""Composes a curl command from a given request
96-
97-
Can be used to reproduce a request in a separate terminal (e.g. debugging)
98-
"""
99-
# Adapted from https://github.com/marcuxyz/curlify2/blob/master/curlify2/curlify.py
100-
method = request.method
101-
url = request.url
102-
103-
# https://curl.se/docs/manpage.html#-X
104-
# -X, --request {method}
105-
_x = "-X" if use_short_options else "--request"
106-
request_option = f"{_x} {method}"
107-
108-
# https://curl.se/docs/manpage.html#-d
109-
# -d, --data <data> HTTP POST data
110-
data_option = ""
111-
if body := request.read().decode():
112-
_d = "-d" if use_short_options else "--data"
113-
data_option = f"{_d} '{body}'"
114-
115-
# https://curl.se/docs/manpage.html#-H
116-
# H, --header <header/@file> Pass custom header(s) to server
117-
118-
headers_option = ""
119-
headers = []
120-
for key, value in request.headers.items():
121-
if "secret" in key.lower() or "pass" in key.lower():
122-
headers.append(f'"{key}: *****"')
123-
else:
124-
headers.append(f'"{key}: {value}"')
125-
126-
if headers:
127-
_h = "-H" if use_short_options else "--header"
128-
headers_option = f"{_h} {f' {_h} '.join(headers)}"
129-
130-
return f"curl {request_option} {headers_option} {data_option} {url}"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import httpx
2+
3+
4+
def _is_secret(k: str) -> bool:
5+
return "secret" in k.lower() or "pass" in k.lower()
6+
7+
8+
def _get_headers_safely(request: httpx.Request) -> dict[str, str]:
9+
return {k: "*" * 5 if _is_secret(k) else v for k, v in request.headers.items()}
10+
11+
12+
def to_httpx_command(
13+
request: httpx.Request, *, use_short_options: bool = True, multiline: bool = False
14+
) -> str:
15+
"""Command with httpx CLI
16+
17+
$ httpx --help
18+
19+
NOTE: Particularly handy as an alternative to curl (e.g. when docker exec in osparc containers)
20+
SEE https://www.python-httpx.org/
21+
"""
22+
cmd = [
23+
"httpx",
24+
]
25+
26+
# -m, --method METHOD
27+
cmd.append(f'{"-m" if use_short_options else "--method"} {request.method}')
28+
29+
# -c, --content TEXT Byte content to include in the request body.
30+
if content := request.read().decode():
31+
cmd.append(f'{"-c" if use_short_options else "--content"} \'{content}\'')
32+
33+
# -h, --headers <NAME VALUE> ... Include additional HTTP headers in the request.
34+
if headers := _get_headers_safely(request):
35+
cmd.extend(
36+
[
37+
f'{"-h" if use_short_options else "--headers"} "{name}" "{value}"'
38+
for name, value in headers.items()
39+
]
40+
)
41+
42+
cmd.append(f"{request.url}")
43+
separator = " \\\n" if multiline else " "
44+
return separator.join(cmd)
45+
46+
47+
def to_curl_command(
48+
request: httpx.Request, *, use_short_options: bool = True, multiline: bool = False
49+
) -> str:
50+
"""Composes a curl command from a given request
51+
52+
$ curl --help
53+
54+
NOTE: Handy reproduce a request in a separate terminal (e.g. debugging)
55+
"""
56+
# Adapted from https://github.com/marcuxyz/curlify2/blob/master/curlify2/curlify.py
57+
cmd = [
58+
"curl",
59+
]
60+
61+
# https://curl.se/docs/manpage.html#-X
62+
# -X, --request {method}
63+
cmd.append(f'{"-X" if use_short_options else "--request"} {request.method}')
64+
65+
# https://curl.se/docs/manpage.html#-H
66+
# H, --header <header/@file> Pass custom header(s) to server
67+
if headers := _get_headers_safely(request):
68+
cmd.extend(
69+
[
70+
f'{"-H" if use_short_options else "--header"} "{k}: {v}"'
71+
for k, v in headers.items()
72+
]
73+
)
74+
75+
# https://curl.se/docs/manpage.html#-d
76+
# -d, --data <data> HTTP POST data
77+
if body := request.read().decode():
78+
_d = "-d" if use_short_options else "--data"
79+
cmd.append(f"{_d} '{body}'")
80+
81+
cmd.append(f"{request.url}")
82+
83+
separator = " \\\n" if multiline else " "
84+
return separator.join(cmd)

packages/service-library/tests/fastapi/test_http_client.py

Lines changed: 2 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
from asgi_lifespan import LifespanManager
1414
from fastapi import FastAPI, status
1515
from models_library.healthchecks import IsResponsive
16-
from servicelib.fastapi.http_client import AppStateMixin, BaseHttpApi, to_curl_command
16+
from servicelib.fastapi.app_state import AppStateMixin
17+
from servicelib.fastapi.http_client import BaseHttpApi
1718

1819

1920
def test_using_app_state_mixin():
@@ -106,51 +107,3 @@ class MyClientApi(BaseHttpApi, AppStateMixin):
106107

107108
# shutdown event
108109
assert api.client.is_closed
109-
110-
111-
async def test_to_curl_command(mock_server_api: respx.MockRouter, base_url: str):
112-
113-
mock_server_api.post(path__startswith="/foo").respond(status.HTTP_200_OK)
114-
mock_server_api.get(path__startswith="/foo").respond(status.HTTP_200_OK)
115-
mock_server_api.delete(path__startswith="/foo").respond(status.HTTP_200_OK)
116-
117-
async with httpx.AsyncClient(base_url=base_url) as client:
118-
response = await client.post(
119-
"/foo",
120-
params={"x": "3"},
121-
json={"y": 12},
122-
headers={"x-secret": "this should not display"},
123-
)
124-
assert response.status_code == 200
125-
126-
cmd_short = to_curl_command(response.request)
127-
128-
assert (
129-
cmd_short
130-
== 'curl -X POST -H "host: test_base_http_api" -H "accept: */*" -H "accept-encoding: gzip, deflate" -H "connection: keep-alive" -H "user-agent: python-httpx/0.25.0" -H "x-secret: *****" -H "content-length: 9" -H "content-type: application/json" -d \'{"y": 12}\' https://test_base_http_api/foo?x=3'
131-
)
132-
133-
cmd_long = to_curl_command(response.request, use_short_options=False)
134-
assert cmd_long == cmd_short.replace("-X", "--request",).replace(
135-
"-H",
136-
"--header",
137-
).replace(
138-
"-d",
139-
"--data",
140-
)
141-
142-
# with GET
143-
response = await client.get("/foo", params={"x": "3"})
144-
cmd_long = to_curl_command(response.request)
145-
146-
assert (
147-
cmd_long
148-
== 'curl -X GET -H "host: test_base_http_api" -H "accept: */*" -H "accept-encoding: gzip, deflate" -H "connection: keep-alive" -H "user-agent: python-httpx/0.25.0" https://test_base_http_api/foo?x=3'
149-
)
150-
151-
# with DELETE
152-
response = await client.delete("/foo", params={"x": "3"})
153-
cmd_long = to_curl_command(response.request)
154-
155-
assert "DELETE" in cmd_long
156-
assert " -d " not in cmd_long
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
4+
# pylint: disable=unused-argument
5+
# pylint: disable=unused-variable
6+
7+
8+
import textwrap
9+
from collections.abc import AsyncIterator, Iterator
10+
11+
import httpx
12+
import pytest
13+
import respx
14+
from fastapi import status
15+
from httpx import AsyncClient
16+
from servicelib.fastapi.httpx_utils import to_curl_command, to_httpx_command
17+
18+
19+
@pytest.fixture
20+
def base_url() -> str:
21+
return "https://test_base_http_api"
22+
23+
24+
@pytest.fixture
25+
def mock_server_api(base_url: str) -> Iterator[respx.MockRouter]:
26+
with respx.mock(
27+
base_url=base_url,
28+
assert_all_called=False,
29+
assert_all_mocked=True, # IMPORTANT: KEEP always True!
30+
) as mock:
31+
mock.get("/").respond(status.HTTP_200_OK)
32+
33+
mock.post(path__startswith="/foo").respond(status.HTTP_200_OK)
34+
mock.get(path__startswith="/foo").respond(status.HTTP_200_OK)
35+
mock.delete(path__startswith="/foo").respond(status.HTTP_200_OK)
36+
37+
yield mock
38+
39+
40+
@pytest.fixture
41+
async def client(
42+
mock_server_api: respx.MockRouter, base_url: str
43+
) -> AsyncIterator[AsyncClient]:
44+
async with httpx.AsyncClient(base_url=base_url) as client:
45+
46+
yield client
47+
48+
49+
async def test_to_curl_command(client: AsyncClient):
50+
51+
# with POST
52+
response = await client.post(
53+
"/foo",
54+
params={"x": "3"},
55+
json={"y": 12},
56+
headers={"x-secret": "this should not display"},
57+
)
58+
assert response.status_code == 200
59+
60+
cmd_short = to_curl_command(response.request)
61+
62+
assert (
63+
cmd_short
64+
== 'curl -X POST -H "host: test_base_http_api" -H "accept: */*" -H "accept-encoding: gzip, deflate" -H "connection: keep-alive" -H "user-agent: python-httpx/0.25.0" -H "x-secret: *****" -H "content-length: 9" -H "content-type: application/json" -d \'{"y": 12}\' https://test_base_http_api/foo?x=3'
65+
)
66+
67+
cmd_long = to_curl_command(response.request, use_short_options=False)
68+
assert cmd_long == cmd_short.replace("-X", "--request",).replace(
69+
"-H",
70+
"--header",
71+
).replace(
72+
"-d",
73+
"--data",
74+
)
75+
76+
# with GET
77+
response = await client.get("/foo", params={"x": "3"})
78+
cmd_multiline = to_curl_command(response.request, multiline=True)
79+
80+
assert (
81+
cmd_multiline
82+
== textwrap.dedent(
83+
"""\
84+
curl \\
85+
-X GET \\
86+
-H "host: test_base_http_api" \\
87+
-H "accept: */*" \\
88+
-H "accept-encoding: gzip, deflate" \\
89+
-H "connection: keep-alive" \\
90+
-H "user-agent: python-httpx/0.25.0" \\
91+
https://test_base_http_api/foo?x=3
92+
"""
93+
).strip()
94+
)
95+
96+
# with DELETE
97+
response = await client.delete("/foo", params={"x": "3"})
98+
cmd = to_curl_command(response.request)
99+
100+
assert "DELETE" in cmd
101+
assert " -d " not in cmd
102+
103+
104+
async def test_to_httpx_command(client: AsyncClient):
105+
response = await client.post(
106+
"/foo",
107+
params={"x": "3"},
108+
json={"y": 12},
109+
headers={"x-secret": "this should not display"},
110+
)
111+
112+
cmd_short = to_httpx_command(response.request, multiline=False)
113+
114+
print(cmd_short)
115+
assert (
116+
cmd_short
117+
== 'httpx -m POST -c \'{"y": 12}\' -h "host" "test_base_http_api" -h "accept" "*/*" -h "accept-encoding" "gzip, deflate" -h "connection" "keep-alive" -h "user-agent" "python-httpx/0.25.0" -h "x-secret" "*****" -h "content-length" "9" -h "content-type" "application/json" https://test_base_http_api/foo?x=3'
118+
)

0 commit comments

Comments
 (0)