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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: micantis
3
- Version: 1.2.6
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
- 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
@@ -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.6
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
 
@@ -11,4 +11,5 @@ micantis.egg-info/requires.txt
11
11
  micantis.egg-info/top_level.txt
12
12
  tests/test_batch_update_result.py
13
13
  tests/test_get_test_request_cells.py
14
+ tests/test_token_provider.py
14
15
  tests/test_upload_execution_artifact.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "micantis"
3
- version = "1.2.6"
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,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