Skip to content

Commit 6ec6952

Browse files
committed
upgrade libs, move mug to own module
1 parent a58855c commit 6ec6952

3 files changed

Lines changed: 336 additions & 40 deletions

File tree

taskbadger/internal/client.py

Lines changed: 242 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,268 @@
11
import ssl
2-
from typing import Dict, Union
2+
from typing import Any, Dict, Optional, Union
33

4-
import attr
4+
import httpx
5+
from attrs import define, evolve, field
56

67

7-
@attr.s(auto_attribs=True)
8+
@define
89
class Client:
910
"""A class for keeping track of data related to the API
1011
12+
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
13+
14+
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
15+
16+
``cookies``: A dictionary of cookies to be sent with every request
17+
18+
``headers``: A dictionary of headers to be sent with every request
19+
20+
``timeout``: The maximum amount of a time a request can take. API functions will raise
21+
httpx.TimeoutException if this is exceeded.
22+
23+
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
24+
but can be set to False for testing purposes.
25+
26+
``follow_redirects``: Whether or not to follow redirects. Default value is False.
27+
28+
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
29+
30+
1131
Attributes:
12-
base_url: The base URL for the API, all requests are made to a relative path to this URL
13-
cookies: A dictionary of cookies to be sent with every request
14-
headers: A dictionary of headers to be sent with every request
15-
timeout: The maximum amount of a time in seconds a request can take. API functions will raise
16-
httpx.TimeoutException if this is exceeded.
17-
verify_ssl: Whether or not to verify the SSL certificate of the API server. This should be True in production,
18-
but can be set to False for testing purposes.
1932
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
20-
status code that was not documented in the source OpenAPI document.
21-
follow_redirects: Whether or not to follow redirects. Default value is False.
33+
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
34+
argument to the constructor.
2235
"""
2336

24-
base_url: str
25-
cookies: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
26-
headers: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
27-
timeout: float = attr.ib(5.0, kw_only=True)
28-
verify_ssl: Union[str, bool, ssl.SSLContext] = attr.ib(True, kw_only=True)
29-
raise_on_unexpected_status: bool = attr.ib(False, kw_only=True)
30-
follow_redirects: bool = attr.ib(False, kw_only=True)
31-
32-
def get_headers(self) -> Dict[str, str]:
33-
"""Get headers to be used in all endpoints"""
34-
return {**self.headers}
37+
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
38+
_base_url: str
39+
_cookies: Dict[str, str] = field(factory=dict, kw_only=True)
40+
_headers: Dict[str, str] = field(factory=dict, kw_only=True)
41+
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True)
42+
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True)
43+
_follow_redirects: bool = field(default=False, kw_only=True)
44+
_httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True)
45+
_client: Optional[httpx.Client] = field(default=None, init=False)
46+
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
3547

3648
def with_headers(self, headers: Dict[str, str]) -> "Client":
3749
"""Get a new client matching this one with additional headers"""
38-
return attr.evolve(self, headers={**self.headers, **headers})
39-
40-
def get_cookies(self) -> Dict[str, str]:
41-
return {**self.cookies}
50+
if self._client is not None:
51+
self._client.headers.update(headers)
52+
if self._async_client is not None:
53+
self._async_client.headers.update(headers)
54+
return evolve(self, headers={**self._headers, **headers})
4255

4356
def with_cookies(self, cookies: Dict[str, str]) -> "Client":
4457
"""Get a new client matching this one with additional cookies"""
45-
return attr.evolve(self, cookies={**self.cookies, **cookies})
46-
47-
def get_timeout(self) -> float:
48-
return self.timeout
58+
if self._client is not None:
59+
self._client.cookies.update(cookies)
60+
if self._async_client is not None:
61+
self._async_client.cookies.update(cookies)
62+
return evolve(self, cookies={**self._cookies, **cookies})
4963

50-
def with_timeout(self, timeout: float) -> "Client":
64+
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
5165
"""Get a new client matching this one with a new timeout (in seconds)"""
52-
return attr.evolve(self, timeout=timeout)
66+
if self._client is not None:
67+
self._client.timeout = timeout
68+
if self._async_client is not None:
69+
self._async_client.timeout = timeout
70+
return evolve(self, timeout=timeout)
71+
72+
def set_httpx_client(self, client: httpx.Client) -> "Client":
73+
"""Manually the underlying httpx.Client
74+
75+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
76+
"""
77+
self._client = client
78+
return self
79+
80+
def get_httpx_client(self) -> httpx.Client:
81+
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
82+
if self._client is None:
83+
self._client = httpx.Client(
84+
base_url=self._base_url,
85+
cookies=self._cookies,
86+
headers=self._headers,
87+
timeout=self._timeout,
88+
verify=self._verify_ssl,
89+
follow_redirects=self._follow_redirects,
90+
**self._httpx_args,
91+
)
92+
return self._client
93+
94+
def __enter__(self) -> "Client":
95+
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
96+
self.get_httpx_client().__enter__()
97+
return self
98+
99+
def __exit__(self, *args: Any, **kwargs: Any) -> None:
100+
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
101+
self.get_httpx_client().__exit__(*args, **kwargs)
102+
103+
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
104+
"""Manually the underlying httpx.AsyncClient
105+
106+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
107+
"""
108+
self._async_client = async_client
109+
return self
110+
111+
def get_async_httpx_client(self) -> httpx.AsyncClient:
112+
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
113+
if self._async_client is None:
114+
self._async_client = httpx.AsyncClient(
115+
base_url=self._base_url,
116+
cookies=self._cookies,
117+
headers=self._headers,
118+
timeout=self._timeout,
119+
verify=self._verify_ssl,
120+
follow_redirects=self._follow_redirects,
121+
**self._httpx_args,
122+
)
123+
return self._async_client
124+
125+
async def __aenter__(self) -> "Client":
126+
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
127+
await self.get_async_httpx_client().__aenter__()
128+
return self
129+
130+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
131+
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
132+
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
133+
134+
135+
@define
136+
class AuthenticatedClient:
137+
"""A Client which has been authenticated for use on secured endpoints
138+
139+
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
140+
141+
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
53142
143+
``cookies``: A dictionary of cookies to be sent with every request
54144
55-
@attr.s(auto_attribs=True)
56-
class AuthenticatedClient(Client):
57-
"""A Client which has been authenticated for use on secured endpoints"""
145+
``headers``: A dictionary of headers to be sent with every request
146+
147+
``timeout``: The maximum amount of a time a request can take. API functions will raise
148+
httpx.TimeoutException if this is exceeded.
149+
150+
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
151+
but can be set to False for testing purposes.
152+
153+
``follow_redirects``: Whether or not to follow redirects. Default value is False.
154+
155+
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
156+
157+
158+
Attributes:
159+
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
160+
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
161+
argument to the constructor.
162+
token: The token to use for authentication
163+
prefix: The prefix to use for the Authorization header
164+
auth_header_name: The name of the Authorization header
165+
"""
166+
167+
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
168+
_base_url: str
169+
_cookies: Dict[str, str] = field(factory=dict, kw_only=True)
170+
_headers: Dict[str, str] = field(factory=dict, kw_only=True)
171+
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True)
172+
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True)
173+
_follow_redirects: bool = field(default=False, kw_only=True)
174+
_httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True)
175+
_client: Optional[httpx.Client] = field(default=None, init=False)
176+
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
58177

59178
token: str
60179
prefix: str = "Bearer"
61180
auth_header_name: str = "Authorization"
62181

63-
def get_headers(self) -> Dict[str, str]:
64-
"""Get headers to be used in authenticated endpoints"""
65-
auth_header_value = f"{self.prefix} {self.token}" if self.prefix else self.token
66-
return {self.auth_header_name: auth_header_value, **self.headers}
182+
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
183+
"""Get a new client matching this one with additional headers"""
184+
if self._client is not None:
185+
self._client.headers.update(headers)
186+
if self._async_client is not None:
187+
self._async_client.headers.update(headers)
188+
return evolve(self, headers={**self._headers, **headers})
189+
190+
def with_cookies(self, cookies: Dict[str, str]) -> "AuthenticatedClient":
191+
"""Get a new client matching this one with additional cookies"""
192+
if self._client is not None:
193+
self._client.cookies.update(cookies)
194+
if self._async_client is not None:
195+
self._async_client.cookies.update(cookies)
196+
return evolve(self, cookies={**self._cookies, **cookies})
197+
198+
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
199+
"""Get a new client matching this one with a new timeout (in seconds)"""
200+
if self._client is not None:
201+
self._client.timeout = timeout
202+
if self._async_client is not None:
203+
self._async_client.timeout = timeout
204+
return evolve(self, timeout=timeout)
205+
206+
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
207+
"""Manually the underlying httpx.Client
208+
209+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
210+
"""
211+
self._client = client
212+
return self
213+
214+
def get_httpx_client(self) -> httpx.Client:
215+
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
216+
if self._client is None:
217+
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
218+
self._client = httpx.Client(
219+
base_url=self._base_url,
220+
cookies=self._cookies,
221+
headers=self._headers,
222+
timeout=self._timeout,
223+
verify=self._verify_ssl,
224+
follow_redirects=self._follow_redirects,
225+
**self._httpx_args,
226+
)
227+
return self._client
228+
229+
def __enter__(self) -> "AuthenticatedClient":
230+
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
231+
self.get_httpx_client().__enter__()
232+
return self
233+
234+
def __exit__(self, *args: Any, **kwargs: Any) -> None:
235+
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
236+
self.get_httpx_client().__exit__(*args, **kwargs)
237+
238+
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient":
239+
"""Manually the underlying httpx.AsyncClient
240+
241+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
242+
"""
243+
self._async_client = async_client
244+
return self
245+
246+
def get_async_httpx_client(self) -> httpx.AsyncClient:
247+
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
248+
if self._async_client is None:
249+
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
250+
self._async_client = httpx.AsyncClient(
251+
base_url=self._base_url,
252+
cookies=self._cookies,
253+
headers=self._headers,
254+
timeout=self._timeout,
255+
verify=self._verify_ssl,
256+
follow_redirects=self._follow_redirects,
257+
**self._httpx_args,
258+
)
259+
return self._async_client
260+
261+
async def __aenter__(self) -> "AuthenticatedClient":
262+
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
263+
await self.get_async_httpx_client().__aenter__()
264+
return self
265+
266+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
267+
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
268+
await self.get_async_httpx_client().__aexit__(*args, **kwargs)

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import pytest
2+
3+
from taskbadger.mug import Badger, Settings
4+
5+
6+
@pytest.fixture
7+
def bind_settings():
8+
Badger.current.bind(Settings("https://taskbadger.net", "token", "org", "proj"))
9+
yield
10+
Badger.current.bind(None)

0 commit comments

Comments
 (0)