11import base64
22import ssl
33import warnings
4- from typing import Dict, Union
4+ from typing import Any, Dict, Optional, Union
5+
6+ import httpx
7+ from attrs import evolve
58
69
710class Client:
8- """A class for keeping track of data related to the API
11+ """A Client which has been authenticated for use on secured endpoints
12+
13+ The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
14+
15+ ``base_url``: The base URL for the API, all requests are made to a relative path to this URL
16+
17+ ``cookies``: A dictionary of cookies to be sent with every request
18+
19+ ``headers``: A dictionary of headers to be sent with every request
20+
21+ ``timeout``: The maximum amount of a time a request can take. API functions will raise
22+ httpx.TimeoutException if this is exceeded.
23+
24+ ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
25+ but can be set to False for testing purposes.
26+
27+ ``follow_redirects``: Whether or not to follow redirects. Default value is False.
28+
29+ ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
30+
931
1032 Attributes:
11- cookies: A dictionary of cookies to be sent with every request
12- headers: A dictionary of headers to be sent with every request
13- timeout: The maximum amount of a time in seconds a request can take. API functions will raise
14- httpx.TimeoutException if this is exceeded.
15- verify_ssl: Whether or not to verify the SSL certificate of the API server. This should be True in production,
16- but can be set to False for testing purposes.
1733 raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
18- status code that was not documented in the source OpenAPI document.
19- http2: Whether or not to use http2, enabled by default.
34+ status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
35+ argument to the constructor.
36+ token: The token to use for authentication
37+ prefix: The prefix to use for the Authorization header
38+ auth_header_name: The name of the Authorization header
2039 """
2140
22- cookies = {}
41+ raise_on_unexpected_status: bool
42+ _base_url: str = "https://ftc-api.firstinspires.org"
43+ cookies: Dict[str, str] = {}
2344 headers: Dict[str, str] = {}
24- timeout: float = 5.0
45+ timeout: Optional[httpx.Timeout] = None
2546 verify_ssl: Union[str, bool, ssl.SSLContext] = True
26- raise_on_unexpected_status: bool = False
47+ follow_redirects: bool = False
48+ httpx_args: Dict[str, Any] = {}
49+ _client: Optional[httpx.Client] = None
50+ _async_client: Optional[httpx.AsyncClient] = None
51+
52+ token: str
2753 prefix: str = "Basic"
2854 auth_header_name: str = "Authorization"
29- http2 = True
3055
3156 def __init__(self, token=None, username="", authorization_key=""):
3257 if token is not None:
@@ -37,19 +62,98 @@ class Client:
3762 base64_bytes = base64.b64encode(token_bytes)
3863 self.token = base64_bytes.decode("ascii")
3964
40- def get_headers(self) -> Dict[str, str]:
41- auth_header_value = f"{self.prefix} {self.token}" if self.prefix else self.token
42- """Get headers to be used in authenticated endpoints"""
43- return {self.auth_header_name: auth_header_value, **self.headers}
65+ def with_headers(self, headers: Dict[str, str]) -> "Client":
66+ """Get a new client matching this one with additional headers"""
67+ if self._client is not None:
68+ self._client.headers.update(headers)
69+ if self._async_client is not None:
70+ self._async_client.headers.update(headers)
71+ return evolve(self, headers={**self.headers, **headers})
4472
45- def get_cookies(self) -> Dict[str, str]:
46- return {**self.cookies}
73+ def with_cookies(self, cookies: Dict[str, str]) -> "Client":
74+ """Get a new client matching this one with additional cookies"""
75+ if self._client is not None:
76+ self._client.cookies.update(cookies)
77+ if self._async_client is not None:
78+ self._async_client.cookies.update(cookies)
79+ return evolve(self, cookies={**self.cookies, **cookies})
4780
48- def get_timeout(self) -> float:
49- return self.timeout
81+ def with_timeout(self, timeout: httpx.Timeout) -> "Client":
82+ """Get a new client matching this one with a new timeout (in seconds)"""
83+ if self._client is not None:
84+ self._client.timeout = timeout
85+ if self._async_client is not None:
86+ self._async_client.timeout = timeout
87+ return evolve(self, timeout=timeout)
88+
89+ def set_httpx_client(self, client: httpx.Client) -> "Client":
90+ """Manually the underlying httpx.Client
91+
92+ **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
93+ """
94+ self._client = client
95+ return self
96+
97+ def get_httpx_client(self) -> httpx.Client:
98+ """Get the underlying httpx.Client, constructing a new one if not previously set"""
99+ if self._client is None:
100+ self.headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
101+ self._client = httpx.Client(
102+ base_url=self._base_url,
103+ cookies=self.cookies,
104+ headers=self.headers,
105+ timeout=self.timeout,
106+ verify=self.verify_ssl,
107+ follow_redirects=self.follow_redirects,
108+ **self.httpx_args,
109+ )
110+ return self._client
111+
112+ def __enter__(self) -> "Client":
113+ """Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
114+ self.get_httpx_client().__enter__()
115+ return self
116+
117+ def __exit__(self, *args: Any, **kwargs: Any) -> None:
118+ """Exit a context manager for internal httpx.Client (see httpx docs)"""
119+ self.get_httpx_client().__exit__(*args, **kwargs)
120+
121+ def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
122+ """Manually the underlying httpx.AsyncClient
123+
124+ **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
125+ """
126+ self._async_client = async_client
127+ return self
128+
129+ def get_async_httpx_client(self) -> httpx.AsyncClient:
130+ """Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
131+ if self._async_client is None:
132+ self.headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
133+ self._async_client = httpx.AsyncClient(
134+ base_url=self._base_url,
135+ cookies=self.cookies,
136+ headers=self.headers,
137+ timeout=self.timeout,
138+ verify=self.verify_ssl,
139+ follow_redirects=self.follow_redirects,
140+ **self.httpx_args,
141+ )
142+ return self._async_client
143+
144+ async def __aenter__(self) -> "Client":
145+ """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
146+ await self.get_async_httpx_client().__aenter__()
147+ return self
148+
149+ async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
150+ """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
151+ await self.get_async_httpx_client().__aexit__(*args, **kwargs)
50152
51153
52154class AuthenticatedClient(Client):
53155 """Deprecated, use Client instead, as it has equivalent functionality, will be removed v1.0.0"""
54- warnings.warn("Will be removed v1.0.0 switch to Client because the functionality has been merged.",
55- DeprecationWarning)
156+
157+ warnings.warn(
158+ "Will be removed v1.0.0 switch to Client because the functionality has been merged.", DeprecationWarning
159+ )
0 commit comments