micantis 1.2.4__tar.gz → 1.2.6__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.4 → micantis-1.2.6}/PKG-INFO +28 -11
- {micantis-1.2.4 → micantis-1.2.6}/README.md +27 -10
- {micantis-1.2.4 → micantis-1.2.6}/micantis/api_wrapper.py +257 -8
- {micantis-1.2.4 → micantis-1.2.6}/micantis.egg-info/PKG-INFO +28 -11
- {micantis-1.2.4 → micantis-1.2.6}/micantis.egg-info/SOURCES.txt +3 -1
- {micantis-1.2.4 → micantis-1.2.6}/pyproject.toml +1 -1
- micantis-1.2.6/tests/test_get_test_request_cells.py +88 -0
- micantis-1.2.6/tests/test_upload_execution_artifact.py +110 -0
- {micantis-1.2.4 → micantis-1.2.6}/LICENSE +0 -0
- {micantis-1.2.4 → micantis-1.2.6}/micantis/__init__.py +0 -0
- {micantis-1.2.4 → micantis-1.2.6}/micantis/utils.py +0 -0
- {micantis-1.2.4 → micantis-1.2.6}/micantis.egg-info/dependency_links.txt +0 -0
- {micantis-1.2.4 → micantis-1.2.6}/micantis.egg-info/requires.txt +0 -0
- {micantis-1.2.4 → micantis-1.2.6}/micantis.egg-info/top_level.txt +0 -0
- {micantis-1.2.4 → micantis-1.2.6}/setup.cfg +0 -0
- {micantis-1.2.4 → micantis-1.2.6}/tests/test_batch_update_result.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.6
|
|
4
4
|
Summary: Package to simplify Micantis API usage
|
|
5
5
|
Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -500,6 +500,24 @@ print(f"Retrieved {len(combined_df)} test requests")
|
|
|
500
500
|
combined_df.head()
|
|
501
501
|
```
|
|
502
502
|
|
|
503
|
+
### Get Test Request Member Cells
|
|
504
|
+
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.
|
|
505
|
+
|
|
506
|
+
```python
|
|
507
|
+
# DataFrame with one row per member cell (default)
|
|
508
|
+
cells_df = api.get_test_request_cells(request_id)
|
|
509
|
+
cells_df.head() # columns: id, name, updated, cycleCount, hasGraphData
|
|
510
|
+
|
|
511
|
+
# Raw list of dicts
|
|
512
|
+
cells = api.get_test_request_cells(request_id, return_format='list')
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
```python
|
|
516
|
+
# Use the cell IDs directly — e.g. fetch metadata for every cell in the request
|
|
517
|
+
cell_ids = cells_df['id'].to_list()
|
|
518
|
+
metadata_df = api.get_cell_metadata(cell_ids=cell_ids, metadata=["Cell height"])
|
|
519
|
+
```
|
|
520
|
+
|
|
503
521
|
## Write Cell Metadata
|
|
504
522
|
Micantis lets you programmatically assign or update metadata for each cell using either:
|
|
505
523
|
- the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
|
|
@@ -1013,14 +1031,16 @@ for group in dupes:
|
|
|
1013
1031
|
Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python script execution. Called from within a Python script running in the Micantis environment.
|
|
1014
1032
|
|
|
1015
1033
|
#### Parameters
|
|
1016
|
-
- `execution_id`: **str (
|
|
1017
|
-
The execution ID (GUID) of the running or completed execution
|
|
1034
|
+
- `execution_id`: **str (optional inside cloud executions)**
|
|
1035
|
+
The execution ID (GUID) of the running or completed execution. Defaults to the `WORKBOOK_EXECUTION_ID` environment variable, which cloud executions always set; required when running elsewhere. A non-GUID first positional argument is treated as `file_path`
|
|
1018
1036
|
- `file_path`: **str (required)**
|
|
1019
1037
|
Local path to the file to upload. Maximum size: 50 MB
|
|
1020
1038
|
- `title`: **str (required)**
|
|
1021
1039
|
Human-readable title for the artifact
|
|
1022
1040
|
- `filename`: **str (optional)**
|
|
1023
1041
|
Override the filename stored on the server. If not provided, derived from the title and file extension
|
|
1042
|
+
- `mime`: **str (optional)**
|
|
1043
|
+
Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
|
|
1024
1044
|
|
|
1025
1045
|
#### Returns
|
|
1026
1046
|
- Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
|
|
@@ -1028,22 +1048,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
|
|
|
1028
1048
|
#### 📘 Examples
|
|
1029
1049
|
|
|
1030
1050
|
```python
|
|
1031
|
-
#
|
|
1032
|
-
result = api.upload_execution_artifact(
|
|
1033
|
-
execution_id="your-execution-guid",
|
|
1034
|
-
file_path="voltage_plot.png",
|
|
1035
|
-
title="Voltage vs Time"
|
|
1036
|
-
)
|
|
1051
|
+
# Inside a cloud execution — execution_id is resolved from the environment
|
|
1052
|
+
result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
|
|
1037
1053
|
print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
|
|
1038
1054
|
```
|
|
1039
1055
|
|
|
1040
1056
|
```python
|
|
1041
|
-
#
|
|
1057
|
+
# Explicit execution ID with a custom filename and content type
|
|
1042
1058
|
result = api.upload_execution_artifact(
|
|
1043
1059
|
execution_id="your-execution-guid",
|
|
1044
1060
|
file_path="results.csv",
|
|
1045
1061
|
title="Cycle Summary Results",
|
|
1046
|
-
filename="cycle_summary_2025.csv"
|
|
1062
|
+
filename="cycle_summary_2025.csv",
|
|
1063
|
+
mime="text/csv"
|
|
1047
1064
|
)
|
|
1048
1065
|
```
|
|
1049
1066
|
|
|
@@ -480,6 +480,24 @@ print(f"Retrieved {len(combined_df)} test requests")
|
|
|
480
480
|
combined_df.head()
|
|
481
481
|
```
|
|
482
482
|
|
|
483
|
+
### Get Test Request Member Cells
|
|
484
|
+
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.
|
|
485
|
+
|
|
486
|
+
```python
|
|
487
|
+
# DataFrame with one row per member cell (default)
|
|
488
|
+
cells_df = api.get_test_request_cells(request_id)
|
|
489
|
+
cells_df.head() # columns: id, name, updated, cycleCount, hasGraphData
|
|
490
|
+
|
|
491
|
+
# Raw list of dicts
|
|
492
|
+
cells = api.get_test_request_cells(request_id, return_format='list')
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
```python
|
|
496
|
+
# Use the cell IDs directly — e.g. fetch metadata for every cell in the request
|
|
497
|
+
cell_ids = cells_df['id'].to_list()
|
|
498
|
+
metadata_df = api.get_cell_metadata(cell_ids=cell_ids, metadata=["Cell height"])
|
|
499
|
+
```
|
|
500
|
+
|
|
483
501
|
## Write Cell Metadata
|
|
484
502
|
Micantis lets you programmatically assign or update metadata for each cell using either:
|
|
485
503
|
- the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
|
|
@@ -993,14 +1011,16 @@ for group in dupes:
|
|
|
993
1011
|
Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python script execution. Called from within a Python script running in the Micantis environment.
|
|
994
1012
|
|
|
995
1013
|
#### Parameters
|
|
996
|
-
- `execution_id`: **str (
|
|
997
|
-
The execution ID (GUID) of the running or completed execution
|
|
1014
|
+
- `execution_id`: **str (optional inside cloud executions)**
|
|
1015
|
+
The execution ID (GUID) of the running or completed execution. Defaults to the `WORKBOOK_EXECUTION_ID` environment variable, which cloud executions always set; required when running elsewhere. A non-GUID first positional argument is treated as `file_path`
|
|
998
1016
|
- `file_path`: **str (required)**
|
|
999
1017
|
Local path to the file to upload. Maximum size: 50 MB
|
|
1000
1018
|
- `title`: **str (required)**
|
|
1001
1019
|
Human-readable title for the artifact
|
|
1002
1020
|
- `filename`: **str (optional)**
|
|
1003
1021
|
Override the filename stored on the server. If not provided, derived from the title and file extension
|
|
1022
|
+
- `mime`: **str (optional)**
|
|
1023
|
+
Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
|
|
1004
1024
|
|
|
1005
1025
|
#### Returns
|
|
1006
1026
|
- Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
|
|
@@ -1008,22 +1028,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
|
|
|
1008
1028
|
#### 📘 Examples
|
|
1009
1029
|
|
|
1010
1030
|
```python
|
|
1011
|
-
#
|
|
1012
|
-
result = api.upload_execution_artifact(
|
|
1013
|
-
execution_id="your-execution-guid",
|
|
1014
|
-
file_path="voltage_plot.png",
|
|
1015
|
-
title="Voltage vs Time"
|
|
1016
|
-
)
|
|
1031
|
+
# Inside a cloud execution — execution_id is resolved from the environment
|
|
1032
|
+
result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
|
|
1017
1033
|
print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
|
|
1018
1034
|
```
|
|
1019
1035
|
|
|
1020
1036
|
```python
|
|
1021
|
-
#
|
|
1037
|
+
# Explicit execution ID with a custom filename and content type
|
|
1022
1038
|
result = api.upload_execution_artifact(
|
|
1023
1039
|
execution_id="your-execution-guid",
|
|
1024
1040
|
file_path="results.csv",
|
|
1025
1041
|
title="Cycle Summary Results",
|
|
1026
|
-
filename="cycle_summary_2025.csv"
|
|
1042
|
+
filename="cycle_summary_2025.csv",
|
|
1043
|
+
mime="text/csv"
|
|
1027
1044
|
)
|
|
1028
1045
|
```
|
|
1029
1046
|
|
|
@@ -2,11 +2,15 @@ import requests
|
|
|
2
2
|
import pandas as pd
|
|
3
3
|
import io
|
|
4
4
|
import json
|
|
5
|
+
import mimetypes
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
7
8
|
import time
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
8
11
|
from datetime import datetime, timedelta, timezone
|
|
9
12
|
from typing import Optional
|
|
13
|
+
from urllib.parse import quote
|
|
10
14
|
from msal import PublicClientApplication
|
|
11
15
|
from azure.identity import (
|
|
12
16
|
ManagedIdentityCredential,
|
|
@@ -43,6 +47,26 @@ def _apply_field_overrides(action, voltage, current, charge_capacity,
|
|
|
43
47
|
action[key] = val
|
|
44
48
|
|
|
45
49
|
|
|
50
|
+
def _is_guid(value) -> bool:
|
|
51
|
+
"""True if value parses as a GUID — the format of execution IDs."""
|
|
52
|
+
try:
|
|
53
|
+
uuid.UUID(str(value))
|
|
54
|
+
return True
|
|
55
|
+
except ValueError:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Content types that must not depend on the host OS mime registry
|
|
60
|
+
# (Windows maps .csv to application/vnd.ms-excel, stored as an opaque artifact).
|
|
61
|
+
_MIME_OVERRIDES = {
|
|
62
|
+
".csv": "text/csv",
|
|
63
|
+
".json": "application/json",
|
|
64
|
+
".txt": "text/plain",
|
|
65
|
+
".md": "text/markdown",
|
|
66
|
+
".svg": "image/svg+xml",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
46
70
|
class BatchUpdateResult:
|
|
47
71
|
"""
|
|
48
72
|
Result of a batch metadata update call (`write_cell_metadata` / `write_data_metadata`).
|
|
@@ -1690,6 +1714,72 @@ class MicantisAPI:
|
|
|
1690
1714
|
raise RuntimeError(f"Test request {request_id} not found.")
|
|
1691
1715
|
raise self._format_request_error(e)
|
|
1692
1716
|
|
|
1717
|
+
def get_test_request_cells(self, request_id, return_format='df', timeout: int = 30):
|
|
1718
|
+
"""
|
|
1719
|
+
Get the member cells of a test request.
|
|
1720
|
+
|
|
1721
|
+
These are the actual cells linked to the request. The `tests` array returned by
|
|
1722
|
+
get_test_request() is the test plan (names and quantities) and contains no cell IDs.
|
|
1723
|
+
|
|
1724
|
+
Automatically reauthenticates if the session is expired or missing.
|
|
1725
|
+
Prints an error message and returns None if the request ultimately fails.
|
|
1726
|
+
|
|
1727
|
+
Parameters
|
|
1728
|
+
----------
|
|
1729
|
+
request_id : str
|
|
1730
|
+
GUID of the test request.
|
|
1731
|
+
return_format : str, optional
|
|
1732
|
+
Return format: 'df' for DataFrame (default), anything else for the raw item list.
|
|
1733
|
+
timeout : int, optional
|
|
1734
|
+
Request timeout in seconds. Default: 30.
|
|
1735
|
+
|
|
1736
|
+
Returns
|
|
1737
|
+
-------
|
|
1738
|
+
pd.DataFrame, list, or None
|
|
1739
|
+
One row/dict per member cell with `id`, `name`, `updated`, `cycleCount`,
|
|
1740
|
+
and `hasGraphData`. Returns None if an error occurs.
|
|
1741
|
+
"""
|
|
1742
|
+
try:
|
|
1743
|
+
return self._retry_on_auth_failure(
|
|
1744
|
+
self._get_test_request_cells, request_id, return_format, timeout
|
|
1745
|
+
)
|
|
1746
|
+
except Exception as e:
|
|
1747
|
+
print(f"⚠️ get_test_request_cells failed: {e}")
|
|
1748
|
+
return None
|
|
1749
|
+
|
|
1750
|
+
def _get_test_request_cells(self, request_id, return_format: str, timeout: int):
|
|
1751
|
+
"""
|
|
1752
|
+
Internal helper for get_test_request_cells. Does not handle retries or printing.
|
|
1753
|
+
|
|
1754
|
+
Raises
|
|
1755
|
+
------
|
|
1756
|
+
RuntimeError
|
|
1757
|
+
If the request fails or authentication is missing.
|
|
1758
|
+
"""
|
|
1759
|
+
if not self.headers:
|
|
1760
|
+
raise RuntimeError("Authentication required. Call authenticate() first.")
|
|
1761
|
+
|
|
1762
|
+
self._refresh_token_if_needed()
|
|
1763
|
+
|
|
1764
|
+
try:
|
|
1765
|
+
response = self.session.get(
|
|
1766
|
+
f"{self.service_url}/publicapi/v1/testmgt/request/get/{request_id}/cells",
|
|
1767
|
+
headers=self.headers,
|
|
1768
|
+
timeout=timeout,
|
|
1769
|
+
)
|
|
1770
|
+
response.raise_for_status()
|
|
1771
|
+
|
|
1772
|
+
items = response.json().get('items', [])
|
|
1773
|
+
if return_format == 'df':
|
|
1774
|
+
return pd.DataFrame(items)
|
|
1775
|
+
|
|
1776
|
+
return items
|
|
1777
|
+
|
|
1778
|
+
except requests.exceptions.RequestException as e:
|
|
1779
|
+
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 404:
|
|
1780
|
+
raise RuntimeError(f"Test request {request_id} not found.")
|
|
1781
|
+
raise self._format_request_error(e)
|
|
1782
|
+
|
|
1693
1783
|
def download_parquet_file(self,
|
|
1694
1784
|
cell_data_id: str,
|
|
1695
1785
|
cycle_ranges: list = None,
|
|
@@ -2794,21 +2884,34 @@ class MicantisAPI:
|
|
|
2794
2884
|
raise self._format_request_error(e)
|
|
2795
2885
|
|
|
2796
2886
|
def upload_execution_artifact(self,
|
|
2797
|
-
execution_id: str,
|
|
2798
|
-
file_path: str,
|
|
2799
|
-
title: str,
|
|
2887
|
+
execution_id: str = None,
|
|
2888
|
+
file_path: str = None,
|
|
2889
|
+
title: str = None,
|
|
2800
2890
|
filename: str = None,
|
|
2801
|
-
timeout: int = 120
|
|
2891
|
+
timeout: int = 120,
|
|
2892
|
+
mime: str = None) -> dict:
|
|
2802
2893
|
"""
|
|
2803
2894
|
Upload an artifact (output file) produced by a running Python execution.
|
|
2804
2895
|
|
|
2896
|
+
Inside a cloud execution, execution_id can be omitted — it is resolved
|
|
2897
|
+
from the WORKBOOK_EXECUTION_ID environment variable, which the runner
|
|
2898
|
+
always sets. The natural call shape there is::
|
|
2899
|
+
|
|
2900
|
+
api.upload_execution_artifact('/tmp/plot.png', title='My Plot')
|
|
2901
|
+
|
|
2902
|
+
A first positional argument that is not a GUID is treated as file_path
|
|
2903
|
+
(when file_path is not also given), so the line above works even though
|
|
2904
|
+
the first parameter is execution_id.
|
|
2905
|
+
|
|
2805
2906
|
Automatically reauthenticates if the session is expired or missing.
|
|
2806
2907
|
Prints an error message and returns None if the request ultimately fails.
|
|
2807
2908
|
|
|
2808
2909
|
Parameters
|
|
2809
2910
|
----------
|
|
2810
|
-
execution_id : str
|
|
2911
|
+
execution_id : str, optional
|
|
2811
2912
|
The execution ID (GUID) of the running or completed Python execution.
|
|
2913
|
+
Defaults to the WORKBOOK_EXECUTION_ID environment variable (always
|
|
2914
|
+
set inside cloud executions). Required when running outside one.
|
|
2812
2915
|
file_path : str
|
|
2813
2916
|
Local path to the file to upload (PNG, CSV, XLSX, JSON, PDF, etc.).
|
|
2814
2917
|
Maximum file size: 50 MB.
|
|
@@ -2819,6 +2922,10 @@ class MicantisAPI:
|
|
|
2819
2922
|
from the title and file extension.
|
|
2820
2923
|
timeout : int, optional
|
|
2821
2924
|
Request timeout in seconds. Default: 120.
|
|
2925
|
+
mime : str, optional
|
|
2926
|
+
Content type stored for the artifact (e.g. 'image/png'). If not
|
|
2927
|
+
provided, guessed from the file_path extension, falling back to
|
|
2928
|
+
'application/octet-stream'.
|
|
2822
2929
|
|
|
2823
2930
|
Returns
|
|
2824
2931
|
-------
|
|
@@ -2834,6 +2941,7 @@ class MicantisAPI:
|
|
|
2834
2941
|
title,
|
|
2835
2942
|
filename,
|
|
2836
2943
|
timeout,
|
|
2944
|
+
mime,
|
|
2837
2945
|
)
|
|
2838
2946
|
except Exception as e:
|
|
2839
2947
|
print(f"⚠️ upload_execution_artifact failed: {e}")
|
|
@@ -2844,28 +2952,61 @@ class MicantisAPI:
|
|
|
2844
2952
|
file_path: str,
|
|
2845
2953
|
title: str,
|
|
2846
2954
|
filename: str,
|
|
2847
|
-
timeout: int
|
|
2955
|
+
timeout: int,
|
|
2956
|
+
mime: str) -> dict:
|
|
2848
2957
|
"""
|
|
2849
2958
|
Internal helper for upload_execution_artifact. Does not handle retries or printing.
|
|
2850
2959
|
|
|
2851
2960
|
Raises
|
|
2852
2961
|
------
|
|
2853
2962
|
RuntimeError
|
|
2854
|
-
If the request fails or
|
|
2963
|
+
If arguments are missing or invalid, the request fails, or
|
|
2964
|
+
authentication is missing.
|
|
2855
2965
|
"""
|
|
2856
2966
|
if not self.headers:
|
|
2857
2967
|
raise RuntimeError("Authentication required. Call authenticate() first.")
|
|
2858
2968
|
|
|
2859
2969
|
self._refresh_token_if_needed()
|
|
2860
2970
|
|
|
2971
|
+
# Forgive the natural call shape api.upload_execution_artifact('/tmp/plot.png', title=...):
|
|
2972
|
+
# a non-GUID first argument is the file path, and the execution ID comes from the environment.
|
|
2973
|
+
if execution_id is not None and not _is_guid(execution_id):
|
|
2974
|
+
if file_path is None:
|
|
2975
|
+
file_path = execution_id
|
|
2976
|
+
execution_id = None
|
|
2977
|
+
else:
|
|
2978
|
+
raise RuntimeError(
|
|
2979
|
+
f"execution_id '{execution_id}' is not an execution GUID. Inside a cloud "
|
|
2980
|
+
"execution you can omit execution_id entirely — it is read from the "
|
|
2981
|
+
"WORKBOOK_EXECUTION_ID environment variable automatically."
|
|
2982
|
+
)
|
|
2983
|
+
|
|
2984
|
+
if execution_id is None:
|
|
2985
|
+
execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
|
|
2986
|
+
if not execution_id:
|
|
2987
|
+
raise RuntimeError(
|
|
2988
|
+
"No execution_id provided and WORKBOOK_EXECUTION_ID is not set. Inside a "
|
|
2989
|
+
"cloud execution the variable is set automatically; outside one, pass "
|
|
2990
|
+
"execution_id explicitly."
|
|
2991
|
+
)
|
|
2992
|
+
|
|
2993
|
+
if not file_path:
|
|
2994
|
+
raise RuntimeError("file_path is required.")
|
|
2995
|
+
if not title:
|
|
2996
|
+
raise RuntimeError("title is required.")
|
|
2997
|
+
|
|
2861
2998
|
url = f"{self.service_url}/publicapi/v1/python/executions/{execution_id}/artifacts"
|
|
2862
2999
|
|
|
3000
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
3001
|
+
content_type = (mime or _MIME_OVERRIDES.get(ext)
|
|
3002
|
+
or mimetypes.guess_type(file_path)[0] or "application/octet-stream")
|
|
3003
|
+
|
|
2863
3004
|
try:
|
|
2864
3005
|
with open(file_path, 'rb') as f:
|
|
2865
3006
|
form_data = {'title': (None, title)}
|
|
2866
3007
|
if filename is not None:
|
|
2867
3008
|
form_data['filename'] = (None, filename)
|
|
2868
|
-
form_data['file'] = (os.path.basename(file_path), f)
|
|
3009
|
+
form_data['file'] = (os.path.basename(file_path), f, content_type)
|
|
2869
3010
|
|
|
2870
3011
|
# Exclude Content-Type so requests sets the multipart boundary correctly
|
|
2871
3012
|
headers = {k: v for k, v in self.headers.items() if k.lower() != 'content-type'}
|
|
@@ -2882,6 +3023,114 @@ class MicantisAPI:
|
|
|
2882
3023
|
except requests.exceptions.RequestException as e:
|
|
2883
3024
|
raise self._format_request_error(e)
|
|
2884
3025
|
|
|
3026
|
+
def list_attachments(self, timeout: int = 30) -> list:
|
|
3027
|
+
"""
|
|
3028
|
+
List the files attached to the Moose conversation that launched this script.
|
|
3029
|
+
|
|
3030
|
+
Only works inside a Moose-triggered cloud execution (uses WORKBOOK_EXECUTION_ID).
|
|
3031
|
+
Automatically reauthenticates if the session is expired or missing. Prints an error
|
|
3032
|
+
and returns None on failure.
|
|
3033
|
+
|
|
3034
|
+
Returns
|
|
3035
|
+
-------
|
|
3036
|
+
list of dict or None
|
|
3037
|
+
One entry per attachment: {"name", "contentType", "sizeBytes"}.
|
|
3038
|
+
"""
|
|
3039
|
+
try:
|
|
3040
|
+
return self._retry_on_auth_failure(self._list_attachments, timeout)
|
|
3041
|
+
except Exception as e:
|
|
3042
|
+
print(f"⚠️ list_attachments failed: {e}")
|
|
3043
|
+
return None
|
|
3044
|
+
|
|
3045
|
+
def _list_attachments(self, timeout: int) -> list:
|
|
3046
|
+
if not self.headers:
|
|
3047
|
+
raise RuntimeError("Authentication required. Call authenticate() first.")
|
|
3048
|
+
self._refresh_token_if_needed()
|
|
3049
|
+
|
|
3050
|
+
execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
|
|
3051
|
+
if not execution_id:
|
|
3052
|
+
raise RuntimeError(
|
|
3053
|
+
"list_attachments is only available inside a cloud execution (WORKBOOK_EXECUTION_ID is not set)."
|
|
3054
|
+
)
|
|
3055
|
+
|
|
3056
|
+
try:
|
|
3057
|
+
r = self.session.get(
|
|
3058
|
+
f"{self.service_url}/publicapi/v1/python/executions/{execution_id}/attachments",
|
|
3059
|
+
headers=self.headers,
|
|
3060
|
+
timeout=timeout,
|
|
3061
|
+
)
|
|
3062
|
+
r.raise_for_status()
|
|
3063
|
+
return r.json()
|
|
3064
|
+
except requests.exceptions.RequestException as e:
|
|
3065
|
+
raise self._format_request_error(e)
|
|
3066
|
+
|
|
3067
|
+
def download_attachment(self, name: str, output_path: str = None,
|
|
3068
|
+
return_type: str = "bytes", timeout: int = 120):
|
|
3069
|
+
"""
|
|
3070
|
+
Download a file attached to the Moose conversation that launched this script, by name,
|
|
3071
|
+
WITHOUT inlining its bytes into your script source. Use this for any conversation
|
|
3072
|
+
attachment too large to paste in — inlining data into the script is bounded by the
|
|
3073
|
+
per-turn output budget (a few KB), but this streams the file into the container.
|
|
3074
|
+
|
|
3075
|
+
Only works inside a Moose-triggered cloud execution (uses WORKBOOK_EXECUTION_ID). Call
|
|
3076
|
+
list_attachments() first to get exact names. Automatically reauthenticates if the
|
|
3077
|
+
session is expired or missing. Prints an error and returns None on failure.
|
|
3078
|
+
|
|
3079
|
+
Parameters
|
|
3080
|
+
----------
|
|
3081
|
+
name : str
|
|
3082
|
+
Exact attachment filename (from list_attachments()).
|
|
3083
|
+
output_path : str, optional
|
|
3084
|
+
Where to save the file when return_type='path'. Defaults to a temp file.
|
|
3085
|
+
return_type : str, optional
|
|
3086
|
+
'bytes' (default) returns the raw bytes; 'path' writes the file to disk and returns
|
|
3087
|
+
the path (best for large files); 'text' returns a UTF-8-decoded str.
|
|
3088
|
+
timeout : int, optional
|
|
3089
|
+
Request timeout in seconds. Default: 120.
|
|
3090
|
+
|
|
3091
|
+
Returns
|
|
3092
|
+
-------
|
|
3093
|
+
bytes | str | None
|
|
3094
|
+
The attachment content per return_type, or None on failure.
|
|
3095
|
+
"""
|
|
3096
|
+
try:
|
|
3097
|
+
return self._retry_on_auth_failure(
|
|
3098
|
+
self._download_attachment, name, output_path, return_type, timeout)
|
|
3099
|
+
except Exception as e:
|
|
3100
|
+
print(f"⚠️ download_attachment failed: {e}")
|
|
3101
|
+
return None
|
|
3102
|
+
|
|
3103
|
+
def _download_attachment(self, name: str, output_path: str, return_type: str, timeout: int):
|
|
3104
|
+
if not self.headers:
|
|
3105
|
+
raise RuntimeError("Authentication required. Call authenticate() first.")
|
|
3106
|
+
self._refresh_token_if_needed()
|
|
3107
|
+
|
|
3108
|
+
execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
|
|
3109
|
+
if not execution_id:
|
|
3110
|
+
raise RuntimeError(
|
|
3111
|
+
"download_attachment is only available inside a cloud execution (WORKBOOK_EXECUTION_ID is not set)."
|
|
3112
|
+
)
|
|
3113
|
+
|
|
3114
|
+
url = (f"{self.service_url}/publicapi/v1/python/executions/{execution_id}"
|
|
3115
|
+
f"/attachments/{quote(name)}")
|
|
3116
|
+
|
|
3117
|
+
try:
|
|
3118
|
+
with self.session.get(url, headers=self.headers, timeout=timeout, stream=True) as r:
|
|
3119
|
+
r.raise_for_status()
|
|
3120
|
+
if return_type == "path":
|
|
3121
|
+
path = output_path or os.path.join(tempfile.gettempdir(), os.path.basename(name))
|
|
3122
|
+
with open(path, "wb") as f:
|
|
3123
|
+
for chunk in r.iter_content(chunk_size=1024 * 1024):
|
|
3124
|
+
if chunk:
|
|
3125
|
+
f.write(chunk)
|
|
3126
|
+
return path
|
|
3127
|
+
content = r.content
|
|
3128
|
+
if return_type == "text":
|
|
3129
|
+
return content.decode("utf-8", errors="replace")
|
|
3130
|
+
return content
|
|
3131
|
+
except requests.exceptions.RequestException as e:
|
|
3132
|
+
raise self._format_request_error(e)
|
|
3133
|
+
|
|
2885
3134
|
# ─────────────────────────────────────────
|
|
2886
3135
|
# Job management (async operation polling)
|
|
2887
3136
|
# ─────────────────────────────────────────
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: micantis
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.6
|
|
4
4
|
Summary: Package to simplify Micantis API usage
|
|
5
5
|
Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -500,6 +500,24 @@ print(f"Retrieved {len(combined_df)} test requests")
|
|
|
500
500
|
combined_df.head()
|
|
501
501
|
```
|
|
502
502
|
|
|
503
|
+
### Get Test Request Member Cells
|
|
504
|
+
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.
|
|
505
|
+
|
|
506
|
+
```python
|
|
507
|
+
# DataFrame with one row per member cell (default)
|
|
508
|
+
cells_df = api.get_test_request_cells(request_id)
|
|
509
|
+
cells_df.head() # columns: id, name, updated, cycleCount, hasGraphData
|
|
510
|
+
|
|
511
|
+
# Raw list of dicts
|
|
512
|
+
cells = api.get_test_request_cells(request_id, return_format='list')
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
```python
|
|
516
|
+
# Use the cell IDs directly — e.g. fetch metadata for every cell in the request
|
|
517
|
+
cell_ids = cells_df['id'].to_list()
|
|
518
|
+
metadata_df = api.get_cell_metadata(cell_ids=cell_ids, metadata=["Cell height"])
|
|
519
|
+
```
|
|
520
|
+
|
|
503
521
|
## Write Cell Metadata
|
|
504
522
|
Micantis lets you programmatically assign or update metadata for each cell using either:
|
|
505
523
|
- the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
|
|
@@ -1013,14 +1031,16 @@ for group in dupes:
|
|
|
1013
1031
|
Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python script execution. Called from within a Python script running in the Micantis environment.
|
|
1014
1032
|
|
|
1015
1033
|
#### Parameters
|
|
1016
|
-
- `execution_id`: **str (
|
|
1017
|
-
The execution ID (GUID) of the running or completed execution
|
|
1034
|
+
- `execution_id`: **str (optional inside cloud executions)**
|
|
1035
|
+
The execution ID (GUID) of the running or completed execution. Defaults to the `WORKBOOK_EXECUTION_ID` environment variable, which cloud executions always set; required when running elsewhere. A non-GUID first positional argument is treated as `file_path`
|
|
1018
1036
|
- `file_path`: **str (required)**
|
|
1019
1037
|
Local path to the file to upload. Maximum size: 50 MB
|
|
1020
1038
|
- `title`: **str (required)**
|
|
1021
1039
|
Human-readable title for the artifact
|
|
1022
1040
|
- `filename`: **str (optional)**
|
|
1023
1041
|
Override the filename stored on the server. If not provided, derived from the title and file extension
|
|
1042
|
+
- `mime`: **str (optional)**
|
|
1043
|
+
Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
|
|
1024
1044
|
|
|
1025
1045
|
#### Returns
|
|
1026
1046
|
- Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
|
|
@@ -1028,22 +1048,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
|
|
|
1028
1048
|
#### 📘 Examples
|
|
1029
1049
|
|
|
1030
1050
|
```python
|
|
1031
|
-
#
|
|
1032
|
-
result = api.upload_execution_artifact(
|
|
1033
|
-
execution_id="your-execution-guid",
|
|
1034
|
-
file_path="voltage_plot.png",
|
|
1035
|
-
title="Voltage vs Time"
|
|
1036
|
-
)
|
|
1051
|
+
# Inside a cloud execution — execution_id is resolved from the environment
|
|
1052
|
+
result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
|
|
1037
1053
|
print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
|
|
1038
1054
|
```
|
|
1039
1055
|
|
|
1040
1056
|
```python
|
|
1041
|
-
#
|
|
1057
|
+
# Explicit execution ID with a custom filename and content type
|
|
1042
1058
|
result = api.upload_execution_artifact(
|
|
1043
1059
|
execution_id="your-execution-guid",
|
|
1044
1060
|
file_path="results.csv",
|
|
1045
1061
|
title="Cycle Summary Results",
|
|
1046
|
-
filename="cycle_summary_2025.csv"
|
|
1062
|
+
filename="cycle_summary_2025.csv",
|
|
1063
|
+
mime="text/csv"
|
|
1047
1064
|
)
|
|
1048
1065
|
```
|
|
1049
1066
|
|
|
@@ -9,4 +9,6 @@ micantis.egg-info/SOURCES.txt
|
|
|
9
9
|
micantis.egg-info/dependency_links.txt
|
|
10
10
|
micantis.egg-info/requires.txt
|
|
11
11
|
micantis.egg-info/top_level.txt
|
|
12
|
-
tests/test_batch_update_result.py
|
|
12
|
+
tests/test_batch_update_result.py
|
|
13
|
+
tests/test_get_test_request_cells.py
|
|
14
|
+
tests/test_upload_execution_artifact.py
|
|
@@ -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,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for upload_execution_artifact argument resolution and multipart content type.
|
|
3
|
+
|
|
4
|
+
Mocks the HTTP layer so it can run anywhere without a live server. Exercises the
|
|
5
|
+
four supported call shapes:
|
|
6
|
+
- Path-only positional + title + mime → execution_id resolved from WORKBOOK_EXECUTION_ID
|
|
7
|
+
- Classic positional (guid, path, title) → unchanged, content type guessed from extension
|
|
8
|
+
- Full keyword form → unchanged
|
|
9
|
+
- No execution_id anywhere and no env var → clear printed error, returns None
|
|
10
|
+
|
|
11
|
+
Run: pytest
|
|
12
|
+
"""
|
|
13
|
+
from .test_batch_update_result import _make_api, _mock_response
|
|
14
|
+
|
|
15
|
+
EXEC_ID = "11111111-2222-3333-4444-555555555555"
|
|
16
|
+
OTHER_EXEC_ID = "99999999-8888-7777-6666-555555555555"
|
|
17
|
+
|
|
18
|
+
RESULT_BODY = {
|
|
19
|
+
"name": "plot.png",
|
|
20
|
+
"title": "My Plot",
|
|
21
|
+
"contentType": "image/png",
|
|
22
|
+
"sizeBytes": 9,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_path_first_positional_resolves_execution_id_from_env(monkeypatch, tmp_path):
|
|
27
|
+
# The natural cloud call shape: file path first, no execution_id anywhere.
|
|
28
|
+
api = _make_api()
|
|
29
|
+
api.session.post.return_value = _mock_response(200, RESULT_BODY)
|
|
30
|
+
monkeypatch.setenv("WORKBOOK_EXECUTION_ID", EXEC_ID)
|
|
31
|
+
|
|
32
|
+
png = tmp_path / "plot.png"
|
|
33
|
+
png.write_bytes(b"\x89PNG fake")
|
|
34
|
+
|
|
35
|
+
result = api.upload_execution_artifact(str(png), title="My Plot", mime="image/png")
|
|
36
|
+
|
|
37
|
+
assert result == RESULT_BODY
|
|
38
|
+
url = api.session.post.call_args.args[0]
|
|
39
|
+
assert url == f"https://fake.test/publicapi/v1/python/executions/{EXEC_ID}/artifacts"
|
|
40
|
+
name, fobj, content_type = api.session.post.call_args.kwargs["files"]["file"]
|
|
41
|
+
assert name == "plot.png"
|
|
42
|
+
assert content_type == "image/png"
|
|
43
|
+
print("PASS: path-first positional resolves WORKBOOK_EXECUTION_ID and sends mime")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_classic_positional_shape_unchanged(monkeypatch, tmp_path):
|
|
47
|
+
# (execution_id, file_path, title) keeps working; explicit GUID wins over the env var,
|
|
48
|
+
# and the part content type is guessed from the file extension when mime is omitted.
|
|
49
|
+
api = _make_api()
|
|
50
|
+
api.session.post.return_value = _mock_response(200, RESULT_BODY)
|
|
51
|
+
monkeypatch.setenv("WORKBOOK_EXECUTION_ID", OTHER_EXEC_ID)
|
|
52
|
+
|
|
53
|
+
png = tmp_path / "voltage_profile.png"
|
|
54
|
+
png.write_bytes(b"\x89PNG fake")
|
|
55
|
+
|
|
56
|
+
result = api.upload_execution_artifact(EXEC_ID, str(png), "Voltage Profile")
|
|
57
|
+
|
|
58
|
+
assert result == RESULT_BODY
|
|
59
|
+
url = api.session.post.call_args.args[0]
|
|
60
|
+
assert url == f"https://fake.test/publicapi/v1/python/executions/{EXEC_ID}/artifacts"
|
|
61
|
+
name, fobj, content_type = api.session.post.call_args.kwargs["files"]["file"]
|
|
62
|
+
assert name == "voltage_profile.png"
|
|
63
|
+
assert content_type == "image/png"
|
|
64
|
+
print("PASS: classic positional (guid, path, title) unchanged; mime guessed from extension")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_keyword_shape_unchanged(monkeypatch, tmp_path):
|
|
68
|
+
api = _make_api()
|
|
69
|
+
api.session.post.return_value = _mock_response(200, RESULT_BODY)
|
|
70
|
+
monkeypatch.delenv("WORKBOOK_EXECUTION_ID", raising=False)
|
|
71
|
+
|
|
72
|
+
csv = tmp_path / "results.csv"
|
|
73
|
+
csv.write_text("a,b\n1,2\n")
|
|
74
|
+
|
|
75
|
+
result = api.upload_execution_artifact(
|
|
76
|
+
execution_id=EXEC_ID,
|
|
77
|
+
file_path=str(csv),
|
|
78
|
+
title="Analysis Results",
|
|
79
|
+
filename="cycle_summary.csv",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert result == RESULT_BODY
|
|
83
|
+
url = api.session.post.call_args.args[0]
|
|
84
|
+
assert url == f"https://fake.test/publicapi/v1/python/executions/{EXEC_ID}/artifacts"
|
|
85
|
+
files = api.session.post.call_args.kwargs["files"]
|
|
86
|
+
assert files["filename"] == (None, "cycle_summary.csv")
|
|
87
|
+
name, fobj, content_type = files["file"]
|
|
88
|
+
assert name == "results.csv"
|
|
89
|
+
# Pinned by _MIME_OVERRIDES, independent of the host OS mime registry.
|
|
90
|
+
assert content_type == "text/csv"
|
|
91
|
+
print("PASS: full keyword form unchanged; filename part still sent")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_missing_execution_id_and_env_var_reports_clear_error(monkeypatch, tmp_path, capsys):
|
|
95
|
+
# Outside a cloud execution with no explicit execution_id: P-03 convention —
|
|
96
|
+
# print the real error (naming the env var) and return None, no HTTP call.
|
|
97
|
+
api = _make_api()
|
|
98
|
+
monkeypatch.delenv("WORKBOOK_EXECUTION_ID", raising=False)
|
|
99
|
+
|
|
100
|
+
csv = tmp_path / "out.csv"
|
|
101
|
+
csv.write_text("a,b\n")
|
|
102
|
+
|
|
103
|
+
result = api.upload_execution_artifact(file_path=str(csv), title="Out")
|
|
104
|
+
|
|
105
|
+
assert result is None
|
|
106
|
+
out = capsys.readouterr().out
|
|
107
|
+
assert "upload_execution_artifact failed" in out
|
|
108
|
+
assert "WORKBOOK_EXECUTION_ID" in out
|
|
109
|
+
api.session.post.assert_not_called()
|
|
110
|
+
print("PASS: missing execution_id + env var → clear error naming WORKBOOK_EXECUTION_ID")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|