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.
- {micantis-1.2.5 → micantis-1.2.7}/PKG-INFO +45 -2
- {micantis-1.2.5 → micantis-1.2.7}/README.md +44 -1
- {micantis-1.2.5 → micantis-1.2.7}/micantis/api_wrapper.py +215 -3
- {micantis-1.2.5 → micantis-1.2.7}/micantis.egg-info/PKG-INFO +45 -2
- {micantis-1.2.5 → micantis-1.2.7}/micantis.egg-info/SOURCES.txt +2 -0
- {micantis-1.2.5 → micantis-1.2.7}/pyproject.toml +1 -1
- micantis-1.2.7/tests/test_get_test_request_cells.py +88 -0
- micantis-1.2.7/tests/test_token_provider.py +148 -0
- {micantis-1.2.5 → micantis-1.2.7}/LICENSE +0 -0
- {micantis-1.2.5 → micantis-1.2.7}/micantis/__init__.py +0 -0
- {micantis-1.2.5 → micantis-1.2.7}/micantis/utils.py +0 -0
- {micantis-1.2.5 → micantis-1.2.7}/micantis.egg-info/dependency_links.txt +0 -0
- {micantis-1.2.5 → micantis-1.2.7}/micantis.egg-info/requires.txt +0 -0
- {micantis-1.2.5 → micantis-1.2.7}/micantis.egg-info/top_level.txt +0 -0
- {micantis-1.2.5 → micantis-1.2.7}/setup.cfg +0 -0
- {micantis-1.2.5 → micantis-1.2.7}/tests/test_batch_update_result.py +0 -0
- {micantis-1.2.5 → 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
|
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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.
|
|
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"`
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|