hakai_api 1.5.2__py3-none-any.whl → 2.0.1__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.
- hakai_api/__init__.py +23 -2
- hakai_api/auth/__init__.py +7 -0
- hakai_api/auth/base.py +256 -0
- hakai_api/auth/desktop.py +263 -0
- hakai_api/auth/desktop_callback.html +94 -0
- hakai_api/auth/web.py +94 -0
- hakai_api/client.py +390 -0
- {hakai_api-1.5.2.dist-info → hakai_api-2.0.1.dist-info}/METADATA +92 -12
- hakai_api-2.0.1.dist-info/RECORD +11 -0
- {hakai_api-1.5.2.dist-info → hakai_api-2.0.1.dist-info}/WHEEL +1 -1
- {hakai_api-1.5.2.dist-info → hakai_api-2.0.1.dist-info}/licenses/LICENSE +1 -1
- hakai_api/Client.py +0 -168
- hakai_api-1.5.2.dist-info/RECORD +0 -6
hakai_api/auth/web.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Web authentication strategy using copy/paste credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from .base import AuthStrategy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebAuthStrategy(AuthStrategy):
|
|
13
|
+
"""Web authentication strategy using copy/paste from login page.
|
|
14
|
+
|
|
15
|
+
This is the traditional authentication flow where users visit a login page,
|
|
16
|
+
authenticate, and copy/paste the resulting credentials string.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, api_root: str, login_page: str, **kwargs: object) -> None:
|
|
20
|
+
"""Initialize the authentication strategy.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_root: The base url of the hakai api.
|
|
24
|
+
login_page: The url of the login page to direct users to.
|
|
25
|
+
**kwargs: Additional parameters passed to the base authentication strategy.
|
|
26
|
+
"""
|
|
27
|
+
super().__init__(api_root, **kwargs)
|
|
28
|
+
self.login_page = login_page
|
|
29
|
+
|
|
30
|
+
def get_credentials(self) -> dict:
|
|
31
|
+
"""Get user credentials from web sign-in flow.
|
|
32
|
+
|
|
33
|
+
First checks for cached credentials, environment variables, or prompts
|
|
34
|
+
for login if none are available.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A dict containing the credentials parsed from user input or cache.
|
|
38
|
+
"""
|
|
39
|
+
# Try environment variable first
|
|
40
|
+
env_credentials = os.getenv("HAKAI_API_CREDENTIALS")
|
|
41
|
+
if env_credentials is not None:
|
|
42
|
+
logger.trace("Loading credentials from environment variable")
|
|
43
|
+
try:
|
|
44
|
+
parsed_creds = self.parse_credentials_string(env_credentials)
|
|
45
|
+
# Check if environment credentials are expired
|
|
46
|
+
if self._are_credentials_expired(parsed_creds):
|
|
47
|
+
logger.warning("Environment variable credentials have expired")
|
|
48
|
+
else:
|
|
49
|
+
return parsed_creds
|
|
50
|
+
except (ValueError, KeyError) as e:
|
|
51
|
+
logger.warning(f"Invalid environment variable credentials: {e}")
|
|
52
|
+
|
|
53
|
+
# Try cached credentials
|
|
54
|
+
if self.file_credentials_are_valid():
|
|
55
|
+
logger.trace("Loading cached credentials from file")
|
|
56
|
+
return self.get_credentials_from_file()
|
|
57
|
+
|
|
58
|
+
# Prompt user for new credentials
|
|
59
|
+
logger.info("No valid cached credentials found, starting web authentication flow")
|
|
60
|
+
return self._get_credentials_from_web_input()
|
|
61
|
+
|
|
62
|
+
def _get_credentials_from_web_input(self) -> dict:
|
|
63
|
+
"""Get user credentials from web sign-in with user input prompt.
|
|
64
|
+
|
|
65
|
+
Prompts the user to copy and paste credentials from the login page.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A dict containing the credentials parsed from user input.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If the input format is invalid or cannot be split properly.
|
|
72
|
+
AttributeError: If the input string lacks expected string methods.
|
|
73
|
+
"""
|
|
74
|
+
logger.info(f"Please go here and authorize: {self.login_page}")
|
|
75
|
+
print(f"Please go here and authorize: {self.login_page}")
|
|
76
|
+
response = input("\nCopy and paste your credentials from the login page here and press <enter>:\n")
|
|
77
|
+
|
|
78
|
+
logger.trace("Parsing credentials from user input")
|
|
79
|
+
try:
|
|
80
|
+
# Reformat response to dict
|
|
81
|
+
credentials = dict(map(lambda x: x.split("="), response.split("&")))
|
|
82
|
+
credentials = self._check_keys_convert_types(credentials)
|
|
83
|
+
logger.trace("Successfully parsed web credentials")
|
|
84
|
+
return credentials
|
|
85
|
+
except (ValueError, AttributeError) as e:
|
|
86
|
+
logger.error(f"Failed to parse credentials from input: {e}")
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def client_type(self) -> str:
|
|
91
|
+
"""Get the client type for web authentication strategy."""
|
|
92
|
+
return "web"
|
|
93
|
+
|
|
94
|
+
# refresh_token method is now inherited from AuthStrategy base class
|
hakai_api/client.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Hakai API Python Client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from requests_oauthlib import OAuth2Session
|
|
11
|
+
|
|
12
|
+
from .auth import DesktopAuthStrategy, WebAuthStrategy
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from requests import Response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Client(OAuth2Session):
|
|
19
|
+
"""Hakai API client for authenticated HTTP requests.
|
|
20
|
+
|
|
21
|
+
Extends OAuth2Session to provide authenticated access to the Hakai API
|
|
22
|
+
resource server. Handles OAuth2 credential management, caching, and
|
|
23
|
+
automatic token refresh for seamless API interactions.
|
|
24
|
+
|
|
25
|
+
The client supports two authentication flows:
|
|
26
|
+
- Web flow: Copy/paste credentials from a web login page (default)
|
|
27
|
+
- Desktop flow: OAuth2 with PKCE for native applications
|
|
28
|
+
|
|
29
|
+
Credentials are automatically cached to the credentials_file for reuse
|
|
30
|
+
across sessions until expiry.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
DEFAULT_API_ROOT: Default production API base URL.
|
|
34
|
+
DEFAULT_LOGIN_PAGE: Default production login page URL.
|
|
35
|
+
CREDENTIALS_ENV_VAR: Environment variable name for credentials.
|
|
36
|
+
USER_AGENT_ENV_VAR: Environment variable name for User-Agent header.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
Basic usage with default settings:
|
|
40
|
+
|
|
41
|
+
>>> client = Client()
|
|
42
|
+
>>> response = client.get("/eims/views/output/stations")
|
|
43
|
+
|
|
44
|
+
Desktop OAuth flow:
|
|
45
|
+
|
|
46
|
+
>>> client = Client(auth_flow="desktop")
|
|
47
|
+
>>> response = client.get("/eims/views/output/stations")
|
|
48
|
+
|
|
49
|
+
Custom API endpoint:
|
|
50
|
+
|
|
51
|
+
>>> client = Client(api_root="https://custom.api.endpoint")
|
|
52
|
+
>>> response = client.get("/custom/endpoint")
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
DEFAULT_API_ROOT = "https://portal.hakai.org/api"
|
|
56
|
+
DEFAULT_LOGIN_PAGE = "https://portal.hakai.org/api-client-login"
|
|
57
|
+
CREDENTIALS_ENV_VAR = "HAKAI_API_CREDENTIALS"
|
|
58
|
+
USER_AGENT_ENV_VAR = "HAKAI_API_USER_AGENT"
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_root: str = DEFAULT_API_ROOT,
|
|
63
|
+
login_page: str = DEFAULT_LOGIN_PAGE,
|
|
64
|
+
credentials: str | dict | None = None,
|
|
65
|
+
credentials_file: str | Path | None = None,
|
|
66
|
+
user_agent: str | None = None,
|
|
67
|
+
auth_flow: Literal["web", "desktop"] = "web",
|
|
68
|
+
local_port: int = 65500,
|
|
69
|
+
use_refresh: bool = True,
|
|
70
|
+
callback_timeout: int = 120,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Create a new Client class with credentials.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
api_root: The base url of the hakai api you want to call.
|
|
76
|
+
Defaults to the production server.
|
|
77
|
+
login_page: The url of the login page to direct users to.
|
|
78
|
+
Defaults to the production login page.
|
|
79
|
+
credentials: Credentials token retrieved from the hakai api
|
|
80
|
+
login page. If `None`, loads cached credentials or prompts for log in.
|
|
81
|
+
credentials_file: The path to the file where credentials are saved. This will default to the path given by
|
|
82
|
+
environment variable `HAKAI_API_CREDENTIALS`, if defined, else to `~/.hakai-api-auth`.
|
|
83
|
+
user_agent: A user-agent string to use when requesting the hakai api to identify your application.
|
|
84
|
+
This will default to the value given by environment variable `HAKAI_API_USER_AGENT`, if defined, else
|
|
85
|
+
to `hakai-api-client-py`.
|
|
86
|
+
auth_flow: Authentication flow type - "web" (default, copy/paste) or "desktop" (OAuth with PKCE).
|
|
87
|
+
Only used if credentials are not provided.
|
|
88
|
+
local_port: Port for local callback server in desktop flow (default 65500).
|
|
89
|
+
Only used when auth_flow="desktop".
|
|
90
|
+
use_refresh: Whether to use the refresh token to automatically extend user sessions.
|
|
91
|
+
Currently only works for credentials obtained with the "desktop" authentication flow.
|
|
92
|
+
callback_timeout: Timeout for detecting credentials in browser in seconds.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If credentials are unable to be set.
|
|
96
|
+
"""
|
|
97
|
+
self._api_root = api_root
|
|
98
|
+
self._login_page = login_page
|
|
99
|
+
self._auth_flow = auth_flow
|
|
100
|
+
self._local_port = local_port
|
|
101
|
+
self._use_refresh = use_refresh
|
|
102
|
+
|
|
103
|
+
if credentials_file is None:
|
|
104
|
+
credentials_file = os.getenv(self.CREDENTIALS_ENV_VAR, Path.home() / ".hakai-api-auth")
|
|
105
|
+
credentials_file = Path(credentials_file)
|
|
106
|
+
|
|
107
|
+
if user_agent is None:
|
|
108
|
+
user_agent = os.getenv(self.USER_AGENT_ENV_VAR, "hakai-api-client-py")
|
|
109
|
+
|
|
110
|
+
# Create authentication strategy
|
|
111
|
+
if auth_flow == "desktop":
|
|
112
|
+
self._auth_strategy = DesktopAuthStrategy(
|
|
113
|
+
api_root, local_port=local_port, credentials_file=credentials_file, callback_timeout=callback_timeout
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
self._auth_strategy = WebAuthStrategy(api_root, login_page=login_page, credentials_file=credentials_file)
|
|
117
|
+
|
|
118
|
+
# Get credentials using strategy or provided values
|
|
119
|
+
logger.trace(f"Initializing Hakai API client with auth_flow={auth_flow}")
|
|
120
|
+
|
|
121
|
+
if isinstance(credentials, dict):
|
|
122
|
+
logger.trace("Using provided credentials dictionary")
|
|
123
|
+
# Validate and type-convert the provided credentials
|
|
124
|
+
self._credentials = self._auth_strategy._check_keys_convert_types(credentials)
|
|
125
|
+
elif isinstance(credentials, str):
|
|
126
|
+
logger.trace("Parsing credentials from provided string")
|
|
127
|
+
self._credentials = self._auth_strategy.parse_credentials_string(credentials)
|
|
128
|
+
else:
|
|
129
|
+
# Use strategy to get credentials (handles env vars and cached credentials properly)
|
|
130
|
+
logger.trace(f"No credentials provided, using {auth_flow} authentication strategy")
|
|
131
|
+
try:
|
|
132
|
+
self._credentials = self._auth_strategy.get_credentials()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Failed to get credentials from strategy: {e}")
|
|
135
|
+
self._credentials = None
|
|
136
|
+
|
|
137
|
+
if self._credentials is None:
|
|
138
|
+
logger.error("Failed to obtain valid credentials from any source")
|
|
139
|
+
raise ValueError("Credentials could not be set.")
|
|
140
|
+
|
|
141
|
+
# Cache the credentials
|
|
142
|
+
logger.trace(f"Caching credentials to file {self.credentials_file}")
|
|
143
|
+
self._auth_strategy.save_credentials_to_file(self._credentials)
|
|
144
|
+
|
|
145
|
+
# Init the OAuth2Session parent class with credentials
|
|
146
|
+
super().__init__(token=self._credentials)
|
|
147
|
+
|
|
148
|
+
# Set User-Agent header
|
|
149
|
+
self.headers.update({"User-Agent": user_agent})
|
|
150
|
+
logger.debug(f"Hakai API client initialized successfully with User-Agent: {user_agent}")
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def api_root(self) -> str:
|
|
154
|
+
"""Return the api base url.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The base URL of the Hakai API.
|
|
158
|
+
"""
|
|
159
|
+
return self._api_root
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def login_page(self) -> str:
|
|
163
|
+
"""Return the login page url.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The URL of the login page.
|
|
167
|
+
"""
|
|
168
|
+
return self._login_page
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def credentials(self) -> dict:
|
|
172
|
+
"""Return the credentials object.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Credentials object.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If credentials are not provided.
|
|
179
|
+
"""
|
|
180
|
+
if self._credentials is None:
|
|
181
|
+
raise ValueError("Credentials have not been set.")
|
|
182
|
+
return self._credentials
|
|
183
|
+
|
|
184
|
+
def reset_credentials(self) -> None:
|
|
185
|
+
"""Remove the cached credentials file.
|
|
186
|
+
|
|
187
|
+
Deletes the credentials file from the filesystem if it exists.
|
|
188
|
+
"""
|
|
189
|
+
self._auth_strategy.reset_credentials()
|
|
190
|
+
|
|
191
|
+
def file_credentials_are_valid(self) -> bool:
|
|
192
|
+
"""Check if the cached credentials exist and are valid.
|
|
193
|
+
|
|
194
|
+
Validates that the credentials file exists, can be parsed,
|
|
195
|
+
contains required fields, and has not expired.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if the credentials are valid, False otherwise.
|
|
199
|
+
"""
|
|
200
|
+
return self._auth_strategy.file_credentials_are_valid()
|
|
201
|
+
|
|
202
|
+
def refresh_token(self) -> bool:
|
|
203
|
+
"""Refresh the access token using the refresh token.
|
|
204
|
+
|
|
205
|
+
Uses the stored refresh token to obtain a new access token from
|
|
206
|
+
the API. Updates the stored credentials and OAuth2Session token
|
|
207
|
+
if successful.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
True if refresh successful, False otherwise.
|
|
211
|
+
"""
|
|
212
|
+
if "refresh_token" not in self._credentials:
|
|
213
|
+
logger.trace("No refresh token available, cannot refresh")
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
logger.trace("Attempting to refresh access token using auth strategy")
|
|
217
|
+
|
|
218
|
+
# All strategies that support refresh tokens should have a refresh_token method
|
|
219
|
+
if self._use_refresh:
|
|
220
|
+
updated_credentials = self._auth_strategy.refresh_token(self._credentials)
|
|
221
|
+
if updated_credentials:
|
|
222
|
+
self._credentials = updated_credentials
|
|
223
|
+
self._auth_strategy.save_credentials_to_file(self._credentials)
|
|
224
|
+
self.token = self._credentials
|
|
225
|
+
logger.trace("Access token refreshed successfully")
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
logger.warning("Token refresh failed or is not supported by the current auth strategy")
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# Factory methods for easy client creation
|
|
232
|
+
@classmethod
|
|
233
|
+
def create_web_client(
|
|
234
|
+
cls,
|
|
235
|
+
api_root: str = DEFAULT_API_ROOT,
|
|
236
|
+
login_page: str = DEFAULT_LOGIN_PAGE,
|
|
237
|
+
credentials: str | dict | None = None,
|
|
238
|
+
) -> Client:
|
|
239
|
+
"""Create a client using web authentication flow.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
api_root: The base url of the hakai api.
|
|
243
|
+
login_page: The url of the login page to direct users to.
|
|
244
|
+
credentials: Optional credentials to use.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
A Client configured for web authentication.
|
|
248
|
+
"""
|
|
249
|
+
return cls(
|
|
250
|
+
api_root=api_root,
|
|
251
|
+
login_page=login_page,
|
|
252
|
+
credentials=credentials,
|
|
253
|
+
auth_flow="web",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def create_desktop_client(
|
|
258
|
+
cls,
|
|
259
|
+
api_root: str = DEFAULT_API_ROOT,
|
|
260
|
+
local_port: int = 65500,
|
|
261
|
+
credentials: str | dict | None = None,
|
|
262
|
+
callback_timeout: int = 120,
|
|
263
|
+
) -> Client:
|
|
264
|
+
"""Create a client using desktop OAuth authentication flow.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
api_root: The base url of the hakai api.
|
|
268
|
+
local_port: Port for local callback server.
|
|
269
|
+
credentials: Optional credentials to use.
|
|
270
|
+
callback_timeout: Timeout for detecting credentials in browser in seconds.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
A Client configured for desktop OAuth authentication.
|
|
274
|
+
"""
|
|
275
|
+
return cls(
|
|
276
|
+
api_root=api_root,
|
|
277
|
+
credentials=credentials,
|
|
278
|
+
auth_flow="desktop",
|
|
279
|
+
local_port=local_port,
|
|
280
|
+
callback_timeout=callback_timeout,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Backward compatibility methods
|
|
284
|
+
def _get_credentials_from_web(self) -> dict:
|
|
285
|
+
"""Backward compatibility method for getting credentials from web prompt.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
dict: Credentials dictionary from web authentication.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
TypeError: If auth_flow is not 'web'.
|
|
292
|
+
"""
|
|
293
|
+
if not isinstance(self._auth_strategy, WebAuthStrategy):
|
|
294
|
+
raise TypeError("_get_credentials_from_web is only available for 'web' auth_flow")
|
|
295
|
+
return self._auth_strategy._get_credentials_from_web_input()
|
|
296
|
+
|
|
297
|
+
def _save_credentials_to_file(self, credentials: dict) -> None:
|
|
298
|
+
"""Backward compatibility method for saving credentials to file."""
|
|
299
|
+
self._auth_strategy.save_credentials_to_file(credentials)
|
|
300
|
+
|
|
301
|
+
def _parse_credentials_string(self, credentials: str) -> dict:
|
|
302
|
+
"""Backward compatibility for parsing credentials string.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
dict: Parsed credentials dictionary.
|
|
306
|
+
"""
|
|
307
|
+
return self._auth_strategy.parse_credentials_string(credentials)
|
|
308
|
+
|
|
309
|
+
def _check_keys_convert_types(self, credentials: dict) -> dict:
|
|
310
|
+
"""Backward compatibility for checking keys and converting types.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
dict: Validated and type-converted credentials dictionary.
|
|
314
|
+
"""
|
|
315
|
+
return self._auth_strategy._check_keys_convert_types(credentials)
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def credentials_file(self) -> str:
|
|
319
|
+
"""Backward compatibility property for credentials file path.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
The path to the credentials file.
|
|
323
|
+
"""
|
|
324
|
+
return str(self._auth_strategy.credentials_file)
|
|
325
|
+
|
|
326
|
+
def _get_credentials_from_file(self) -> dict:
|
|
327
|
+
"""Backward compatibility method for getting credentials from file.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
The contents of the credentials file as a dict.
|
|
331
|
+
"""
|
|
332
|
+
return self._auth_strategy.get_credentials_from_file()
|
|
333
|
+
|
|
334
|
+
def request(self, method: str, uri: str, **kwargs: dict[str, Any]) -> Response:
|
|
335
|
+
"""Override request method to handle 401 responses by re-authenticating.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
method: HTTP method (GET, POST, etc.)
|
|
339
|
+
uri: URI to request
|
|
340
|
+
**kwargs: Additional arguments forwarded to requests.request().
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Response object
|
|
344
|
+
"""
|
|
345
|
+
# Test for relative urls and prepend API root if needed
|
|
346
|
+
if uri.startswith("/"):
|
|
347
|
+
uri = f"{self.api_root}{uri}"
|
|
348
|
+
|
|
349
|
+
# First attempt
|
|
350
|
+
response = super().request(method, uri, **kwargs)
|
|
351
|
+
|
|
352
|
+
# If we get a 401, try to re-authenticate once
|
|
353
|
+
if response.status_code == 401:
|
|
354
|
+
logger.warning("Received 401 Unauthorized, attempting to re-authenticate")
|
|
355
|
+
|
|
356
|
+
# First try refresh token if available
|
|
357
|
+
if "refresh_token" in self._credentials:
|
|
358
|
+
logger.trace("Attempting token refresh")
|
|
359
|
+
if self.refresh_token():
|
|
360
|
+
logger.trace("Token refresh successful, retrying request")
|
|
361
|
+
response = super().request(method, uri, **kwargs)
|
|
362
|
+
return response
|
|
363
|
+
else:
|
|
364
|
+
logger.warning("Token refresh failed, falling back to full re-authentication")
|
|
365
|
+
|
|
366
|
+
# If no refresh token or refresh failed, do full re-authentication
|
|
367
|
+
logger.debug("Starting full re-authentication flow")
|
|
368
|
+
|
|
369
|
+
# Clear cached credentials and get new ones
|
|
370
|
+
self._auth_strategy.reset_credentials()
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
# Get new credentials using the strategy
|
|
374
|
+
self._credentials = self._auth_strategy.get_credentials()
|
|
375
|
+
|
|
376
|
+
# Update the token for this session
|
|
377
|
+
self.token = self._credentials
|
|
378
|
+
|
|
379
|
+
# Cache the new credentials
|
|
380
|
+
self._auth_strategy.save_credentials_to_file(self._credentials)
|
|
381
|
+
|
|
382
|
+
# Retry the request with new credentials
|
|
383
|
+
logger.trace("Re-authentication successful, retrying request")
|
|
384
|
+
response = super().request(method, uri, **kwargs)
|
|
385
|
+
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logger.error(f"Re-authentication failed: {e}")
|
|
388
|
+
# Return the original 401 response if re-auth fails
|
|
389
|
+
|
|
390
|
+
return response
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hakai_api
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Get Hakai database resources using http calls
|
|
5
|
-
Author-email: Taylor Denouden <taylor.denouden@hakai.org>
|
|
5
|
+
Author-email: Taylor Denouden <taylor.denouden@hakai.org>, Chris Davis <chris.davis@hakai.org>, Nate Rosenstock <nate.rosenstock@hakai.org>, Sam Albers <sam.albers@hakai.org>
|
|
6
|
+
Maintainer-email: Taylor Denouden <taylor.denouden@hakai.org>, Sam Albers <sam.albers@hakai.org>
|
|
6
7
|
License-Expression: MIT
|
|
7
8
|
License-File: LICENSE
|
|
8
9
|
Requires-Python: >=3.8
|
|
10
|
+
Requires-Dist: loguru>=0.7.3
|
|
11
|
+
Requires-Dist: pkce>=1.0.3
|
|
9
12
|
Requires-Dist: pytz>=2025.2
|
|
10
13
|
Requires-Dist: requests-oauthlib>=2.0.0
|
|
11
14
|
Requires-Dist: requests>=2.32.3
|
|
@@ -28,12 +31,17 @@ OAuth2 credentials with url requests.
|
|
|
28
31
|
[Installation](#installation)
|
|
29
32
|
|
|
30
33
|
[Quickstart](#quickstart)
|
|
34
|
+
- [Desktop OAuth Flow](#desktop-oauth-flow)
|
|
35
|
+
- [User Agent Configuration](#user-agent-configuration)
|
|
31
36
|
|
|
32
37
|
[Methods](#methods)
|
|
33
38
|
|
|
34
39
|
[API endpoints](#api-endpoints)
|
|
35
40
|
|
|
36
41
|
[Advanced usage](#advanced-usage)
|
|
42
|
+
- [Custom API Endpoints](#custom-api-endpoints)
|
|
43
|
+
- [Relative Endpoint Support](#relative-endpoint-support)
|
|
44
|
+
- [Credentials Configuration](#credentials-configuration)
|
|
37
45
|
|
|
38
46
|
[Contributing](#contributing)
|
|
39
47
|
|
|
@@ -55,15 +63,47 @@ from hakai_api import Client
|
|
|
55
63
|
# Get the api request client
|
|
56
64
|
client = Client() # Follow stdout prompts to get an API token
|
|
57
65
|
|
|
58
|
-
# Make a data request for chlorophyll data
|
|
59
|
-
|
|
60
|
-
response = client.get(url)
|
|
66
|
+
# Make a data request for chlorophyll data (using relative endpoint)
|
|
67
|
+
response = client.get('/eims/views/output/chlorophyll?limit=50')
|
|
61
68
|
|
|
62
|
-
print(url) # https://hecate.hakai.org/api/eims/views/output/chlorophyll...
|
|
63
69
|
print(response.json())
|
|
64
70
|
# [{'action': '', 'event_pk': 7064, 'rn': '1', 'date': '2012-05-17', 'work_area': 'CALVERT'...
|
|
65
71
|
```
|
|
66
72
|
|
|
73
|
+
## Desktop OAuth Flow
|
|
74
|
+
|
|
75
|
+
For native applications and automated scripts, use the desktop OAuth flow with PKCE:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from hakai_api import Client
|
|
79
|
+
|
|
80
|
+
# Use desktop OAuth flow (opens browser, more secure)
|
|
81
|
+
client = Client(auth_flow="desktop")
|
|
82
|
+
|
|
83
|
+
# Or use the factory method
|
|
84
|
+
client = Client.create_desktop_client()
|
|
85
|
+
|
|
86
|
+
# Make requests using relative endpoints
|
|
87
|
+
response = client.get('/eims/views/output/stations')
|
|
88
|
+
print(response.json())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## User Agent Configuration
|
|
92
|
+
|
|
93
|
+
**Important**: Set a descriptive user agent to help identify your application on the backend. Often the repository url is a good way to identify yourself:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from hakai_api import Client
|
|
97
|
+
import os
|
|
98
|
+
|
|
99
|
+
# Set user agent during initialization
|
|
100
|
+
client = Client(user_agent="MyApp/1.0 (contact@example.com)")
|
|
101
|
+
|
|
102
|
+
# Or set via environment variable
|
|
103
|
+
os.environ['HAKAI_API_USER_AGENT'] = "MyApp/1.0 (contact@example.com)"
|
|
104
|
+
client = Client()
|
|
105
|
+
```
|
|
106
|
+
|
|
67
107
|
# Methods
|
|
68
108
|
|
|
69
109
|
This library exports a single client name `Client`. Instantiating this class produces
|
|
@@ -84,8 +124,10 @@ from, see the [Hakai API documentation](https://github.com/HakaiInstitute/hakai-
|
|
|
84
124
|
|
|
85
125
|
# Advanced usage
|
|
86
126
|
|
|
127
|
+
## Custom API Endpoints
|
|
128
|
+
|
|
87
129
|
You can specify which API to access when instantiating the Client. By default, the API
|
|
88
|
-
uses `https://
|
|
130
|
+
uses `https://portal.hakai.org/api` as the API root. It may be useful to use this
|
|
89
131
|
library to access a locally running API instance or to access the Goose API for testing
|
|
90
132
|
purposes. If you are always going to be accessing data from a locally running API
|
|
91
133
|
instance, you are better off using the requests.py library directly since Authorization
|
|
@@ -99,7 +141,25 @@ client = Client("http://localhost:8666")
|
|
|
99
141
|
print(client.api_root) # http://localhost:8666
|
|
100
142
|
```
|
|
101
143
|
|
|
102
|
-
|
|
144
|
+
## Relative Endpoint Support
|
|
145
|
+
|
|
146
|
+
The client supports relative endpoints that automatically prepend the API root:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from hakai_api import Client
|
|
150
|
+
|
|
151
|
+
client = Client()
|
|
152
|
+
|
|
153
|
+
# These are equivalent:
|
|
154
|
+
response1 = client.get('/eims/views/output/stations')
|
|
155
|
+
response2 = client.get('https://portal.hakai.org/api/eims/views/output/stations')
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Credentials Configuration
|
|
159
|
+
|
|
160
|
+
### Direct Credentials
|
|
161
|
+
|
|
162
|
+
You can pass in the credentials string retrieved from the hakai API login page
|
|
103
163
|
while initiating the Client class.
|
|
104
164
|
|
|
105
165
|
```python
|
|
@@ -109,10 +169,30 @@ from hakai_api import Client
|
|
|
109
169
|
client = Client(credentials="CREDENTIAL_TOKEN")
|
|
110
170
|
```
|
|
111
171
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
172
|
+
### Environment Variables
|
|
173
|
+
|
|
174
|
+
Set credentials using the `HAKAI_API_CREDENTIALS` environment variable. This is useful
|
|
175
|
+
for e.g. setting credentials in a docker container. The value of the environment variable
|
|
176
|
+
should be the credentials token retrieved from the Hakai API login page.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
export HAKAI_API_CREDENTIALS="your_credential_token_here"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Custom Credentials File Location
|
|
183
|
+
|
|
184
|
+
By default, credentials are saved to `~/.hakai-api-auth`. You can customize this location:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from hakai_api import Client
|
|
188
|
+
|
|
189
|
+
# Set custom credentials file path
|
|
190
|
+
client = Client(credentials_file="/path/to/my/credentials")
|
|
191
|
+
|
|
192
|
+
# Or use environment variable
|
|
193
|
+
# export HAKAI_API_CREDENTIALS="/path/to/my/credentials"
|
|
194
|
+
client = Client()
|
|
195
|
+
```
|
|
116
196
|
|
|
117
197
|
# Contributing
|
|
118
198
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
hakai_api/__init__.py,sha256=MkCIFgEAySfdofoAldNIP0pcM6AGAhNDxkBXjwceTrk,693
|
|
2
|
+
hakai_api/client.py,sha256=FVvAB0YCH4b6RXR_owbYYkIBTy4ky2U33GkCMar5p8g,15034
|
|
3
|
+
hakai_api/auth/__init__.py,sha256=LfCI6mqeU2TNEw1lx6dX38-VnEyk28em6safdej3ceM,234
|
|
4
|
+
hakai_api/auth/base.py,sha256=M25bJ8Y0ho3j6FtDO8lknxsGqjiOnxed2rVOFn4SCcE,9349
|
|
5
|
+
hakai_api/auth/desktop.py,sha256=D-TbQUcAArAxgh4FVogDH4SaPlJp_1SYV-YLiCCd8L0,9996
|
|
6
|
+
hakai_api/auth/desktop_callback.html,sha256=T7bMbgEGPyQC2erJKN0KrcRIxmBl0bFhAWp5m60mPTc,2545
|
|
7
|
+
hakai_api/auth/web.py,sha256=8fBKBFaI_wN-iqjF1dH7y-pOE26i7009xjmQvVzroaU,3755
|
|
8
|
+
hakai_api-2.0.1.dist-info/METADATA,sha256=9yf3K6Lz9mGDk9a8NdG7bGRjUpq9_hnUo2W_PduJxr4,6211
|
|
9
|
+
hakai_api-2.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
hakai_api-2.0.1.dist-info/licenses/LICENSE,sha256=i8v9xjsJeSpF9B1I7sOroqgw2-ZKY3-hQF3i27Ap6Ns,1072
|
|
11
|
+
hakai_api-2.0.1.dist-info/RECORD,,
|