earthscope-sdk 0.2.0__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. earthscope_sdk/__init__.py +5 -1
  2. earthscope_sdk/auth/auth_flow.py +240 -346
  3. earthscope_sdk/auth/client_credentials_flow.py +42 -162
  4. earthscope_sdk/auth/device_code_flow.py +169 -213
  5. earthscope_sdk/auth/error.py +46 -0
  6. earthscope_sdk/client/__init__.py +3 -0
  7. earthscope_sdk/client/_client.py +35 -0
  8. earthscope_sdk/client/user/_base.py +39 -0
  9. earthscope_sdk/client/user/_service.py +94 -0
  10. earthscope_sdk/client/user/models.py +53 -0
  11. earthscope_sdk/common/__init__.py +0 -0
  12. earthscope_sdk/common/_sync_runner.py +141 -0
  13. earthscope_sdk/common/client.py +99 -0
  14. earthscope_sdk/common/context.py +174 -0
  15. earthscope_sdk/common/service.py +59 -0
  16. earthscope_sdk/config/__init__.py +0 -0
  17. earthscope_sdk/config/_bootstrap.py +42 -0
  18. earthscope_sdk/config/_compat.py +148 -0
  19. earthscope_sdk/config/_util.py +48 -0
  20. earthscope_sdk/config/error.py +4 -0
  21. earthscope_sdk/config/models.py +310 -0
  22. earthscope_sdk/config/settings.py +295 -0
  23. earthscope_sdk/model/secret.py +29 -0
  24. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/METADATA +147 -123
  25. earthscope_sdk-1.0.0.dist-info/RECORD +30 -0
  26. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/WHEEL +1 -1
  27. earthscope_sdk/user/user.py +0 -32
  28. earthscope_sdk-0.2.0.dist-info/RECORD +0 -12
  29. /earthscope_sdk/{user → client/user}/__init__.py +0 -0
  30. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info/licenses}/LICENSE +0 -0
  31. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,190 +1,70 @@
1
- from dataclasses import dataclass
2
- from pathlib import Path
3
- import json
4
1
  import logging
5
- import requests
2
+ from json import JSONDecodeError
6
3
 
7
- from earthscope_sdk.auth.auth_flow import (
8
- AuthFlow,
9
- AuthFlowError,
10
- NoTokensError,
11
- UnauthorizedError,
12
- ValidTokens
13
- )
4
+ from earthscope_sdk.auth.auth_flow import AuthFlow
5
+ from earthscope_sdk.auth.error import ClientCredentialsFlowError, UnauthorizedError
6
+ from earthscope_sdk.common.context import SdkContext
14
7
 
15
8
  logger = logging.getLogger(__name__)
16
9
 
17
10
 
18
- class ClientCredentialsFlowError(AuthFlowError):
19
- pass
20
-
21
-
22
- @dataclass
23
- class ClientCredentials:
24
- client_id: str
25
- client_secret: str
26
-
27
-
28
- @dataclass
29
- class GetTokensErrorResponse:
30
- error: str
31
- error_description: str
32
-
33
-
34
11
  class ClientCredentialsFlow(AuthFlow):
35
12
  """
36
- This is an abstract subclass of the AuthFlow abstract class. This class handles the client credential flow actions.
37
-
38
- Attributes
39
- __________
40
- audience : str
41
- Auth0 API Identifier (default is 'https://account.earthscope.org')
42
- domain : str
43
- Auth0 tenant domain URL or custom domain (default is 'login.earthscope.org')
44
- client_credentials: ClientCredentials
45
- The client credentials - client id and client secret
46
- _secret: str
47
- The client secret of the machine-to-machine application from the client_credentials
48
-
49
- Methods
50
- -------
51
- request_tokens
52
- Request the token from Auth0. Validates and saves the token locally.
13
+ Implements the oauth2 Client Credentials "machine-to-machine" (M2M) flow.
53
14
  """
54
- def __init__(
55
- self, domain: str, audience: str, client_credentials: ClientCredentials
56
- ) -> None:
57
- if not client_credentials or not client_credentials.client_secret:
58
- raise ValueError("Client secret missing")
59
15
 
60
- super().__init__(
61
- domain=domain,
62
- audience=audience,
63
- client_id=client_credentials.client_id,
64
- scope="",
65
- )
66
- # Flow management vars
67
- self._secret = client_credentials.client_secret
16
+ def __init__(self, ctx: SdkContext):
17
+ if not ctx.settings.oauth2.client_secret:
18
+ raise ValueError("Client secret required for client credentials flow")
68
19
 
69
- def get_token_or_request_if_expired(self):
70
- """
71
- Loads the access token if it is currently saved locally.
72
- Requests the token if the token is expired or if no token is found locally
73
- """
74
- try:
75
- self.load_tokens()
76
- if self.ttl.total_seconds() <= 0:
77
- self.request_tokens()
20
+ super().__init__(ctx=ctx)
78
21
 
79
- except NoTokensError:
80
- self.request_tokens()
22
+ # httpx.Auth objects can be used in either sync or async clients so we
23
+ # facilitate both from the same class
24
+ self.request_tokens = ctx.syncify(self.async_request_tokens)
81
25
 
82
- return self.access_token
26
+ async def async_refresh(self, *args, **kwargs):
27
+ # alias for self.request_tokens() so that base class can get new tokens automagically
28
+ return await self.async_request_tokens()
83
29
 
84
- def request_tokens(self):
30
+ async def async_request_tokens(self):
85
31
  """
86
- Request the token from Auth0. Validates and saves the token locally.
32
+ Request access token from IdP
87
33
 
88
- Raises
89
- ------
90
- Unauthorized Error
91
- If the request returns as unauthorized
92
- ClientCredentialsFlowError
93
- If there is an error in the clientcredentials flow other than unauthorized
34
+ Raises:
35
+ Unauthorized Error: the request returns as unauthorized
36
+ ClientCredentialsFlowError: unhandled error in the client credentials flow
94
37
  """
95
- r = requests.post(
96
- f"https://{self.auth0_domain}/oauth/token",
38
+ r = await self._ctx.httpx_client.post(
39
+ f"{self._settings.domain}oauth/token",
40
+ auth=None, # override client default
97
41
  headers={"content-type": "application/x-www-form-urlencoded"},
98
42
  data={
99
43
  "grant_type": "client_credentials",
100
- "client_id": self.auth0_client_id,
101
- "client_secret": self._secret,
102
- "audience": self.auth0_audience,
44
+ "client_id": self._settings.client_id,
45
+ "client_secret": self._settings.client_secret.get_secret_value(),
46
+ "audience": self._settings.audience,
103
47
  },
104
48
  )
49
+ try:
50
+ resp: dict = r.json()
51
+ except JSONDecodeError:
52
+ raise ClientCredentialsFlowError(
53
+ f"Unable to unpack IdP response: {r.content}"
54
+ )
55
+
56
+ # Success!
105
57
  if r.status_code == 200:
106
- self.validate_and_save_tokens(r.json())
58
+ self._validate_and_save_tokens(resp)
59
+
107
60
  logger.debug(f"Got tokens: {self.tokens}")
108
61
  return self
109
62
 
63
+ # Unauthorized
110
64
  if r.status_code == 401:
111
- err = GetTokensErrorResponse(**r.json())
112
- if err.error == "access_denied":
113
- if err.error_description == "Unauthorized":
114
- raise UnauthorizedError
115
-
116
- raise ClientCredentialsFlowError
65
+ raise UnauthorizedError(
66
+ f"m2m client '{self._settings.client_id}' is not authorized. IdP response: {resp}"
67
+ )
117
68
 
118
-
119
- class ClientCredentialsFlowSimple(ClientCredentialsFlow):
120
- """
121
- A simple concrete implementation of the ClientCredentialsFlow class. This will store and load tokens on your filesystem
122
-
123
- Attributes:
124
- -----------
125
- audience : str, optional
126
- Auth0 API Identifier (default is 'https://account.earthscope.org')
127
- client_credentials: ClientCredentials
128
- the client credentials
129
- domain : str, optional
130
- Auth0 tenant domain URL or custom domain (default is 'login.earthscope.org')
131
- path : str
132
- The path where your access token will be stored and read
133
-
134
- Methods
135
- -------
136
- load_tokens
137
- Loads and validates your access token from the location specified by the given path.
138
- save_tokens(creds)
139
- Saves your access token to the location specified by the given path.
140
- """
141
- def __init__(
142
- self,
143
- client_id: str,
144
- client_secret: str,
145
- path: str,
146
- domain: str = "login.earthscope.org",
147
- audience: str = "https://account.earthscope.org",
148
- ) -> None:
149
- super().__init__(
150
- domain=domain,
151
- audience=audience,
152
- client_credentials=ClientCredentials(
153
- client_id=client_id, client_secret=client_secret
154
- ),
155
- )
156
- self.path = Path(path)
157
-
158
- def load_tokens(self):
159
- """
160
- Loads and validates the access token from the location specified by the given path.
161
-
162
- Raises
163
- ------
164
- NoTokensError
165
- If no token is found
166
- Returns
167
- _______
168
- valid token : str
169
- """
170
- try:
171
- with self.path.open() as f:
172
- json_state = json.load(f)
173
- except FileNotFoundError:
174
- raise NoTokensError
175
-
176
- self.validate_tokens(json_state)
177
- return self.tokens
178
-
179
- def save_tokens(self, creds: ValidTokens):
180
- """
181
- Writes the token to the location specified by the given path in json format.
182
-
183
- Parameters
184
- ----------
185
- creds: ValidTokens
186
- token credentials
187
- """
188
- json_str_state = json.dumps(vars(creds))
189
- with self.path.open("w") as f:
190
- f.write(json_str_state)
69
+ # Unhandled
70
+ raise ClientCredentialsFlowError("client credentials flow failed", r.content)
@@ -1,16 +1,20 @@
1
- import json
2
1
  import logging
3
- import requests
4
-
5
- from abc import abstractmethod
6
- from dataclasses import dataclass
2
+ from asyncio import sleep
3
+ from contextlib import asynccontextmanager, contextmanager
7
4
  from enum import Enum
8
- from pathlib import Path
9
- from time import sleep
5
+ from json import JSONDecodeError
10
6
  from typing import Optional
11
7
 
12
- from earthscope_sdk.auth.auth_flow import AuthFlow, NoTokensError, ValidTokens
8
+ from pydantic import BaseModel, ValidationError
13
9
 
10
+ from earthscope_sdk.auth.auth_flow import AuthFlow
11
+ from earthscope_sdk.auth.error import (
12
+ DeviceCodePollingError,
13
+ DeviceCodePollingExpiredError,
14
+ DeviceCodeRequestDeviceCodeError,
15
+ UnauthorizedError,
16
+ )
17
+ from earthscope_sdk.common.context import SdkContext
14
18
 
15
19
  logger = logging.getLogger(__name__)
16
20
 
@@ -22,273 +26,225 @@ class PollingErrorType(str, Enum):
22
26
  ACCESS_DENIED = "access_denied"
23
27
 
24
28
 
25
- class RequestDeviceTokensError(RuntimeError):
26
- pass
27
-
28
-
29
- class PollingError(ValueError):
30
- pass
31
-
32
-
33
- class PollingExpiredError(PollingError):
34
- pass
35
-
36
-
37
- class PollingAccessDeniedError(PollingError):
38
- pass
39
-
40
-
41
- @dataclass
42
- class GetDeviceCodeResponse:
29
+ class GetDeviceCodeResponse(BaseModel):
43
30
  device_code: str
44
31
  user_code: str
45
32
  verification_uri: str
46
33
  verification_uri_complete: str
47
34
  expires_in: int
48
- interval: int
35
+ interval: float
49
36
 
50
37
 
51
- @dataclass
52
- class PollingErrorResponse:
38
+ class PollingErrorResponse(BaseModel):
53
39
  error: PollingErrorType
54
40
  error_description: str
55
41
 
56
42
 
57
43
  class DeviceCodeFlow(AuthFlow):
58
44
  """
59
- This is an abstract subclass of the AuthFlow abstract class. This class handles the device code flow actions.
60
-
61
- Atttributes
62
- -----------
63
- domain : str
64
- Auth0 tenant domain URL or custom domain
65
- audience : str
66
- Auth0 API Identifier
67
- client_id : str
68
- Identification value of Auth0 Application
69
- scope : str
70
- The specific actions Auth0 applications can be allowed to do or information that they can request on a user’s behalf
71
- _is_polling: bool, optional
72
- True if currently polling (default is False)
73
- _device_codes, optional
74
- The device code (default is None)
75
-
76
- Methods
77
- -------
78
- do_flow
79
- Requests the device code, prompts the user to complete the device code flow, polls while waiting for completion
80
- and returns the token
81
- poll
82
- Polls for completion of the device code flow
83
- prompt_user
84
- abstract method for prompting the user to complette the device code flow
85
- request_device_code
86
- requests the device code from Auth0
45
+ Implements the oauth2 Device Code flow.
87
46
  """
47
+
88
48
  @property
89
49
  def polling(self):
90
- "If is in proccess of polling"
50
+ """
51
+ This instance is in the process of polling for tokens
52
+ """
91
53
  return self._is_polling
92
54
 
93
- @property
94
- def started(self):
95
- "Returns a boolean on if the device code flow has started"
96
- return self._device_codes is not None
97
-
98
- def __init__(self, domain: str, audience: str, client_id: str, scope: str) -> None:
99
- super().__init__(
100
- domain=domain,
101
- audience=audience,
102
- client_id=client_id,
103
- scope=scope,
104
- )
55
+ def __init__(self, ctx: SdkContext):
56
+ if ctx.settings.oauth2.client_secret:
57
+ raise ValueError("Client secret should not be used with device code flow")
58
+
59
+ super().__init__(ctx=ctx)
60
+
105
61
  # Flow management vars
106
62
  self._is_polling = False
107
- self._device_codes: Optional[GetDeviceCodeResponse] = None
108
63
 
109
- def do_flow(self):
110
- """
111
- Runs request_device_code, prompt_user, and poll methods. This is the device code flow "login" process.
64
+ # httpx.Auth objects can be used in either sync or async clients so we
65
+ # facilitate both from the same class
66
+ self._poll = ctx.syncify(self._async_poll)
67
+ self._request_device_code = ctx.syncify(self._async_request_device_code)
112
68
 
113
- Returns
114
- -------
115
- token
69
+ @asynccontextmanager
70
+ async def async_do_flow(self, scope: Optional[str] = None):
71
+ """
72
+ Perform the oauth2 Device Code flow:
73
+ - requests device code
74
+ - yields the device code to the caller for them to prompt the user
75
+ - polls for access token
76
+
77
+ Args:
78
+ scope: the specific oauth2 scopes to request
79
+
80
+ Yields:
81
+ GetDeviceCodeResponse: the device code response that must be shown to the user
82
+ to facilitate a login.
83
+
84
+ Raises:
85
+ DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
86
+ DeviceCodePollingExpiredError: the polling token expired
87
+ UnauthorizedError: access denied
88
+ DeviceCodePollingError: unhandled polling error
89
+
90
+ Examples:
91
+ >>> async with device_flow.async_do_flow() as codes:
92
+ ... # prompt the user to login using the device code
93
+ ... print(f"Open the following URL in a web browser to login: {codes.verification_uri_complete}")
116
94
  """
117
- self.request_device_code()
118
- self.prompt_user()
119
- self.poll()
120
- return self.tokens
95
+ # NOTE: if you update this method, also update the synchronous version
96
+
97
+ codes = await self._async_request_device_code(scope=scope)
98
+
99
+ # Yield codes to facilitate prompting the user
100
+ yield codes
101
+
102
+ await self._async_poll(codes=codes)
121
103
 
122
- def poll(self):
104
+ @contextmanager
105
+ def do_flow(self, scope: Optional[str] = None):
123
106
  """
124
- Polling auth0 for token. Sets self._is_polling.
107
+ Perform the oauth2 Device Code flow:
108
+ - requests device code
109
+ - yields the device code to the caller for them to prompt the user
110
+ - polls for access token
111
+
112
+ Args:
113
+ scope: the specific oauth2 scopes to request
114
+
115
+ Yields:
116
+ GetDeviceCodeResponse: the device code response that must be shown to the user
117
+ to facilitate a login.
118
+
119
+ Raises:
120
+ DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
121
+ DeviceCodePollingExpiredError: the polling token expired
122
+ UnauthorizedError: access denied
123
+ DeviceCodePollingError: unhandled polling error
124
+
125
+ Examples:
126
+ >>> with device_flow.do_flow() as codes:
127
+ ... # prompt the user to login using the device code
128
+ ... print(f"Open the following URL in a web browser to login: {codes.verification_uri_complete}")
125
129
  """
126
- if not self._device_codes:
127
- raise PollingError("Cannot poll without initial device code response")
130
+ # NOTE: we explicitly redefine this sync method because ctx.syncify()
131
+ # does not support generators
132
+
133
+ codes = self._request_device_code(scope=scope)
134
+
135
+ # Yield codes to facilitate prompting the user
136
+ yield codes
137
+
138
+ self._poll(codes=codes)
139
+
140
+ async def _async_poll(self, codes: GetDeviceCodeResponse):
141
+ """
142
+ Polling IdP for tokens.
143
+
144
+ Args:
145
+ codes: device code response from IdP after starting the flow
128
146
 
147
+ Raises:
148
+ DeviceCodePollingExpiredError: the polling token expired
149
+ UnauthorizedError: access denied
150
+ DeviceCodePollingError: unhandled polling error
151
+ """
129
152
  if self._is_polling:
130
- raise PollingError("Attempted to double poll")
153
+ raise DeviceCodePollingError("Polling is already in progress")
131
154
 
132
155
  self._is_polling = True
133
156
  try:
134
157
  while True:
135
- sleep(self._device_codes.interval)
158
+ # IdP-provided poll interval
159
+ await sleep(codes.interval)
136
160
 
137
- r = requests.post(
138
- f"https://{self.auth0_domain}/oauth/token",
161
+ r = await self._ctx.httpx_client.post(
162
+ f"{self._settings.domain}oauth/token",
163
+ auth=None, # override client default
139
164
  headers={"content-type": "application/x-www-form-urlencoded"},
140
165
  data={
141
166
  "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
142
- "device_code": self._device_codes.device_code,
143
- "client_id": self.auth0_client_id,
167
+ "device_code": codes.device_code,
168
+ "client_id": self._settings.client_id,
144
169
  },
145
170
  )
171
+
172
+ try:
173
+ resp: dict = r.json()
174
+ except JSONDecodeError:
175
+ raise DeviceCodePollingError(
176
+ f"Unable to unpack IdP response: {r.content}"
177
+ )
178
+
179
+ # Success!
146
180
  if r.status_code == 200:
147
- tokens = self.validate_and_save_tokens(r.json())
148
- logger.debug(f"Got tokens: {tokens}")
181
+ self._validate_and_save_tokens(resp)
182
+
183
+ logger.debug(f"Got tokens: {self.tokens}")
149
184
  return self
150
185
 
151
- poll_err = PollingErrorResponse(**r.json())
186
+ # Keep polling
187
+ try:
188
+ poll_err = PollingErrorResponse.model_validate(resp)
189
+ except ValidationError as e:
190
+ raise DeviceCodePollingError(
191
+ f"Failed to unpack polling response: {r.text}"
192
+ ) from e
152
193
  if poll_err.error in [
153
194
  PollingErrorType.AUTHORIZATION_PENDING,
154
195
  PollingErrorType.SLOW_DOWN,
155
196
  ]:
156
197
  continue
157
198
 
199
+ # Timeout
158
200
  if poll_err.error == PollingErrorType.EXPIRED_TOKEN:
159
- raise PollingExpiredError
201
+ raise DeviceCodePollingExpiredError
160
202
 
203
+ # Unauthorized
161
204
  if poll_err.error == PollingErrorType.ACCESS_DENIED:
162
- raise PollingAccessDeniedError
205
+ raise UnauthorizedError
163
206
 
207
+ # Unhandled
164
208
  if poll_err:
165
- raise PollingError(f"Unknown polling error: {poll_err}")
209
+ raise DeviceCodePollingError(f"Unknown polling error: {poll_err}")
210
+
166
211
  finally:
167
212
  self._is_polling = False
168
213
 
169
- @abstractmethod
170
- def prompt_user(self):
171
- pass
172
-
173
- def request_device_code(self):
214
+ async def _async_request_device_code(self, scope: Optional[str] = None):
174
215
  """
175
- Request the device code from auth0 and sets self._device_codes
216
+ Request new device code from IdP
217
+
218
+ Args:
219
+ scope: the specific oauth2 scopes to request
176
220
 
177
- Raises
178
- ------
179
- RequestDeviceTokensError
180
- failed to get a device code
221
+ Raises:
222
+ DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
181
223
  """
182
- r = requests.post(
183
- f"https://{self.auth0_domain}/oauth/device/code",
224
+ scope = scope or self._settings.scope
225
+
226
+ r = await self._ctx.httpx_client.post(
227
+ f"{self._settings.domain}oauth/device/code",
228
+ auth=None, # override client default
184
229
  headers={"content-type": "application/x-www-form-urlencoded"},
185
230
  data={
186
- "client_id": self.auth0_client_id,
187
- "scope": self.token_scope,
188
- "audience": self.auth0_audience,
231
+ "client_id": self._settings.client_id,
232
+ "scope": scope,
233
+ "audience": self._settings.audience,
189
234
  },
190
235
  )
191
- if r.status_code == 200:
192
- self._device_codes = GetDeviceCodeResponse(**r.json())
193
- logger.debug(f"Got device code response: {self._device_codes}")
194
- return self
195
-
196
- raise RequestDeviceTokensError(f"Failed to get a device code: {r.text}")
197
-
198
-
199
- class DeviceCodeFlowSimple(DeviceCodeFlow):
200
- """
201
- This is a concrete implementation of the DeviceCodeFlow abstract class. This will store tokens on your filesystem
202
- and print the user prompt to the standard out.
203
-
204
- Default Auth0 values are for the Production tenant for UNAVCO Data File Server Access Application and User Management API.
205
-
206
- Attributes:
207
- -----------
208
- path : str
209
- The path where your access token will be stored and read. If this is a directory, the file name will be assigned as sso_tokens.json. You maye provide your own file name if desired.
210
- domain : str, optional
211
- Auth0 tenant domain URL or custom domain (default is 'login.earthscope.org')
212
- audience : str, optional
213
- Auth0 API Identifier (default is 'https://account.earthscope.org')
214
- client_id : str, optional
215
- Identification value of Auth0 Application (default is 'b9DtAFBd6QvMg761vI3YhYquNZbJX5G0')
216
- scope : str, optional
217
- The specific actions Auth0 applications can be allowed to do or information that they can request on a user’s behalf. (default is "openid profile email offline_access")
218
-
219
- Methods
220
- -------
221
- load_tokens
222
- Loads and validates your access token from the location specified by the given path.
223
- save_tokens(creds)
224
- Saves your access token to the location specified by the given path.
225
- prompt_user
226
- Prints the link for the sso authorization flow.
227
- """
228
- def __init__(
229
- self,
230
- path: str,
231
- domain: str = "login.earthscope.org",
232
- audience: str = "https://account.earthscope.org",
233
- client_id: str = "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0",
234
- scope: str = "openid profile email offline_access"
235
-
236
- ) -> None:
237
- super().__init__(
238
- domain=domain,
239
- audience=audience,
240
- client_id=client_id,
241
- scope=scope,
242
- )
243
- self.path = Path(path)
244
- if self.path.is_dir():
245
- self.path = self.path / "sso_tokens.json"
246
236
 
247
- def load_tokens(self):
248
- """
249
- Loads and validates your access token from the location specified by the given path.
250
-
251
- Raises
252
- ------
253
- NoTokensError
254
- If no token is found
255
- Returns
256
- _______
257
- valid token : str
258
- """
259
- try:
260
- with self.path.open() as f:
261
- json_state = json.load(f)
262
- except FileNotFoundError:
263
- raise NoTokensError
264
-
265
- self.validate_tokens(json_state)
266
- return self.tokens
267
-
268
- def save_tokens(self, creds: ValidTokens):
269
- """
270
- writes your access token to the location specified by the given path in json format.
271
-
272
- Parameters
273
- ----------
274
- creds: ValidTokens
275
- token credentials
276
- """
277
- json_str_state = json.dumps(vars(creds))
278
- with self.path.open("w") as f:
279
- f.write(json_str_state)
280
-
281
- def prompt_user(self):
282
- """
283
- Prints the url to complete the sso authorization flow to to stdout
284
- """
285
- if not self._device_codes:
286
- raise RuntimeError(
287
- "You must start the device flow before prompting the user"
237
+ if r.status_code != 200:
238
+ raise DeviceCodeRequestDeviceCodeError(
239
+ f"Failed to get a device code: {r.content}"
288
240
  )
289
241
 
290
- print(
291
- f"""To complete the SSO authorization, please visit the following URL in a browser of your choice:
292
- {self._device_codes.verification_uri_complete}
293
- """
294
- )
242
+ try:
243
+ codes = GetDeviceCodeResponse.model_validate_json(r.content)
244
+ except ValidationError as e:
245
+ raise DeviceCodeRequestDeviceCodeError(
246
+ f"Failed to unpack device code response: {r.text}"
247
+ ) from e
248
+
249
+ logger.debug(f"Got device code response: {codes}")
250
+ return codes