micantis 1.2.6__tar.gz → 1.2.7__tar.gz
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.
- {micantis-1.2.6 → micantis-1.2.7}/PKG-INFO +27 -2
- {micantis-1.2.6 → micantis-1.2.7}/README.md +26 -1
- {micantis-1.2.6 → micantis-1.2.7}/micantis/api_wrapper.py +149 -3
- {micantis-1.2.6 → micantis-1.2.7}/micantis.egg-info/PKG-INFO +27 -2
- {micantis-1.2.6 → micantis-1.2.7}/micantis.egg-info/SOURCES.txt +1 -0
- {micantis-1.2.6 → micantis-1.2.7}/pyproject.toml +1 -1
- micantis-1.2.7/tests/test_token_provider.py +148 -0
- {micantis-1.2.6 → micantis-1.2.7}/LICENSE +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/micantis/__init__.py +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/micantis/utils.py +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/micantis.egg-info/dependency_links.txt +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/micantis.egg-info/requires.txt +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/micantis.egg-info/top_level.txt +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/setup.cfg +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/tests/test_batch_update_result.py +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/tests/test_get_test_request_cells.py +0 -0
- {micantis-1.2.6 → micantis-1.2.7}/tests/test_upload_execution_artifact.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: micantis
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.7
|
|
4
4
|
Summary: Package to simplify Micantis API usage
|
|
5
5
|
Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -121,10 +121,35 @@ api = MicantisAPI() # Will automatically use environment variables
|
|
|
121
121
|
# No need to call authenticate() when using a token
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
``` python
|
|
125
|
+
# Option 5 - Externally-managed tokens (web apps holding per-user tokens)
|
|
126
|
+
# Provide a zero-argument callable that always returns a currently-valid
|
|
127
|
+
# bearer token. It is consulted before every request (and on 401 retry),
|
|
128
|
+
# so your app owns the refresh logic — e.g. reading the signed-in user's
|
|
129
|
+
# token from Azure App Service / Container Apps built-in authentication.
|
|
130
|
+
service_url = 'your service url'
|
|
131
|
+
|
|
132
|
+
api = MicantisAPI(service_url=service_url, token_provider=get_current_user_token)
|
|
133
|
+
# No need to call authenticate() when using a token provider
|
|
134
|
+
```
|
|
135
|
+
|
|
124
136
|
### Authenticate API
|
|
125
137
|
``` api.authenticate() ```
|
|
126
138
|
|
|
127
|
-
**Note:** When using a pre-existing token (Option 3 or 4), you don't need to call `authenticate()` as the token is already configured.
|
|
139
|
+
**Note:** When using a pre-existing token (Option 3 or 4) or a token provider (Option 5), you don't need to call `authenticate()` as the token is already configured.
|
|
140
|
+
|
|
141
|
+
### Who am I?
|
|
142
|
+
|
|
143
|
+
``` python
|
|
144
|
+
me = api.get_whoami()
|
|
145
|
+
# {'username': 'jane.doe@example.com', 'displayName': 'Jane Doe', 'email': ...,
|
|
146
|
+
# 'roles': ['Analyst'], 'accessGroups': [{'id': 3, 'name': 'PRC'}],
|
|
147
|
+
# 'defaultAccessGroupId': 3, 'unrestrictedAccess': False}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Returns the identity the API resolves for your credentials, including the access
|
|
151
|
+
groups that scope your data visibility. Handy for "signed in as ..." banners and
|
|
152
|
+
for discovering which labs/groups you can query.
|
|
128
153
|
|
|
129
154
|
### Download Data Table Summary
|
|
130
155
|
|
|
@@ -101,10 +101,35 @@ api = MicantisAPI() # Will automatically use environment variables
|
|
|
101
101
|
# No need to call authenticate() when using a token
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
``` python
|
|
105
|
+
# Option 5 - Externally-managed tokens (web apps holding per-user tokens)
|
|
106
|
+
# Provide a zero-argument callable that always returns a currently-valid
|
|
107
|
+
# bearer token. It is consulted before every request (and on 401 retry),
|
|
108
|
+
# so your app owns the refresh logic — e.g. reading the signed-in user's
|
|
109
|
+
# token from Azure App Service / Container Apps built-in authentication.
|
|
110
|
+
service_url = 'your service url'
|
|
111
|
+
|
|
112
|
+
api = MicantisAPI(service_url=service_url, token_provider=get_current_user_token)
|
|
113
|
+
# No need to call authenticate() when using a token provider
|
|
114
|
+
```
|
|
115
|
+
|
|
104
116
|
### Authenticate API
|
|
105
117
|
``` api.authenticate() ```
|
|
106
118
|
|
|
107
|
-
**Note:** When using a pre-existing token (Option 3 or 4), you don't need to call `authenticate()` as the token is already configured.
|
|
119
|
+
**Note:** When using a pre-existing token (Option 3 or 4) or a token provider (Option 5), you don't need to call `authenticate()` as the token is already configured.
|
|
120
|
+
|
|
121
|
+
### Who am I?
|
|
122
|
+
|
|
123
|
+
``` python
|
|
124
|
+
me = api.get_whoami()
|
|
125
|
+
# {'username': 'jane.doe@example.com', 'displayName': 'Jane Doe', 'email': ...,
|
|
126
|
+
# 'roles': ['Analyst'], 'accessGroups': [{'id': 3, 'name': 'PRC'}],
|
|
127
|
+
# 'defaultAccessGroupId': 3, 'unrestrictedAccess': False}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Returns the identity the API resolves for your credentials, including the access
|
|
131
|
+
groups that scope your data visibility. Handy for "signed in as ..." banners and
|
|
132
|
+
for discovering which labs/groups you can query.
|
|
108
133
|
|
|
109
134
|
### Download Data Table Summary
|
|
110
135
|
|
|
@@ -9,7 +9,7 @@ import time
|
|
|
9
9
|
import tempfile
|
|
10
10
|
import uuid
|
|
11
11
|
from datetime import datetime, timedelta, timezone
|
|
12
|
-
from typing import Optional
|
|
12
|
+
from typing import Callable, Optional
|
|
13
13
|
from urllib.parse import quote
|
|
14
14
|
from msal import PublicClientApplication
|
|
15
15
|
from azure.identity import (
|
|
@@ -118,6 +118,7 @@ class MicantisAPI:
|
|
|
118
118
|
tenant_id: Optional[str] = None,
|
|
119
119
|
use_managed_identity: bool = False,
|
|
120
120
|
token: Optional[str] = None,
|
|
121
|
+
token_provider: Optional[Callable[[], str]] = None,
|
|
121
122
|
verify: bool = True):
|
|
122
123
|
"""
|
|
123
124
|
Initialize the API client.
|
|
@@ -129,6 +130,12 @@ class MicantisAPI:
|
|
|
129
130
|
token : str, optional
|
|
130
131
|
Pre-existing bearer token. If not provided, checks WORKBOOK_TOKEN environment variable.
|
|
131
132
|
If a token is provided, authentication methods will be skipped.
|
|
133
|
+
token_provider : callable, optional
|
|
134
|
+
Zero-argument callable returning a currently-valid bearer token. Consulted
|
|
135
|
+
before every request (and on 401 retry), so the provider owns refresh logic.
|
|
136
|
+
Use this when tokens are managed externally (e.g. a web app holding per-user
|
|
137
|
+
tokens). Takes precedence over other authentication methods; `token` (if also
|
|
138
|
+
given) only seeds the initial value.
|
|
132
139
|
verify : bool, optional
|
|
133
140
|
Whether to verify SSL certificates. Default: True. Set to False only for local
|
|
134
141
|
development against self-signed certificates — never disable in production.
|
|
@@ -145,12 +152,20 @@ class MicantisAPI:
|
|
|
145
152
|
self.authority = authority
|
|
146
153
|
self.scopes = scopes or ["user.read"]
|
|
147
154
|
self.token = token or os.environ.get("WORKBOOK_TOKEN")
|
|
155
|
+
self._token_provider = token_provider
|
|
148
156
|
self.headers = None
|
|
149
157
|
|
|
158
|
+
# If a provider is given, seed the initial token from it
|
|
159
|
+
if self._token_provider and not self.token:
|
|
160
|
+
self.token = self._token_provider()
|
|
161
|
+
|
|
150
162
|
# If token is provided, set up headers immediately
|
|
151
163
|
if self.token:
|
|
152
164
|
self.headers = {"Authorization": f"Bearer {self.token}"}
|
|
153
|
-
|
|
165
|
+
if self._token_provider:
|
|
166
|
+
print("✅ Using externally-managed token (token_provider)")
|
|
167
|
+
else:
|
|
168
|
+
print("✅ Using pre-existing token from environment or parameter")
|
|
154
169
|
|
|
155
170
|
# Extra options for service principal / managed identity
|
|
156
171
|
self.service_principal_id = service_principal_id
|
|
@@ -248,10 +263,21 @@ class MicantisAPI:
|
|
|
248
263
|
def _refresh_token_if_needed(self):
|
|
249
264
|
"""
|
|
250
265
|
Check if the token is expired or about to expire, and refresh it if needed.
|
|
251
|
-
|
|
266
|
+
Works for identity-based authentication (Service Principal / Managed Identity)
|
|
267
|
+
and for externally-managed tokens (token_provider).
|
|
252
268
|
|
|
253
269
|
Returns True if token was refreshed, False otherwise.
|
|
254
270
|
"""
|
|
271
|
+
# Token-provider mode: the provider owns freshness — consult it every time.
|
|
272
|
+
# (getattr: tolerate instances built without __init__, e.g. in tests)
|
|
273
|
+
if getattr(self, "_token_provider", None):
|
|
274
|
+
new_token = self._token_provider()
|
|
275
|
+
if new_token and new_token != self.token:
|
|
276
|
+
self.token = new_token
|
|
277
|
+
self.headers = {"Authorization": f"Bearer {self.token}"}
|
|
278
|
+
return True
|
|
279
|
+
return False
|
|
280
|
+
|
|
255
281
|
if not self._credential or not self._token_expires:
|
|
256
282
|
return False
|
|
257
283
|
|
|
@@ -284,6 +310,12 @@ class MicantisAPI:
|
|
|
284
310
|
- Entra interactive if client_id + authority given
|
|
285
311
|
- Username/password login otherwise
|
|
286
312
|
"""
|
|
313
|
+
# Token-provider mode: re-consult the provider (it owns refresh) and return.
|
|
314
|
+
# This also makes the 401-retry path re-check the provider.
|
|
315
|
+
if getattr(self, "_token_provider", None):
|
|
316
|
+
self._refresh_token_if_needed()
|
|
317
|
+
return
|
|
318
|
+
|
|
287
319
|
# Skip authentication if token already set
|
|
288
320
|
if self.headers and self.token:
|
|
289
321
|
print("✅ Token already configured, skipping authentication")
|
|
@@ -385,6 +417,60 @@ class MicantisAPI:
|
|
|
385
417
|
# Generic fallback
|
|
386
418
|
return RuntimeError(f"Request failed: {e}")
|
|
387
419
|
|
|
420
|
+
# ------------------------
|
|
421
|
+
# Identity
|
|
422
|
+
# ------------------------
|
|
423
|
+
def get_whoami(self, timeout: int = 30):
|
|
424
|
+
"""
|
|
425
|
+
Fetch the identity the API resolves for the current credentials.
|
|
426
|
+
|
|
427
|
+
Useful for showing "signed in as ..." and for discovering which access
|
|
428
|
+
groups (labs) the caller can see.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
timeout : int, optional
|
|
433
|
+
Request timeout in seconds. Default: 30.
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
dict or None
|
|
438
|
+
Keys: 'username', 'displayName', 'email', 'roles' (list of str),
|
|
439
|
+
'accessGroups' (list of {'id', 'name'}), 'defaultAccessGroupId',
|
|
440
|
+
'unrestrictedAccess' (bool — True for admins who bypass access-group
|
|
441
|
+
filtering). None if the request fails.
|
|
442
|
+
"""
|
|
443
|
+
try:
|
|
444
|
+
return self._retry_on_auth_failure(self._get_whoami, timeout)
|
|
445
|
+
except Exception as e:
|
|
446
|
+
print(f"⚠️ get_whoami failed: {e}")
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
def _get_whoami(self, timeout: int):
|
|
450
|
+
"""
|
|
451
|
+
Internal helper for get_whoami. Does not handle retries or printing.
|
|
452
|
+
|
|
453
|
+
Raises
|
|
454
|
+
------
|
|
455
|
+
RuntimeError
|
|
456
|
+
If the request fails or authentication is missing.
|
|
457
|
+
"""
|
|
458
|
+
if not self.headers:
|
|
459
|
+
raise RuntimeError("Authentication required. Call authenticate() first.")
|
|
460
|
+
|
|
461
|
+
self._refresh_token_if_needed()
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
response = self.session.get(
|
|
465
|
+
f"{self.service_url}/publicapi/v1/whoami",
|
|
466
|
+
headers=self.headers,
|
|
467
|
+
timeout=timeout,
|
|
468
|
+
)
|
|
469
|
+
response.raise_for_status()
|
|
470
|
+
return response.json()
|
|
471
|
+
except requests.exceptions.RequestException as e:
|
|
472
|
+
raise self._format_request_error(e)
|
|
473
|
+
|
|
388
474
|
def _get_data_table(
|
|
389
475
|
self,
|
|
390
476
|
offset: int = 0,
|
|
@@ -574,6 +660,10 @@ class MicantisAPI:
|
|
|
574
660
|
Automatically reauthenticates if the session is expired or missing.
|
|
575
661
|
Prints an error message and returns None if the request ultimately fails.
|
|
576
662
|
|
|
663
|
+
Supported data kinds: CycleTesterData, StitchedData, CleanedData,
|
|
664
|
+
DataLoggerData, and PotentiostatData (EIS; requires platform 2.18+).
|
|
665
|
+
FileData and other kinds fail server-side and return None.
|
|
666
|
+
|
|
577
667
|
Parameters
|
|
578
668
|
----------
|
|
579
669
|
guid : str
|
|
@@ -2429,6 +2519,31 @@ class MicantisAPI:
|
|
|
2429
2519
|
action["name"] = name
|
|
2430
2520
|
return action
|
|
2431
2521
|
|
|
2522
|
+
@staticmethod
|
|
2523
|
+
def change_column_units(source_column_index, new_units, name=None):
|
|
2524
|
+
"""Build a change-column-units action for clean_data (MetadataEdit mode).
|
|
2525
|
+
|
|
2526
|
+
Set a cycle-tester aux channel's units. The units drive the channel's
|
|
2527
|
+
display name ("Name (units)"), so use this to fix a wrong unit suffix
|
|
2528
|
+
(e.g. a temperature channel that shows "(V)"). Cycle-tester aux only —
|
|
2529
|
+
logger column units live in the Name, so use rename_column there.
|
|
2530
|
+
|
|
2531
|
+
Parameters
|
|
2532
|
+
----------
|
|
2533
|
+
source_column_index : int
|
|
2534
|
+
0-based index into the ORIGINAL source column list.
|
|
2535
|
+
new_units : str
|
|
2536
|
+
New units string (e.g. 'degC').
|
|
2537
|
+
name : str, optional
|
|
2538
|
+
Descriptive name for this action.
|
|
2539
|
+
"""
|
|
2540
|
+
action = {"$type": "units",
|
|
2541
|
+
"sourceColumnIndex": int(source_column_index),
|
|
2542
|
+
"newUnits": new_units}
|
|
2543
|
+
if name is not None:
|
|
2544
|
+
action["name"] = name
|
|
2545
|
+
return action
|
|
2546
|
+
|
|
2432
2547
|
@staticmethod
|
|
2433
2548
|
def reorder_columns(source_column_indices, name=None):
|
|
2434
2549
|
"""Build a reorder-columns action for clean_data (MetadataEdit mode).
|
|
@@ -2485,6 +2600,37 @@ class MicantisAPI:
|
|
|
2485
2600
|
action["columns"] = [{"columnIndex": int(k), "value": float(v)} for k, v in columns.items()]
|
|
2486
2601
|
return action
|
|
2487
2602
|
|
|
2603
|
+
@staticmethod
|
|
2604
|
+
def shift_time(offset_seconds=None, rezero_from_first_row=False, name=None):
|
|
2605
|
+
"""Build a shift-time action for clean_data (Parametric mode).
|
|
2606
|
+
|
|
2607
|
+
Shift every surviving timestep's test time by a constant. Works on
|
|
2608
|
+
cycle-tester and logger sources. To make the first remaining row T=0
|
|
2609
|
+
(e.g. after dropping a leading rest step), remove those rows first, then
|
|
2610
|
+
shift_time(rezero_from_first_row=True) — actions apply in order.
|
|
2611
|
+
|
|
2612
|
+
Parameters
|
|
2613
|
+
----------
|
|
2614
|
+
offset_seconds : float, optional
|
|
2615
|
+
Subtract this many seconds from every timestep. Pass this OR
|
|
2616
|
+
rezero_from_first_row, not both.
|
|
2617
|
+
rezero_from_first_row : bool, optional
|
|
2618
|
+
Re-zero so the first surviving row becomes 0.
|
|
2619
|
+
name : str, optional
|
|
2620
|
+
Descriptive name for this action.
|
|
2621
|
+
"""
|
|
2622
|
+
if (offset_seconds is None) == (not rezero_from_first_row):
|
|
2623
|
+
raise ValueError(
|
|
2624
|
+
"shift_time requires exactly one of offset_seconds or rezero_from_first_row.")
|
|
2625
|
+
action = {"$type": "shiftTime"}
|
|
2626
|
+
if name is not None:
|
|
2627
|
+
action["name"] = name
|
|
2628
|
+
if rezero_from_first_row:
|
|
2629
|
+
action["rezeroFromFirstRow"] = True
|
|
2630
|
+
else:
|
|
2631
|
+
action["offsetSeconds"] = float(offset_seconds)
|
|
2632
|
+
return action
|
|
2633
|
+
|
|
2488
2634
|
def clean_data(self,
|
|
2489
2635
|
source_ids: list,
|
|
2490
2636
|
mode: str = 'CycleAutoFixup',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: micantis
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.7
|
|
4
4
|
Summary: Package to simplify Micantis API usage
|
|
5
5
|
Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -121,10 +121,35 @@ api = MicantisAPI() # Will automatically use environment variables
|
|
|
121
121
|
# No need to call authenticate() when using a token
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
``` python
|
|
125
|
+
# Option 5 - Externally-managed tokens (web apps holding per-user tokens)
|
|
126
|
+
# Provide a zero-argument callable that always returns a currently-valid
|
|
127
|
+
# bearer token. It is consulted before every request (and on 401 retry),
|
|
128
|
+
# so your app owns the refresh logic — e.g. reading the signed-in user's
|
|
129
|
+
# token from Azure App Service / Container Apps built-in authentication.
|
|
130
|
+
service_url = 'your service url'
|
|
131
|
+
|
|
132
|
+
api = MicantisAPI(service_url=service_url, token_provider=get_current_user_token)
|
|
133
|
+
# No need to call authenticate() when using a token provider
|
|
134
|
+
```
|
|
135
|
+
|
|
124
136
|
### Authenticate API
|
|
125
137
|
``` api.authenticate() ```
|
|
126
138
|
|
|
127
|
-
**Note:** When using a pre-existing token (Option 3 or 4), you don't need to call `authenticate()` as the token is already configured.
|
|
139
|
+
**Note:** When using a pre-existing token (Option 3 or 4) or a token provider (Option 5), you don't need to call `authenticate()` as the token is already configured.
|
|
140
|
+
|
|
141
|
+
### Who am I?
|
|
142
|
+
|
|
143
|
+
``` python
|
|
144
|
+
me = api.get_whoami()
|
|
145
|
+
# {'username': 'jane.doe@example.com', 'displayName': 'Jane Doe', 'email': ...,
|
|
146
|
+
# 'roles': ['Analyst'], 'accessGroups': [{'id': 3, 'name': 'PRC'}],
|
|
147
|
+
# 'defaultAccessGroupId': 3, 'unrestrictedAccess': False}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Returns the identity the API resolves for your credentials, including the access
|
|
151
|
+
groups that scope your data visibility. Handy for "signed in as ..." banners and
|
|
152
|
+
for discovering which labs/groups you can query.
|
|
128
153
|
|
|
129
154
|
### Download Data Table Summary
|
|
130
155
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for the token_provider authentication mode and get_whoami.
|
|
3
|
+
|
|
4
|
+
Mocks the HTTP layer so it can run anywhere without a live server. Exercises:
|
|
5
|
+
- Constructor wiring: provider seeds token + headers; explicit token= takes precedence
|
|
6
|
+
- _refresh_token_if_needed consults the provider (rotates headers on change)
|
|
7
|
+
- 401 retry re-consults the provider and succeeds with the new token
|
|
8
|
+
- get_whoami happy path (URL + payload) and failure path (P-03: print + None)
|
|
9
|
+
- Regression: static token= without a provider keeps its no-refresh behavior
|
|
10
|
+
|
|
11
|
+
Run: pytest
|
|
12
|
+
"""
|
|
13
|
+
from unittest.mock import MagicMock
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from micantis.api_wrapper import MicantisAPI
|
|
18
|
+
|
|
19
|
+
from .test_batch_update_result import _make_api, _mock_response
|
|
20
|
+
|
|
21
|
+
WHOAMI_BODY = {
|
|
22
|
+
"username": "jane.doe@milwaukeetool.com",
|
|
23
|
+
"displayName": "Jane Doe",
|
|
24
|
+
"email": "jane.doe@milwaukeetool.com",
|
|
25
|
+
"roles": ["Analyst"],
|
|
26
|
+
"accessGroups": [{"id": 3, "name": "PRC"}, {"id": 7, "name": "AES"}],
|
|
27
|
+
"defaultAccessGroupId": 3,
|
|
28
|
+
"unrestrictedAccess": False,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _make_provider_api(provider):
|
|
33
|
+
api = MicantisAPI(service_url="https://fake.test", token_provider=provider)
|
|
34
|
+
api.session = MagicMock()
|
|
35
|
+
return api
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_constructor_seeds_token_from_provider():
|
|
39
|
+
provider = MagicMock(return_value="tok-1")
|
|
40
|
+
api = _make_provider_api(provider)
|
|
41
|
+
|
|
42
|
+
assert api.token == "tok-1"
|
|
43
|
+
assert api.headers == {"Authorization": "Bearer tok-1"}
|
|
44
|
+
provider.assert_called_once()
|
|
45
|
+
print("PASS: constructor seeds token + headers from provider")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_explicit_token_seeds_initial_value_over_provider():
|
|
49
|
+
provider = MagicMock(return_value="tok-from-provider")
|
|
50
|
+
api = MicantisAPI(service_url="https://fake.test", token="tok-explicit",
|
|
51
|
+
token_provider=provider)
|
|
52
|
+
|
|
53
|
+
assert api.token == "tok-explicit"
|
|
54
|
+
provider.assert_not_called() # only consulted lazily, before requests
|
|
55
|
+
|
|
56
|
+
api._refresh_token_if_needed()
|
|
57
|
+
assert api.token == "tok-from-provider"
|
|
58
|
+
print("PASS: explicit token seeds initial value; provider takes over on refresh")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_refresh_rotates_headers_when_provider_returns_new_token():
|
|
62
|
+
tokens = iter(["tok-1", "tok-2"])
|
|
63
|
+
api = _make_provider_api(lambda: next(tokens))
|
|
64
|
+
|
|
65
|
+
refreshed = api._refresh_token_if_needed()
|
|
66
|
+
|
|
67
|
+
assert refreshed is True
|
|
68
|
+
assert api.token == "tok-2"
|
|
69
|
+
assert api.headers == {"Authorization": "Bearer tok-2"}
|
|
70
|
+
print("PASS: refresh rotates token + headers on provider change")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_refresh_is_noop_when_provider_returns_same_token():
|
|
74
|
+
api = _make_provider_api(lambda: "tok-1")
|
|
75
|
+
|
|
76
|
+
refreshed = api._refresh_token_if_needed()
|
|
77
|
+
|
|
78
|
+
assert refreshed is False
|
|
79
|
+
assert api.token == "tok-1"
|
|
80
|
+
print("PASS: refresh no-op when provider returns unchanged token")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_authenticate_is_provider_refresh_not_interactive():
|
|
84
|
+
tokens = iter(["tok-1", "tok-2"])
|
|
85
|
+
api = _make_provider_api(lambda: next(tokens))
|
|
86
|
+
|
|
87
|
+
api.authenticate() # must not attempt msal/azure.identity flows
|
|
88
|
+
|
|
89
|
+
assert api.token == "tok-2"
|
|
90
|
+
print("PASS: authenticate() in provider mode just re-consults the provider")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_401_retry_reconsults_provider():
|
|
94
|
+
calls = {"n": 0}
|
|
95
|
+
|
|
96
|
+
def provider():
|
|
97
|
+
calls["n"] += 1
|
|
98
|
+
return "tok-stale" if calls["n"] <= 3 else "tok-fresh"
|
|
99
|
+
|
|
100
|
+
api = _make_provider_api(provider)
|
|
101
|
+
|
|
102
|
+
resp_401 = _mock_response(401)
|
|
103
|
+
resp_401.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp_401)
|
|
104
|
+
resp_200 = _mock_response(200, WHOAMI_BODY)
|
|
105
|
+
api.session.get.side_effect = [resp_401, resp_200]
|
|
106
|
+
|
|
107
|
+
result = api.get_whoami()
|
|
108
|
+
|
|
109
|
+
assert result == WHOAMI_BODY
|
|
110
|
+
assert api.session.get.call_count == 2
|
|
111
|
+
# The retried request must carry the rotated token
|
|
112
|
+
retry_headers = api.session.get.call_args.kwargs["headers"]
|
|
113
|
+
assert retry_headers == {"Authorization": "Bearer tok-fresh"}
|
|
114
|
+
print("PASS: 401 → provider re-consulted → retry succeeds with fresh token")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_get_whoami_calls_endpoint_and_returns_dict():
|
|
118
|
+
api = _make_api()
|
|
119
|
+
api.session.get.return_value = _mock_response(200, WHOAMI_BODY)
|
|
120
|
+
|
|
121
|
+
result = api.get_whoami()
|
|
122
|
+
|
|
123
|
+
assert result == WHOAMI_BODY
|
|
124
|
+
url = api.session.get.call_args.args[0]
|
|
125
|
+
assert url == "https://fake.test/publicapi/v1/whoami"
|
|
126
|
+
print("PASS: get_whoami hits /publicapi/v1/whoami and returns the payload")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_get_whoami_failure_prints_and_returns_none(capsys):
|
|
130
|
+
api = _make_api()
|
|
131
|
+
resp = _mock_response(500)
|
|
132
|
+
resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp)
|
|
133
|
+
api.session.get.return_value = resp
|
|
134
|
+
|
|
135
|
+
result = api.get_whoami()
|
|
136
|
+
|
|
137
|
+
assert result is None
|
|
138
|
+
assert "get_whoami failed" in capsys.readouterr().out
|
|
139
|
+
print("PASS: get_whoami failure → printed error, returns None")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_static_token_without_provider_never_refreshes():
|
|
143
|
+
api = MicantisAPI(service_url="https://fake.test", token="tok-static")
|
|
144
|
+
api.session = MagicMock()
|
|
145
|
+
|
|
146
|
+
assert api._refresh_token_if_needed() is False
|
|
147
|
+
assert api.headers == {"Authorization": "Bearer tok-static"}
|
|
148
|
+
print("PASS: static token= mode unchanged (no refresh path)")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|