asa-api-client 0.1.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.
- asa_api_client/__init__.py +50 -0
- asa_api_client/auth.py +462 -0
- asa_api_client/client.py +332 -0
- asa_api_client/exceptions.py +221 -0
- asa_api_client/logging.py +128 -0
- asa_api_client/models/__init__.py +106 -0
- asa_api_client/models/ad_groups.py +301 -0
- asa_api_client/models/base.py +345 -0
- asa_api_client/models/campaigns.py +255 -0
- asa_api_client/models/keywords.py +196 -0
- asa_api_client/models/reports.py +359 -0
- asa_api_client/resources/__init__.py +19 -0
- asa_api_client/resources/ad_groups.py +152 -0
- asa_api_client/resources/base.py +593 -0
- asa_api_client/resources/campaigns.py +158 -0
- asa_api_client/resources/keywords.py +269 -0
- asa_api_client/resources/reports.py +504 -0
- asa_api_client/settings.py +126 -0
- asa_api_client/utils/__init__.py +1 -0
- asa_api_client-0.1.0.dist-info/METADATA +236 -0
- asa_api_client-0.1.0.dist-info/RECORD +23 -0
- asa_api_client-0.1.0.dist-info/WHEEL +4 -0
- asa_api_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Apple Search Ads API client library.
|
|
2
|
+
|
|
3
|
+
A modern, fully-typed Python client for the Apple Search Ads API
|
|
4
|
+
with async support, Pydantic models, and comprehensive logging.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
Basic usage with the client::
|
|
8
|
+
|
|
9
|
+
from asa_api_client import AppleSearchAdsClient
|
|
10
|
+
|
|
11
|
+
client = AppleSearchAdsClient(
|
|
12
|
+
client_id="your-client-id",
|
|
13
|
+
team_id="your-team-id",
|
|
14
|
+
key_id="your-key-id",
|
|
15
|
+
org_id=123456,
|
|
16
|
+
private_key_path="path/to/private-key.pem",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# List all campaigns
|
|
20
|
+
campaigns = client.campaigns.list()
|
|
21
|
+
|
|
22
|
+
# Async usage
|
|
23
|
+
campaigns = await client.campaigns.list_async()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from asa_api_client.client import AppleSearchAdsClient
|
|
27
|
+
from asa_api_client.exceptions import (
|
|
28
|
+
AppleSearchAdsError,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
ConfigurationError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
ValidationError,
|
|
34
|
+
)
|
|
35
|
+
from asa_api_client.logging import configure_logging
|
|
36
|
+
from asa_api_client.settings import Settings
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AppleSearchAdsClient",
|
|
40
|
+
"AppleSearchAdsError",
|
|
41
|
+
"AuthenticationError",
|
|
42
|
+
"ConfigurationError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"Settings",
|
|
46
|
+
"ValidationError",
|
|
47
|
+
"configure_logging",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
__version__ = "0.1.0"
|
asa_api_client/auth.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Authentication handling for the Apple Search Ads API.
|
|
2
|
+
|
|
3
|
+
This module handles OAuth 2.0 authentication with the Apple Search Ads API,
|
|
4
|
+
including JWT token generation and access token management.
|
|
5
|
+
|
|
6
|
+
The authentication flow:
|
|
7
|
+
1. Generate a client secret (JWT) signed with your private key
|
|
8
|
+
2. Exchange the client secret for an access token
|
|
9
|
+
3. Use the access token for API requests
|
|
10
|
+
4. Automatically refresh when the token expires
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Self
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import jwt
|
|
20
|
+
|
|
21
|
+
from asa_api_client.exceptions import AuthenticationError, ConfigurationError, NetworkError
|
|
22
|
+
from asa_api_client.logging import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
# Apple's OAuth token endpoint
|
|
27
|
+
TOKEN_URL = "https://appleid.apple.com/auth/oauth2/token"
|
|
28
|
+
|
|
29
|
+
# Apple Search Ads API audience
|
|
30
|
+
AUDIENCE = "https://appleid.apple.com"
|
|
31
|
+
|
|
32
|
+
# Scope for Search Ads API access
|
|
33
|
+
SCOPE = "searchadsorg"
|
|
34
|
+
|
|
35
|
+
# JWT algorithm
|
|
36
|
+
ALGORITHM = "ES256"
|
|
37
|
+
|
|
38
|
+
# Maximum lifetime for client secret (180 days per Apple's requirements)
|
|
39
|
+
MAX_CLIENT_SECRET_LIFETIME = timedelta(days=180)
|
|
40
|
+
|
|
41
|
+
# Default access token refresh buffer (refresh 5 minutes before expiry)
|
|
42
|
+
TOKEN_REFRESH_BUFFER = timedelta(minutes=5)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class AccessToken:
|
|
47
|
+
"""Represents an OAuth access token.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
token: The access token string.
|
|
51
|
+
expires_at: When the token expires.
|
|
52
|
+
token_type: The type of token (typically "Bearer").
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
token: str
|
|
56
|
+
expires_at: datetime
|
|
57
|
+
token_type: str = "Bearer"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_expired(self) -> bool:
|
|
61
|
+
"""Check if the token is expired or about to expire.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True if the token is expired or will expire within
|
|
65
|
+
the refresh buffer period.
|
|
66
|
+
"""
|
|
67
|
+
return datetime.now(UTC) >= (self.expires_at - TOKEN_REFRESH_BUFFER)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def authorization_header(self) -> str:
|
|
71
|
+
"""Get the Authorization header value.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The full Authorization header value including token type.
|
|
75
|
+
"""
|
|
76
|
+
return f"{self.token_type} {self.token}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Authenticator:
|
|
80
|
+
"""Handles authentication with the Apple Search Ads API.
|
|
81
|
+
|
|
82
|
+
This class manages the OAuth 2.0 flow for the Apple Search Ads API,
|
|
83
|
+
including JWT client secret generation and access token management.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
client_id: Your Apple Search Ads API client ID.
|
|
87
|
+
team_id: Your Apple Developer team ID.
|
|
88
|
+
key_id: The key ID for your private key.
|
|
89
|
+
org_id: Your Apple Search Ads organization ID.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
Create an authenticator and get an access token::
|
|
93
|
+
|
|
94
|
+
auth = Authenticator(
|
|
95
|
+
client_id="SEARCHADS.abc123",
|
|
96
|
+
team_id="TEAM123",
|
|
97
|
+
key_id="KEY123",
|
|
98
|
+
private_key_path=Path("private-key.pem"),
|
|
99
|
+
org_id=123456,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
token = auth.get_access_token()
|
|
103
|
+
# Or async
|
|
104
|
+
token = await auth.get_access_token_async()
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
client_id: str,
|
|
111
|
+
team_id: str,
|
|
112
|
+
key_id: str,
|
|
113
|
+
org_id: int,
|
|
114
|
+
private_key: str | None = None,
|
|
115
|
+
private_key_path: Path | str | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Initialize the authenticator.
|
|
118
|
+
|
|
119
|
+
You must provide either `private_key` or `private_key_path`.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
client_id: Your Apple Search Ads API client ID.
|
|
123
|
+
team_id: Your Apple Developer team ID.
|
|
124
|
+
key_id: The key ID for your private key.
|
|
125
|
+
org_id: Your Apple Search Ads organization ID.
|
|
126
|
+
private_key: The private key as a PEM-encoded string.
|
|
127
|
+
private_key_path: Path to the private key PEM file.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ConfigurationError: If neither or both private key options
|
|
131
|
+
are provided, or if the key cannot be loaded.
|
|
132
|
+
"""
|
|
133
|
+
self.client_id = client_id
|
|
134
|
+
self.team_id = team_id
|
|
135
|
+
self.key_id = key_id
|
|
136
|
+
self.org_id = org_id
|
|
137
|
+
|
|
138
|
+
self._private_key = self._load_private_key(private_key, private_key_path)
|
|
139
|
+
self._access_token: AccessToken | None = None
|
|
140
|
+
self._client_secret: str | None = None
|
|
141
|
+
self._client_secret_expires_at: datetime | None = None
|
|
142
|
+
|
|
143
|
+
logger.debug(
|
|
144
|
+
"Authenticator initialized for client_id=%s, org_id=%d",
|
|
145
|
+
self.client_id,
|
|
146
|
+
self.org_id,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _load_private_key(
|
|
150
|
+
self,
|
|
151
|
+
private_key: str | None,
|
|
152
|
+
private_key_path: Path | str | None,
|
|
153
|
+
) -> str:
|
|
154
|
+
"""Load the private key from string or file.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
private_key: The private key as a string.
|
|
158
|
+
private_key_path: Path to the private key file.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The private key as a PEM-encoded string.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ConfigurationError: If the key cannot be loaded.
|
|
165
|
+
"""
|
|
166
|
+
if private_key and private_key_path:
|
|
167
|
+
raise ConfigurationError("Provide either 'private_key' or 'private_key_path', not both")
|
|
168
|
+
|
|
169
|
+
if not private_key and not private_key_path:
|
|
170
|
+
raise ConfigurationError("Must provide either 'private_key' or 'private_key_path'")
|
|
171
|
+
|
|
172
|
+
if private_key:
|
|
173
|
+
logger.debug("Using provided private key string")
|
|
174
|
+
return private_key
|
|
175
|
+
|
|
176
|
+
path = Path(private_key_path) if isinstance(private_key_path, str) else private_key_path
|
|
177
|
+
assert path is not None # For type checker
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
key_content = path.read_text()
|
|
181
|
+
logger.debug("Loaded private key from %s", path)
|
|
182
|
+
return key_content
|
|
183
|
+
except FileNotFoundError as e:
|
|
184
|
+
raise ConfigurationError(f"Private key file not found: {path}") from e
|
|
185
|
+
except PermissionError as e:
|
|
186
|
+
raise ConfigurationError(f"Cannot read private key file: {path}") from e
|
|
187
|
+
except OSError as e:
|
|
188
|
+
raise ConfigurationError(f"Error reading private key file: {e}") from e
|
|
189
|
+
|
|
190
|
+
def _generate_client_secret(self) -> str:
|
|
191
|
+
"""Generate a JWT client secret.
|
|
192
|
+
|
|
193
|
+
The client secret is a JWT signed with your private key that
|
|
194
|
+
Apple uses to verify your identity.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The encoded JWT client secret.
|
|
198
|
+
"""
|
|
199
|
+
now = datetime.now(UTC)
|
|
200
|
+
expiration = now + MAX_CLIENT_SECRET_LIFETIME
|
|
201
|
+
|
|
202
|
+
headers = {
|
|
203
|
+
"alg": ALGORITHM,
|
|
204
|
+
"kid": self.key_id,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
payload = {
|
|
208
|
+
"sub": self.client_id,
|
|
209
|
+
"aud": AUDIENCE,
|
|
210
|
+
"iat": int(now.timestamp()),
|
|
211
|
+
"exp": int(expiration.timestamp()),
|
|
212
|
+
"iss": self.team_id,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
client_secret = jwt.encode(
|
|
217
|
+
payload,
|
|
218
|
+
self._private_key,
|
|
219
|
+
algorithm=ALGORITHM,
|
|
220
|
+
headers=headers,
|
|
221
|
+
)
|
|
222
|
+
except jwt.exceptions.PyJWTError as e:
|
|
223
|
+
raise AuthenticationError(f"Failed to generate client secret: {e}") from e
|
|
224
|
+
|
|
225
|
+
self._client_secret = client_secret
|
|
226
|
+
self._client_secret_expires_at = expiration
|
|
227
|
+
|
|
228
|
+
logger.debug("Generated new client secret, expires at %s", expiration)
|
|
229
|
+
return client_secret
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def client_secret(self) -> str:
|
|
233
|
+
"""Get or generate the client secret.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The JWT client secret, generating a new one if needed.
|
|
237
|
+
"""
|
|
238
|
+
now = datetime.now(UTC)
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
self._client_secret is None
|
|
242
|
+
or self._client_secret_expires_at is None
|
|
243
|
+
or now >= self._client_secret_expires_at - timedelta(days=1)
|
|
244
|
+
):
|
|
245
|
+
return self._generate_client_secret()
|
|
246
|
+
|
|
247
|
+
return self._client_secret
|
|
248
|
+
|
|
249
|
+
def _request_access_token(self, http_client: httpx.Client) -> AccessToken:
|
|
250
|
+
"""Request a new access token from Apple.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
http_client: The HTTP client to use for the request.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
The new access token.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
AuthenticationError: If authentication fails.
|
|
260
|
+
NetworkError: If there's a network problem.
|
|
261
|
+
"""
|
|
262
|
+
data = {
|
|
263
|
+
"grant_type": "client_credentials",
|
|
264
|
+
"client_id": self.client_id,
|
|
265
|
+
"client_secret": self.client_secret,
|
|
266
|
+
"scope": SCOPE,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
logger.debug("Requesting access token from %s", TOKEN_URL)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
response = http_client.post(
|
|
273
|
+
TOKEN_URL,
|
|
274
|
+
data=data,
|
|
275
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
276
|
+
)
|
|
277
|
+
except httpx.RequestError as e:
|
|
278
|
+
raise NetworkError(f"Failed to request access token: {e}") from e
|
|
279
|
+
|
|
280
|
+
if response.status_code != 200:
|
|
281
|
+
error_data = response.json() if response.content else {}
|
|
282
|
+
error_msg = error_data.get(
|
|
283
|
+
"error_description", error_data.get("error", "Unknown error")
|
|
284
|
+
)
|
|
285
|
+
raise AuthenticationError(
|
|
286
|
+
f"Failed to obtain access token: {error_msg}",
|
|
287
|
+
status_code=response.status_code,
|
|
288
|
+
response_body=error_data,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
token_data = response.json()
|
|
292
|
+
expires_in = token_data.get("expires_in", 3600)
|
|
293
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)
|
|
294
|
+
|
|
295
|
+
token = AccessToken(
|
|
296
|
+
token=token_data["access_token"],
|
|
297
|
+
expires_at=expires_at,
|
|
298
|
+
token_type=token_data.get("token_type", "Bearer"),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
logger.info("Obtained new access token, expires at %s", expires_at)
|
|
302
|
+
return token
|
|
303
|
+
|
|
304
|
+
async def _request_access_token_async(self, http_client: httpx.AsyncClient) -> AccessToken:
|
|
305
|
+
"""Request a new access token from Apple asynchronously.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
http_client: The async HTTP client to use for the request.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The new access token.
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
AuthenticationError: If authentication fails.
|
|
315
|
+
NetworkError: If there's a network problem.
|
|
316
|
+
"""
|
|
317
|
+
data = {
|
|
318
|
+
"grant_type": "client_credentials",
|
|
319
|
+
"client_id": self.client_id,
|
|
320
|
+
"client_secret": self.client_secret,
|
|
321
|
+
"scope": SCOPE,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
logger.debug("Requesting access token from %s (async)", TOKEN_URL)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
response = await http_client.post(
|
|
328
|
+
TOKEN_URL,
|
|
329
|
+
data=data,
|
|
330
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
331
|
+
)
|
|
332
|
+
except httpx.RequestError as e:
|
|
333
|
+
raise NetworkError(f"Failed to request access token: {e}") from e
|
|
334
|
+
|
|
335
|
+
if response.status_code != 200:
|
|
336
|
+
error_data = response.json() if response.content else {}
|
|
337
|
+
error_msg = error_data.get(
|
|
338
|
+
"error_description", error_data.get("error", "Unknown error")
|
|
339
|
+
)
|
|
340
|
+
raise AuthenticationError(
|
|
341
|
+
f"Failed to obtain access token: {error_msg}",
|
|
342
|
+
status_code=response.status_code,
|
|
343
|
+
response_body=error_data,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
token_data = response.json()
|
|
347
|
+
expires_in = token_data.get("expires_in", 3600)
|
|
348
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)
|
|
349
|
+
|
|
350
|
+
token = AccessToken(
|
|
351
|
+
token=token_data["access_token"],
|
|
352
|
+
expires_at=expires_at,
|
|
353
|
+
token_type=token_data.get("token_type", "Bearer"),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
logger.info("Obtained new access token (async), expires at %s", expires_at)
|
|
357
|
+
return token
|
|
358
|
+
|
|
359
|
+
def get_access_token(self, http_client: httpx.Client) -> AccessToken:
|
|
360
|
+
"""Get a valid access token, refreshing if necessary.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
http_client: The HTTP client to use for token requests.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
A valid access token.
|
|
367
|
+
"""
|
|
368
|
+
if self._access_token is None or self._access_token.is_expired:
|
|
369
|
+
self._access_token = self._request_access_token(http_client)
|
|
370
|
+
|
|
371
|
+
return self._access_token
|
|
372
|
+
|
|
373
|
+
async def get_access_token_async(self, http_client: httpx.AsyncClient) -> AccessToken:
|
|
374
|
+
"""Get a valid access token asynchronously, refreshing if necessary.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
http_client: The async HTTP client to use for token requests.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
A valid access token.
|
|
381
|
+
"""
|
|
382
|
+
if self._access_token is None or self._access_token.is_expired:
|
|
383
|
+
self._access_token = await self._request_access_token_async(http_client)
|
|
384
|
+
|
|
385
|
+
return self._access_token
|
|
386
|
+
|
|
387
|
+
def invalidate_token(self) -> None:
|
|
388
|
+
"""Invalidate the current access token.
|
|
389
|
+
|
|
390
|
+
Call this if you receive an authentication error to force
|
|
391
|
+
a new token to be obtained on the next request.
|
|
392
|
+
"""
|
|
393
|
+
logger.debug("Invalidating current access token")
|
|
394
|
+
self._access_token = None
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def from_env(cls, env_file: str | Path | None = ".env") -> Self:
|
|
398
|
+
"""Create an Authenticator from environment variables and .env file.
|
|
399
|
+
|
|
400
|
+
Loads configuration from environment variables and optionally from
|
|
401
|
+
a `.env` file. Environment variables take precedence.
|
|
402
|
+
|
|
403
|
+
Required settings (via env vars or .env file):
|
|
404
|
+
- ASA_CLIENT_ID
|
|
405
|
+
- ASA_TEAM_ID
|
|
406
|
+
- ASA_KEY_ID
|
|
407
|
+
- ASA_ORG_ID
|
|
408
|
+
- ASA_PRIVATE_KEY or ASA_PRIVATE_KEY_PATH
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
env_file: Path to .env file to load. Set to None to skip
|
|
412
|
+
loading from file and only use environment variables.
|
|
413
|
+
Defaults to ".env".
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
A configured Authenticator instance.
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
ConfigurationError: If required settings are missing or invalid.
|
|
420
|
+
|
|
421
|
+
Example:
|
|
422
|
+
Load from environment and .env file::
|
|
423
|
+
|
|
424
|
+
auth = Authenticator.from_env()
|
|
425
|
+
|
|
426
|
+
Load from a specific env file::
|
|
427
|
+
|
|
428
|
+
auth = Authenticator.from_env(".env.production")
|
|
429
|
+
|
|
430
|
+
Only use environment variables (no file)::
|
|
431
|
+
|
|
432
|
+
auth = Authenticator.from_env(env_file=None)
|
|
433
|
+
"""
|
|
434
|
+
from pydantic import ValidationError
|
|
435
|
+
|
|
436
|
+
from asa_api_client.settings import Settings
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
if env_file is None:
|
|
440
|
+
# Only load from environment variables
|
|
441
|
+
settings = Settings(_env_file=None)
|
|
442
|
+
else:
|
|
443
|
+
settings = Settings(_env_file=env_file)
|
|
444
|
+
except ValidationError as e:
|
|
445
|
+
# Convert pydantic validation errors to ConfigurationError
|
|
446
|
+
errors = e.errors()
|
|
447
|
+
if errors:
|
|
448
|
+
first_error = errors[0]
|
|
449
|
+
field = first_error.get("loc", ["unknown"])[0]
|
|
450
|
+
msg = first_error.get("msg", "validation error")
|
|
451
|
+
raise ConfigurationError(
|
|
452
|
+
f"Configuration error for ASA_{str(field).upper()}: {msg}"
|
|
453
|
+
) from e
|
|
454
|
+
raise ConfigurationError(f"Configuration error: {e}") from e
|
|
455
|
+
|
|
456
|
+
return cls(
|
|
457
|
+
client_id=settings.client_id,
|
|
458
|
+
team_id=settings.team_id,
|
|
459
|
+
key_id=settings.key_id,
|
|
460
|
+
org_id=settings.org_id,
|
|
461
|
+
private_key=settings.get_private_key(),
|
|
462
|
+
)
|