micantis 1.2.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: micantis
3
- Version: 1.2.5
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
 
@@ -500,6 +525,24 @@ print(f"Retrieved {len(combined_df)} test requests")
500
525
  combined_df.head()
501
526
  ```
502
527
 
528
+ ### Get Test Request Member Cells
529
+ Retrieve the actual cells linked to a test request. Note that the `tests` array from `get_test_request()` is the test plan (names and quantities) — it contains no cell IDs.
530
+
531
+ ```python
532
+ # DataFrame with one row per member cell (default)
533
+ cells_df = api.get_test_request_cells(request_id)
534
+ cells_df.head() # columns: id, name, updated, cycleCount, hasGraphData
535
+
536
+ # Raw list of dicts
537
+ cells = api.get_test_request_cells(request_id, return_format='list')
538
+ ```
539
+
540
+ ```python
541
+ # Use the cell IDs directly — e.g. fetch metadata for every cell in the request
542
+ cell_ids = cells_df['id'].to_list()
543
+ metadata_df = api.get_cell_metadata(cell_ids=cell_ids, metadata=["Cell height"])
544
+ ```
545
+
503
546
  ## Write Cell Metadata
504
547
  Micantis lets you programmatically assign or update metadata for each cell using either:
505
548
  - the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
@@ -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
 
@@ -480,6 +505,24 @@ print(f"Retrieved {len(combined_df)} test requests")
480
505
  combined_df.head()
481
506
  ```
482
507
 
508
+ ### Get Test Request Member Cells
509
+ Retrieve the actual cells linked to a test request. Note that the `tests` array from `get_test_request()` is the test plan (names and quantities) — it contains no cell IDs.
510
+
511
+ ```python
512
+ # DataFrame with one row per member cell (default)
513
+ cells_df = api.get_test_request_cells(request_id)
514
+ cells_df.head() # columns: id, name, updated, cycleCount, hasGraphData
515
+
516
+ # Raw list of dicts
517
+ cells = api.get_test_request_cells(request_id, return_format='list')
518
+ ```
519
+
520
+ ```python
521
+ # Use the cell IDs directly — e.g. fetch metadata for every cell in the request
522
+ cell_ids = cells_df['id'].to_list()
523
+ metadata_df = api.get_cell_metadata(cell_ids=cell_ids, metadata=["Cell height"])
524
+ ```
525
+
483
526
  ## Write Cell Metadata
484
527
  Micantis lets you programmatically assign or update metadata for each cell using either:
485
528
  - the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
@@ -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
- print("✅ Using pre-existing token from environment or parameter")
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
- Only works for identity-based authentication (Service Principal / Managed Identity).
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
@@ -1714,6 +1804,72 @@ class MicantisAPI:
1714
1804
  raise RuntimeError(f"Test request {request_id} not found.")
1715
1805
  raise self._format_request_error(e)
1716
1806
 
1807
+ def get_test_request_cells(self, request_id, return_format='df', timeout: int = 30):
1808
+ """
1809
+ Get the member cells of a test request.
1810
+
1811
+ These are the actual cells linked to the request. The `tests` array returned by
1812
+ get_test_request() is the test plan (names and quantities) and contains no cell IDs.
1813
+
1814
+ Automatically reauthenticates if the session is expired or missing.
1815
+ Prints an error message and returns None if the request ultimately fails.
1816
+
1817
+ Parameters
1818
+ ----------
1819
+ request_id : str
1820
+ GUID of the test request.
1821
+ return_format : str, optional
1822
+ Return format: 'df' for DataFrame (default), anything else for the raw item list.
1823
+ timeout : int, optional
1824
+ Request timeout in seconds. Default: 30.
1825
+
1826
+ Returns
1827
+ -------
1828
+ pd.DataFrame, list, or None
1829
+ One row/dict per member cell with `id`, `name`, `updated`, `cycleCount`,
1830
+ and `hasGraphData`. Returns None if an error occurs.
1831
+ """
1832
+ try:
1833
+ return self._retry_on_auth_failure(
1834
+ self._get_test_request_cells, request_id, return_format, timeout
1835
+ )
1836
+ except Exception as e:
1837
+ print(f"⚠️ get_test_request_cells failed: {e}")
1838
+ return None
1839
+
1840
+ def _get_test_request_cells(self, request_id, return_format: str, timeout: int):
1841
+ """
1842
+ Internal helper for get_test_request_cells. Does not handle retries or printing.
1843
+
1844
+ Raises
1845
+ ------
1846
+ RuntimeError
1847
+ If the request fails or authentication is missing.
1848
+ """
1849
+ if not self.headers:
1850
+ raise RuntimeError("Authentication required. Call authenticate() first.")
1851
+
1852
+ self._refresh_token_if_needed()
1853
+
1854
+ try:
1855
+ response = self.session.get(
1856
+ f"{self.service_url}/publicapi/v1/testmgt/request/get/{request_id}/cells",
1857
+ headers=self.headers,
1858
+ timeout=timeout,
1859
+ )
1860
+ response.raise_for_status()
1861
+
1862
+ items = response.json().get('items', [])
1863
+ if return_format == 'df':
1864
+ return pd.DataFrame(items)
1865
+
1866
+ return items
1867
+
1868
+ except requests.exceptions.RequestException as e:
1869
+ if hasattr(e, 'response') and e.response is not None and e.response.status_code == 404:
1870
+ raise RuntimeError(f"Test request {request_id} not found.")
1871
+ raise self._format_request_error(e)
1872
+
1717
1873
  def download_parquet_file(self,
1718
1874
  cell_data_id: str,
1719
1875
  cycle_ranges: list = None,
@@ -2363,6 +2519,31 @@ class MicantisAPI:
2363
2519
  action["name"] = name
2364
2520
  return action
2365
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
+
2366
2547
  @staticmethod
2367
2548
  def reorder_columns(source_column_indices, name=None):
2368
2549
  """Build a reorder-columns action for clean_data (MetadataEdit mode).
@@ -2419,6 +2600,37 @@ class MicantisAPI:
2419
2600
  action["columns"] = [{"columnIndex": int(k), "value": float(v)} for k, v in columns.items()]
2420
2601
  return action
2421
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
+
2422
2634
  def clean_data(self,
2423
2635
  source_ids: list,
2424
2636
  mode: str = 'CycleAutoFixup',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: micantis
3
- Version: 1.2.5
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
 
@@ -500,6 +525,24 @@ print(f"Retrieved {len(combined_df)} test requests")
500
525
  combined_df.head()
501
526
  ```
502
527
 
528
+ ### Get Test Request Member Cells
529
+ Retrieve the actual cells linked to a test request. Note that the `tests` array from `get_test_request()` is the test plan (names and quantities) — it contains no cell IDs.
530
+
531
+ ```python
532
+ # DataFrame with one row per member cell (default)
533
+ cells_df = api.get_test_request_cells(request_id)
534
+ cells_df.head() # columns: id, name, updated, cycleCount, hasGraphData
535
+
536
+ # Raw list of dicts
537
+ cells = api.get_test_request_cells(request_id, return_format='list')
538
+ ```
539
+
540
+ ```python
541
+ # Use the cell IDs directly — e.g. fetch metadata for every cell in the request
542
+ cell_ids = cells_df['id'].to_list()
543
+ metadata_df = api.get_cell_metadata(cell_ids=cell_ids, metadata=["Cell height"])
544
+ ```
545
+
503
546
  ## Write Cell Metadata
504
547
  Micantis lets you programmatically assign or update metadata for each cell using either:
505
548
  - the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
@@ -10,4 +10,6 @@ micantis.egg-info/dependency_links.txt
10
10
  micantis.egg-info/requires.txt
11
11
  micantis.egg-info/top_level.txt
12
12
  tests/test_batch_update_result.py
13
+ tests/test_get_test_request_cells.py
14
+ tests/test_token_provider.py
13
15
  tests/test_upload_execution_artifact.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "micantis"
3
- version = "1.2.5"
3
+ version = "1.2.7"
4
4
  description = "Package to simplify Micantis API usage"
5
5
  authors = [
6
6
  { name = "Mykela DeLuca", email = "mykela.deluca@micantis.io" }
@@ -0,0 +1,88 @@
1
+ """
2
+ Unit tests for get_test_request_cells.
3
+
4
+ Mocks the HTTP layer so it can run anywhere without a live server. Exercises:
5
+ - Default 'df' return format → DataFrame with one row per member cell
6
+ - Non-'df' return format → raw item list
7
+ - Empty membership → empty DataFrame
8
+ - 404 → P-03 convention: printed "not found" error, returns None
9
+
10
+ Run: pytest
11
+ """
12
+ import pandas as pd
13
+ import requests
14
+
15
+ from .test_batch_update_result import _make_api, _mock_response
16
+
17
+ REQUEST_ID = "11111111-2222-3333-4444-555555555555"
18
+
19
+ CELLS_BODY = {
20
+ "count": 2,
21
+ "items": [
22
+ {
23
+ "id": "aaaaaaaa-0000-0000-0000-000000000001",
24
+ "name": "IQC-EVE-20RM-HLM7-0001",
25
+ "updated": "2026-05-01T00:00:00Z",
26
+ "cycleCount": 12,
27
+ "hasGraphData": True,
28
+ },
29
+ {
30
+ "id": "aaaaaaaa-0000-0000-0000-000000000002",
31
+ "name": "IQC-EVE-20RM-HLM7-0002",
32
+ "updated": "2026-05-02T00:00:00Z",
33
+ "cycleCount": 0,
34
+ "hasGraphData": False,
35
+ },
36
+ ],
37
+ }
38
+
39
+
40
+ def test_default_df_returns_one_row_per_cell():
41
+ api = _make_api()
42
+ api.session.get.return_value = _mock_response(200, CELLS_BODY)
43
+
44
+ result = api.get_test_request_cells(REQUEST_ID)
45
+
46
+ assert isinstance(result, pd.DataFrame)
47
+ assert len(result) == 2
48
+ assert list(result.columns) == ["id", "name", "updated", "cycleCount", "hasGraphData"]
49
+ assert result["name"].tolist() == ["IQC-EVE-20RM-HLM7-0001", "IQC-EVE-20RM-HLM7-0002"]
50
+ url = api.session.get.call_args.args[0]
51
+ assert url == f"https://fake.test/publicapi/v1/testmgt/request/get/{REQUEST_ID}/cells"
52
+ print("PASS: default 'df' format → DataFrame with one row per member cell")
53
+
54
+
55
+ def test_raw_list_format_returns_items():
56
+ api = _make_api()
57
+ api.session.get.return_value = _mock_response(200, CELLS_BODY)
58
+
59
+ result = api.get_test_request_cells(REQUEST_ID, return_format="list")
60
+
61
+ assert result == CELLS_BODY["items"]
62
+ print("PASS: non-'df' format → raw item list")
63
+
64
+
65
+ def test_empty_membership_returns_empty_dataframe():
66
+ api = _make_api()
67
+ api.session.get.return_value = _mock_response(200, {"count": 0, "items": []})
68
+
69
+ result = api.get_test_request_cells(REQUEST_ID)
70
+
71
+ assert isinstance(result, pd.DataFrame)
72
+ assert result.empty
73
+ print("PASS: empty membership → empty DataFrame")
74
+
75
+
76
+ def test_404_prints_not_found_and_returns_none(capsys):
77
+ api = _make_api()
78
+ resp = _mock_response(404)
79
+ resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp)
80
+ api.session.get.return_value = resp
81
+
82
+ result = api.get_test_request_cells(REQUEST_ID)
83
+
84
+ assert result is None
85
+ out = capsys.readouterr().out
86
+ assert "get_test_request_cells failed" in out
87
+ assert f"Test request {REQUEST_ID} not found" in out
88
+ print("PASS: 404 → printed not-found error, returns None")
@@ -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