micantis 1.2.3__tar.gz → 1.2.5__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.3
3
+ Version: 1.2.5
4
4
  Summary: Package to simplify Micantis API usage
5
5
  Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
6
6
  License-Expression: MIT
@@ -548,6 +548,55 @@ assert result.success, result.errors
548
548
 
549
549
  > **Rename rules:** CellTest names must be unique and may only contain letters, digits, and `_ - . ( ) space`. CellData names must be unique; renaming a CellData also auto-updates the owning CellTest's DataSourceKey.
550
550
 
551
+ ## Write Data Metadata
552
+ Assign or update metadata on a **data set** (CellData record), the same way as cells, using either:
553
+ - the built-in field `"name"` (renames the data set) or `"dataSourceKey"`
554
+ - the human-readable field name of a user-defined property (e.g., "Active Cycle Test?")
555
+ - or the internal propertyDefinitionId (UUID)
556
+
557
+ User-defined property names are resolved against the data-set metadata definitions, which you can list with `api.list_data_metadata_definitions()`.
558
+
559
+ **Per-entity atomic:** all changes targeting a single data set commit or rollback together; other data sets are unaffected. Returns a `BatchUpdateResult` with `.success` and `.errors`, identical to `write_cell_metadata`.
560
+
561
+ #### 📘 Examples
562
+
563
+ ```python
564
+ # Example 1: Set a user-defined property on specific data sets
565
+ changes = [
566
+ {"id": "your-cell-data-guid-here", "field": "Active Cycle Test?", "value": True},
567
+ ]
568
+
569
+ result = api.write_data_metadata(changes=changes)
570
+ if not result:
571
+ for e in result.errors:
572
+ print(f"{e['itemId']}: {e['error']}")
573
+ ```
574
+
575
+ ```python
576
+ # Example 2: Rename a data set and update its dataSourceKey
577
+ changes = [
578
+ {"id": "your-cell-data-guid-here", "field": "name", "value": "Formation_Run_42"},
579
+ {"id": "your-cell-data-guid-here", "field": "dataSourceKey", "value": "Formation"},
580
+ ]
581
+
582
+ result = api.write_data_metadata(changes=changes)
583
+ assert result.success, result.errors
584
+ ```
585
+
586
+ ```python
587
+ # Example 3: Update using propertyDefinitionId (advanced)
588
+ changes = [
589
+ {
590
+ "id": "your-cell-data-guid-here",
591
+ "propertyDefinitionId": "your-property-definition-guid",
592
+ "value": "true"
593
+ }
594
+ ]
595
+
596
+ result = api.write_data_metadata(changes=changes)
597
+ assert result.success, result.errors
598
+ ```
599
+
551
600
  ## Stitch Data
552
601
  Combine multiple data sets into a single stitched data set. This is useful for creating continuous test data from multiple separate test runs.
553
602
 
@@ -964,14 +1013,16 @@ for group in dupes:
964
1013
  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.
965
1014
 
966
1015
  #### Parameters
967
- - `execution_id`: **str (required)**
968
- The execution ID (GUID) of the running or completed execution
1016
+ - `execution_id`: **str (optional inside cloud executions)**
1017
+ 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`
969
1018
  - `file_path`: **str (required)**
970
1019
  Local path to the file to upload. Maximum size: 50 MB
971
1020
  - `title`: **str (required)**
972
1021
  Human-readable title for the artifact
973
1022
  - `filename`: **str (optional)**
974
1023
  Override the filename stored on the server. If not provided, derived from the title and file extension
1024
+ - `mime`: **str (optional)**
1025
+ Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
975
1026
 
976
1027
  #### Returns
977
1028
  - Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
@@ -979,22 +1030,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
979
1030
  #### 📘 Examples
980
1031
 
981
1032
  ```python
982
- # Upload a plot generated during a Python execution
983
- result = api.upload_execution_artifact(
984
- execution_id="your-execution-guid",
985
- file_path="voltage_plot.png",
986
- title="Voltage vs Time"
987
- )
1033
+ # Inside a cloud execution execution_id is resolved from the environment
1034
+ result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
988
1035
  print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
989
1036
  ```
990
1037
 
991
1038
  ```python
992
- # Upload a CSV results file with a custom filename
1039
+ # Explicit execution ID with a custom filename and content type
993
1040
  result = api.upload_execution_artifact(
994
1041
  execution_id="your-execution-guid",
995
1042
  file_path="results.csv",
996
1043
  title="Cycle Summary Results",
997
- filename="cycle_summary_2025.csv"
1044
+ filename="cycle_summary_2025.csv",
1045
+ mime="text/csv"
998
1046
  )
999
1047
  ```
1000
1048
 
@@ -528,6 +528,55 @@ assert result.success, result.errors
528
528
 
529
529
  > **Rename rules:** CellTest names must be unique and may only contain letters, digits, and `_ - . ( ) space`. CellData names must be unique; renaming a CellData also auto-updates the owning CellTest's DataSourceKey.
530
530
 
531
+ ## Write Data Metadata
532
+ Assign or update metadata on a **data set** (CellData record), the same way as cells, using either:
533
+ - the built-in field `"name"` (renames the data set) or `"dataSourceKey"`
534
+ - the human-readable field name of a user-defined property (e.g., "Active Cycle Test?")
535
+ - or the internal propertyDefinitionId (UUID)
536
+
537
+ User-defined property names are resolved against the data-set metadata definitions, which you can list with `api.list_data_metadata_definitions()`.
538
+
539
+ **Per-entity atomic:** all changes targeting a single data set commit or rollback together; other data sets are unaffected. Returns a `BatchUpdateResult` with `.success` and `.errors`, identical to `write_cell_metadata`.
540
+
541
+ #### 📘 Examples
542
+
543
+ ```python
544
+ # Example 1: Set a user-defined property on specific data sets
545
+ changes = [
546
+ {"id": "your-cell-data-guid-here", "field": "Active Cycle Test?", "value": True},
547
+ ]
548
+
549
+ result = api.write_data_metadata(changes=changes)
550
+ if not result:
551
+ for e in result.errors:
552
+ print(f"{e['itemId']}: {e['error']}")
553
+ ```
554
+
555
+ ```python
556
+ # Example 2: Rename a data set and update its dataSourceKey
557
+ changes = [
558
+ {"id": "your-cell-data-guid-here", "field": "name", "value": "Formation_Run_42"},
559
+ {"id": "your-cell-data-guid-here", "field": "dataSourceKey", "value": "Formation"},
560
+ ]
561
+
562
+ result = api.write_data_metadata(changes=changes)
563
+ assert result.success, result.errors
564
+ ```
565
+
566
+ ```python
567
+ # Example 3: Update using propertyDefinitionId (advanced)
568
+ changes = [
569
+ {
570
+ "id": "your-cell-data-guid-here",
571
+ "propertyDefinitionId": "your-property-definition-guid",
572
+ "value": "true"
573
+ }
574
+ ]
575
+
576
+ result = api.write_data_metadata(changes=changes)
577
+ assert result.success, result.errors
578
+ ```
579
+
531
580
  ## Stitch Data
532
581
  Combine multiple data sets into a single stitched data set. This is useful for creating continuous test data from multiple separate test runs.
533
582
 
@@ -944,14 +993,16 @@ for group in dupes:
944
993
  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.
945
994
 
946
995
  #### Parameters
947
- - `execution_id`: **str (required)**
948
- The execution ID (GUID) of the running or completed execution
996
+ - `execution_id`: **str (optional inside cloud executions)**
997
+ 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`
949
998
  - `file_path`: **str (required)**
950
999
  Local path to the file to upload. Maximum size: 50 MB
951
1000
  - `title`: **str (required)**
952
1001
  Human-readable title for the artifact
953
1002
  - `filename`: **str (optional)**
954
1003
  Override the filename stored on the server. If not provided, derived from the title and file extension
1004
+ - `mime`: **str (optional)**
1005
+ Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
955
1006
 
956
1007
  #### Returns
957
1008
  - Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
@@ -959,22 +1010,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
959
1010
  #### 📘 Examples
960
1011
 
961
1012
  ```python
962
- # Upload a plot generated during a Python execution
963
- result = api.upload_execution_artifact(
964
- execution_id="your-execution-guid",
965
- file_path="voltage_plot.png",
966
- title="Voltage vs Time"
967
- )
1013
+ # Inside a cloud execution execution_id is resolved from the environment
1014
+ result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
968
1015
  print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
969
1016
  ```
970
1017
 
971
1018
  ```python
972
- # Upload a CSV results file with a custom filename
1019
+ # Explicit execution ID with a custom filename and content type
973
1020
  result = api.upload_execution_artifact(
974
1021
  execution_id="your-execution-guid",
975
1022
  file_path="results.csv",
976
1023
  title="Cycle Summary Results",
977
- filename="cycle_summary_2025.csv"
1024
+ filename="cycle_summary_2025.csv",
1025
+ mime="text/csv"
978
1026
  )
979
1027
  ```
980
1028
 
@@ -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`).
@@ -884,6 +908,59 @@ class MicantisAPI:
884
908
  except requests.exceptions.RequestException as e:
885
909
  raise self._format_request_error(e)
886
910
 
911
+ def list_data_metadata_definitions(self, return_format='df', timeout: int = 30):
912
+ """
913
+ Returns a list of cell data metadata types.
914
+
915
+ Parameters
916
+ ----------
917
+ return_format : str, optional
918
+ Return format: 'df' for DataFrame (default), anything else for raw list.
919
+ timeout : int, optional
920
+ Request timeout in seconds. Default: 30.
921
+ """
922
+ try:
923
+ return self._retry_on_auth_failure(
924
+ self._list_data_metadata_definitions, return_format, timeout
925
+ )
926
+ except Exception as e:
927
+ print(f"⚠️ list_data_metadata_definitions failed: {e}")
928
+ return None
929
+
930
+ def _list_data_metadata_definitions(self, return_format: str, timeout: int):
931
+ """
932
+ Internal helper for list_data_metadata_definitions. Does not handle retries or printing.
933
+
934
+ Raises
935
+ ------
936
+ RuntimeError
937
+ If the request fails or authentication is missing.
938
+ """
939
+ if not self.headers:
940
+ raise RuntimeError("Authentication required. Call authenticate() first.")
941
+
942
+ self._refresh_token_if_needed()
943
+
944
+ try:
945
+ # Make the GET request
946
+ response = self.session.get(
947
+ f"{self.service_url}/publicapi/v1/metadata/list/data",
948
+ headers=self.headers,
949
+ timeout=timeout,
950
+ )
951
+ response.raise_for_status()
952
+
953
+ item = response.json()
954
+ if return_format == 'df':
955
+ df = pd.DataFrame(item)
956
+ return df
957
+
958
+ else:
959
+ return item
960
+
961
+ except requests.exceptions.RequestException as e:
962
+ raise self._format_request_error(e)
963
+
887
964
  def get_cell_metadata(self, cell_ids: list, metadata: list = None, return_images: bool = False, timeout: int = 30):
888
965
  """
889
966
  Fetch metadata for specific cells, using either metadata names or IDs.
@@ -1144,9 +1221,14 @@ class MicantisAPI:
1144
1221
  """
1145
1222
  Wrapper for POST /data/updatemetadata endpoint.
1146
1223
 
1147
- Updates built-in metadata fields on cell data records. Supported built-in
1148
- fields are "name" and "dataSourceKey". User-defined metadata properties on
1149
- cell data are not yet supported via the public API.
1224
+ Each change targets either a built-in field (supported: "name",
1225
+ "dataSourceKey") or a user-defined metadata property. For user-defined
1226
+ properties, pass either a human-readable 'field' name (resolved via
1227
+ metadata definitions) or a 'propertyDefinitionId' UUID.
1228
+
1229
+ Built-in field names take precedence over user-defined names; if you have a
1230
+ user-defined property literally named "name" or "dataSourceKey", use
1231
+ propertyDefinitionId to disambiguate.
1150
1232
 
1151
1233
  Renaming a data set ("name" field) enforces name-uniqueness; the change is
1152
1234
  rejected if another data set already has the target name. The owning
@@ -1166,7 +1248,10 @@ class MicantisAPI:
1166
1248
  changes : list of dict
1167
1249
  A list of metadata change dictionaries. Each dictionary must contain:
1168
1250
  - "id": str (cell data GUID)
1169
- - "field": "name" or "dataSourceKey"
1251
+ - One of:
1252
+ - "field": "name" or "dataSourceKey" (built-in fields)
1253
+ - "field": "<metadata name>" (user-defined, e.g. "Active Cycle Test?")
1254
+ - "propertyDefinitionId": "<UUID>" (user-defined)
1170
1255
  - "value": any (will be cast to str)
1171
1256
 
1172
1257
  timeout : int, optional
@@ -1184,8 +1269,9 @@ class MicantisAPI:
1184
1269
  Examples
1185
1270
  --------
1186
1271
  >>> result = api.write_data_metadata([
1187
- ... {"id": cell_data_id, "field": "name", "value": "Formation_Run_42"},
1188
- ... {"id": cell_data_id, "field": "dataSourceKey", "value": "Formation"},
1272
+ ... {"id": cell_data_id, "field": "name", "value": "Formation_Run_42"},
1273
+ ... {"id": cell_data_id, "field": "dataSourceKey", "value": "Formation"},
1274
+ ... {"id": cell_data_id, "field": "Active Cycle Test?", "value": True},
1189
1275
  ... ])
1190
1276
  >>> if not result:
1191
1277
  ... for e in result.errors:
@@ -1203,20 +1289,35 @@ class MicantisAPI:
1203
1289
 
1204
1290
  self._refresh_token_if_needed()
1205
1291
 
1292
+ # Only look up user-property definitions if at least one change uses a 'field' that
1293
+ # isn't a built-in (lookup is a separate round-trip and unnecessary otherwise).
1294
+ needs_lookup = any(
1295
+ "field" in change and change["field"] not in self._BUILTIN_DATA_METADATA_FIELDS
1296
+ for change in changes
1297
+ )
1298
+ if needs_lookup:
1299
+ metadata_df = self._list_data_metadata_definitions('df', timeout)
1300
+ name_to_id = dict(zip(metadata_df['name'], metadata_df['id']))
1301
+ else:
1302
+ name_to_id = {}
1303
+
1206
1304
  formatted_changes = []
1207
1305
  for change in changes:
1208
- field = change.get("field")
1209
- if field not in self._BUILTIN_DATA_METADATA_FIELDS:
1210
- supported = ", ".join(f'"{f}"' for f in sorted(self._BUILTIN_DATA_METADATA_FIELDS))
1211
- raise RuntimeError(
1212
- f"write_data_metadata only supports built-in fields ({supported}). "
1213
- f"Got field={field!r}."
1214
- )
1215
- formatted_changes.append({
1216
- "id": change["id"],
1217
- "field": field,
1218
- "value": str(change["value"]),
1219
- })
1306
+ item = {"id": change["id"], "value": str(change["value"])}
1307
+
1308
+ if "field" in change and change["field"] in self._BUILTIN_DATA_METADATA_FIELDS:
1309
+ item["field"] = change["field"]
1310
+ elif "propertyDefinitionId" in change:
1311
+ item["propertyDefinitionId"] = change["propertyDefinitionId"]
1312
+ elif "field" in change:
1313
+ field_name = change["field"]
1314
+ if field_name not in name_to_id:
1315
+ raise RuntimeError(f"Field name '{field_name}' not found in metadata definitions.")
1316
+ item["propertyDefinitionId"] = name_to_id[field_name]
1317
+ else:
1318
+ raise RuntimeError("Each change must include either 'field' or 'propertyDefinitionId'.")
1319
+
1320
+ formatted_changes.append(item)
1220
1321
 
1221
1322
  payload = {"changes": formatted_changes}
1222
1323
 
@@ -2213,6 +2314,111 @@ class MicantisAPI:
2213
2314
  cycle, step_kind, step_index)
2214
2315
  return action
2215
2316
 
2317
+ @staticmethod
2318
+ def rename_column(source_column_index, new_name, name=None):
2319
+ """Build a rename-column action for clean_data (MetadataEdit mode).
2320
+
2321
+ Rename a column's display Name — what graphs, tables, and reports look up
2322
+ by YAxisName. Targets the ORIGINAL source column index; action order does
2323
+ not shift index meaning.
2324
+
2325
+ Parameters
2326
+ ----------
2327
+ source_column_index : int
2328
+ 0-based index into the ORIGINAL source column list.
2329
+ new_name : str
2330
+ New display name. Preserve exact spacing / punctuation expected by
2331
+ downstream lookups (e.g. "Temp 1 (C)" with two spaces).
2332
+ name : str, optional
2333
+ Descriptive name for this action.
2334
+ """
2335
+ action = {"$type": "rename",
2336
+ "sourceColumnIndex": int(source_column_index),
2337
+ "newName": new_name}
2338
+ if name is not None:
2339
+ action["name"] = name
2340
+ return action
2341
+
2342
+ @staticmethod
2343
+ def change_column_kind(source_column_index, new_kind, name=None):
2344
+ """Build a change-column-kind action for clean_data (MetadataEdit mode).
2345
+
2346
+ Change a column's YAxisKind (cycle-tester aux) or ImportItemKind (logger)
2347
+ — controls which kind-based lookups match the column. Targets the ORIGINAL
2348
+ source column index.
2349
+
2350
+ Parameters
2351
+ ----------
2352
+ source_column_index : int
2353
+ 0-based index into the ORIGINAL source column list.
2354
+ new_kind : str
2355
+ Exact enum value name (e.g. 'Voltage', 'AuxTemperature').
2356
+ name : str, optional
2357
+ Descriptive name for this action.
2358
+ """
2359
+ action = {"$type": "kind",
2360
+ "sourceColumnIndex": int(source_column_index),
2361
+ "newKind": new_kind}
2362
+ if name is not None:
2363
+ action["name"] = name
2364
+ return action
2365
+
2366
+ @staticmethod
2367
+ def reorder_columns(source_column_indices, name=None):
2368
+ """Build a reorder-columns action for clean_data (MetadataEdit mode).
2369
+
2370
+ Declarative final column layout: source columns appear in the cleaned
2371
+ output in the order listed; any source index not listed is dropped.
2372
+ Combines drop + reorder in one action. At most one per definition.
2373
+
2374
+ Parameters
2375
+ ----------
2376
+ source_column_indices : list[int]
2377
+ Ordered list of 0-based source column indices to keep. Omit an index
2378
+ to drop that column. Example: [0, 1, 3, 4, 5] drops source column 2.
2379
+ name : str, optional
2380
+ Descriptive name for this action.
2381
+ """
2382
+ action = {"$type": "reorderColumns",
2383
+ "sourceColumnIndices": [int(i) for i in source_column_indices]}
2384
+ if name is not None:
2385
+ action["name"] = name
2386
+ return action
2387
+
2388
+ @staticmethod
2389
+ def edit_logger_step(line=None, lines=None, columns=None, name=None):
2390
+ """Build an edit-logger-timestep action for clean_data (logger-data analog of modify_step/bulk_modify).
2391
+
2392
+ Edit column values by 0-based column index on rows matched by ``line`` and/or
2393
+ ``lines`` (union). Use on DataLoggerData sources only; for cycle-tester data
2394
+ use modify_step / bulk_modify.
2395
+
2396
+ Parameters
2397
+ ----------
2398
+ line : int, optional
2399
+ Single line number to edit.
2400
+ lines : list of (int, int) tuples, optional
2401
+ Line number ranges to edit, e.g. [(100, 200)]. End may be None for open-ended.
2402
+ columns : dict of {int: float}
2403
+ Column edits as {column_index: value}. Column index is 0-based and
2404
+ must refer to a valid column in the source ImportDefinition.
2405
+ name : str, optional
2406
+ Descriptive name for this action.
2407
+ """
2408
+ if line is None and not lines:
2409
+ raise ValueError("edit_logger_step requires either line or lines.")
2410
+ if not columns:
2411
+ raise ValueError("edit_logger_step requires columns.")
2412
+ action = {"$type": "editLogger"}
2413
+ if name is not None:
2414
+ action["name"] = name
2415
+ if line is not None:
2416
+ action["lineNumber"] = line
2417
+ if lines:
2418
+ action["lineNumberRanges"] = _ranges(lines)
2419
+ action["columns"] = [{"columnIndex": int(k), "value": float(v)} for k, v in columns.items()]
2420
+ return action
2421
+
2216
2422
  def clean_data(self,
2217
2423
  source_ids: list,
2218
2424
  mode: str = 'CycleAutoFixup',
@@ -2231,14 +2437,19 @@ class MicantisAPI:
2231
2437
  source_ids : list of str
2232
2438
  List of cell data IDs (GUIDs) to clean.
2233
2439
  mode : str, optional
2234
- Cleaning mode. One of 'CycleAutoFixup', 'FilterCycles', 'Parametric'.
2235
- Default: 'CycleAutoFixup'. Auto-set to 'Parametric' when actions is provided.
2440
+ Cleaning mode. One of 'CycleAutoFixup', 'FilterCycles', 'Parametric', 'MetadataEdit'.
2441
+ Default: 'CycleAutoFixup'. When ``actions`` is provided and mode is still the default,
2442
+ it auto-sets to 'Parametric'. For 'MetadataEdit', pass mode explicitly.
2236
2443
  filter_cycles_definition : dict, optional
2237
2444
  Required when mode is 'FilterCycles'. Definition for filtering cycles.
2238
2445
  actions : list, optional
2239
- List of action dicts built with api.remove_steps(),
2240
- api.modify_step(), and/or api.bulk_modify().
2241
- Auto-sets mode to 'Parametric'. Example::
2446
+ For Parametric mode: list of dicts from api.remove_steps(), api.modify_step(),
2447
+ api.bulk_modify(), api.edit_logger_step().
2448
+ For MetadataEdit mode: list of dicts from api.rename_column(),
2449
+ api.change_column_kind(), api.reorder_columns().
2450
+ Actions are applied in the order given.
2451
+
2452
+ Parametric example (cycle-tester)::
2242
2453
 
2243
2454
  api.clean_data(
2244
2455
  source_ids=[id],
@@ -2248,6 +2459,29 @@ class MicantisAPI:
2248
2459
  api.bulk_modify(lines=[(100, 5000)], cycle=1),
2249
2460
  ]
2250
2461
  )
2462
+
2463
+ Parametric example (DataLoggerData)::
2464
+
2465
+ api.clean_data(
2466
+ source_ids=[logger_id],
2467
+ actions=[
2468
+ api.remove_steps(lines=[(1, 3)]),
2469
+ api.edit_logger_step(line=10, columns={0: 1.5}),
2470
+ ]
2471
+ )
2472
+
2473
+ MetadataEdit example (rename + drop — works on both CT and logger)::
2474
+
2475
+ api.clean_data(
2476
+ source_ids=[id],
2477
+ mode='MetadataEdit',
2478
+ actions=[
2479
+ api.rename_column(source_column_index=3, new_name='Temp 1 (C)'),
2480
+ api.rename_column(source_column_index=4, new_name='Temp 2 (C)'),
2481
+ api.rename_column(source_column_index=5, new_name='Temp 3 (C)'),
2482
+ api.reorder_columns([0, 1, 3, 4, 5]), # drops source column 2
2483
+ ]
2484
+ )
2251
2485
  allow_async : bool, optional
2252
2486
  If True, runs asynchronously and returns job ID. Default: False.
2253
2487
  timeout : int, optional
@@ -2260,7 +2494,8 @@ class MicantisAPI:
2260
2494
  If allow_async is True: {'job_id': str}
2261
2495
  Returns None if an error occurs.
2262
2496
  """
2263
- if actions is not None:
2497
+ # Auto-set mode only if the caller left the default; preserves explicit 'MetadataEdit' etc.
2498
+ if actions is not None and mode == 'CycleAutoFixup':
2264
2499
  mode = 'Parametric'
2265
2500
 
2266
2501
  try:
@@ -2291,8 +2526,10 @@ class MicantisAPI:
2291
2526
 
2292
2527
  if mode == 'Parametric' and not actions:
2293
2528
  raise ValueError("actions is required when mode is 'Parametric'.")
2529
+ if mode == 'MetadataEdit' and not actions:
2530
+ raise ValueError("actions is required when mode is 'MetadataEdit'.")
2294
2531
 
2295
- _mode_map = {'CycleAutoFixup': 1, 'Parametric': 2, 'FilterCycles': 4}
2532
+ _mode_map = {'CycleAutoFixup': 1, 'Parametric': 2, 'FilterCycles': 4, 'MetadataEdit': 5}
2296
2533
  if mode not in _mode_map:
2297
2534
  raise ValueError(f"mode must be one of {list(_mode_map)}, got '{mode}'")
2298
2535
 
@@ -2304,7 +2541,10 @@ class MicantisAPI:
2304
2541
  if filter_cycles_definition is not None:
2305
2542
  payload["filterCyclesDefinition"] = filter_cycles_definition
2306
2543
  if actions is not None:
2307
- payload["parametricDefinition"] = {"actions": actions}
2544
+ if mode == 'MetadataEdit':
2545
+ payload["metadataEditDefinition"] = {"actions": actions}
2546
+ else:
2547
+ payload["parametricDefinition"] = {"actions": actions}
2308
2548
 
2309
2549
  url = f"{self.service_url}/publicapi/v1/data/clean"
2310
2550
  if allow_async:
@@ -2578,21 +2818,34 @@ class MicantisAPI:
2578
2818
  raise self._format_request_error(e)
2579
2819
 
2580
2820
  def upload_execution_artifact(self,
2581
- execution_id: str,
2582
- file_path: str,
2583
- title: str,
2821
+ execution_id: str = None,
2822
+ file_path: str = None,
2823
+ title: str = None,
2584
2824
  filename: str = None,
2585
- timeout: int = 120) -> dict:
2825
+ timeout: int = 120,
2826
+ mime: str = None) -> dict:
2586
2827
  """
2587
2828
  Upload an artifact (output file) produced by a running Python execution.
2588
2829
 
2830
+ Inside a cloud execution, execution_id can be omitted — it is resolved
2831
+ from the WORKBOOK_EXECUTION_ID environment variable, which the runner
2832
+ always sets. The natural call shape there is::
2833
+
2834
+ api.upload_execution_artifact('/tmp/plot.png', title='My Plot')
2835
+
2836
+ A first positional argument that is not a GUID is treated as file_path
2837
+ (when file_path is not also given), so the line above works even though
2838
+ the first parameter is execution_id.
2839
+
2589
2840
  Automatically reauthenticates if the session is expired or missing.
2590
2841
  Prints an error message and returns None if the request ultimately fails.
2591
2842
 
2592
2843
  Parameters
2593
2844
  ----------
2594
- execution_id : str
2845
+ execution_id : str, optional
2595
2846
  The execution ID (GUID) of the running or completed Python execution.
2847
+ Defaults to the WORKBOOK_EXECUTION_ID environment variable (always
2848
+ set inside cloud executions). Required when running outside one.
2596
2849
  file_path : str
2597
2850
  Local path to the file to upload (PNG, CSV, XLSX, JSON, PDF, etc.).
2598
2851
  Maximum file size: 50 MB.
@@ -2603,6 +2856,10 @@ class MicantisAPI:
2603
2856
  from the title and file extension.
2604
2857
  timeout : int, optional
2605
2858
  Request timeout in seconds. Default: 120.
2859
+ mime : str, optional
2860
+ Content type stored for the artifact (e.g. 'image/png'). If not
2861
+ provided, guessed from the file_path extension, falling back to
2862
+ 'application/octet-stream'.
2606
2863
 
2607
2864
  Returns
2608
2865
  -------
@@ -2618,6 +2875,7 @@ class MicantisAPI:
2618
2875
  title,
2619
2876
  filename,
2620
2877
  timeout,
2878
+ mime,
2621
2879
  )
2622
2880
  except Exception as e:
2623
2881
  print(f"⚠️ upload_execution_artifact failed: {e}")
@@ -2628,28 +2886,61 @@ class MicantisAPI:
2628
2886
  file_path: str,
2629
2887
  title: str,
2630
2888
  filename: str,
2631
- timeout: int) -> dict:
2889
+ timeout: int,
2890
+ mime: str) -> dict:
2632
2891
  """
2633
2892
  Internal helper for upload_execution_artifact. Does not handle retries or printing.
2634
2893
 
2635
2894
  Raises
2636
2895
  ------
2637
2896
  RuntimeError
2638
- If the request fails or authentication is missing.
2897
+ If arguments are missing or invalid, the request fails, or
2898
+ authentication is missing.
2639
2899
  """
2640
2900
  if not self.headers:
2641
2901
  raise RuntimeError("Authentication required. Call authenticate() first.")
2642
2902
 
2643
2903
  self._refresh_token_if_needed()
2644
2904
 
2905
+ # Forgive the natural call shape api.upload_execution_artifact('/tmp/plot.png', title=...):
2906
+ # a non-GUID first argument is the file path, and the execution ID comes from the environment.
2907
+ if execution_id is not None and not _is_guid(execution_id):
2908
+ if file_path is None:
2909
+ file_path = execution_id
2910
+ execution_id = None
2911
+ else:
2912
+ raise RuntimeError(
2913
+ f"execution_id '{execution_id}' is not an execution GUID. Inside a cloud "
2914
+ "execution you can omit execution_id entirely — it is read from the "
2915
+ "WORKBOOK_EXECUTION_ID environment variable automatically."
2916
+ )
2917
+
2918
+ if execution_id is None:
2919
+ execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
2920
+ if not execution_id:
2921
+ raise RuntimeError(
2922
+ "No execution_id provided and WORKBOOK_EXECUTION_ID is not set. Inside a "
2923
+ "cloud execution the variable is set automatically; outside one, pass "
2924
+ "execution_id explicitly."
2925
+ )
2926
+
2927
+ if not file_path:
2928
+ raise RuntimeError("file_path is required.")
2929
+ if not title:
2930
+ raise RuntimeError("title is required.")
2931
+
2645
2932
  url = f"{self.service_url}/publicapi/v1/python/executions/{execution_id}/artifacts"
2646
2933
 
2934
+ ext = os.path.splitext(file_path)[1].lower()
2935
+ content_type = (mime or _MIME_OVERRIDES.get(ext)
2936
+ or mimetypes.guess_type(file_path)[0] or "application/octet-stream")
2937
+
2647
2938
  try:
2648
2939
  with open(file_path, 'rb') as f:
2649
2940
  form_data = {'title': (None, title)}
2650
2941
  if filename is not None:
2651
2942
  form_data['filename'] = (None, filename)
2652
- form_data['file'] = (os.path.basename(file_path), f)
2943
+ form_data['file'] = (os.path.basename(file_path), f, content_type)
2653
2944
 
2654
2945
  # Exclude Content-Type so requests sets the multipart boundary correctly
2655
2946
  headers = {k: v for k, v in self.headers.items() if k.lower() != 'content-type'}
@@ -2666,6 +2957,114 @@ class MicantisAPI:
2666
2957
  except requests.exceptions.RequestException as e:
2667
2958
  raise self._format_request_error(e)
2668
2959
 
2960
+ def list_attachments(self, timeout: int = 30) -> list:
2961
+ """
2962
+ List the files attached to the Moose conversation that launched this script.
2963
+
2964
+ Only works inside a Moose-triggered cloud execution (uses WORKBOOK_EXECUTION_ID).
2965
+ Automatically reauthenticates if the session is expired or missing. Prints an error
2966
+ and returns None on failure.
2967
+
2968
+ Returns
2969
+ -------
2970
+ list of dict or None
2971
+ One entry per attachment: {"name", "contentType", "sizeBytes"}.
2972
+ """
2973
+ try:
2974
+ return self._retry_on_auth_failure(self._list_attachments, timeout)
2975
+ except Exception as e:
2976
+ print(f"⚠️ list_attachments failed: {e}")
2977
+ return None
2978
+
2979
+ def _list_attachments(self, timeout: int) -> list:
2980
+ if not self.headers:
2981
+ raise RuntimeError("Authentication required. Call authenticate() first.")
2982
+ self._refresh_token_if_needed()
2983
+
2984
+ execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
2985
+ if not execution_id:
2986
+ raise RuntimeError(
2987
+ "list_attachments is only available inside a cloud execution (WORKBOOK_EXECUTION_ID is not set)."
2988
+ )
2989
+
2990
+ try:
2991
+ r = self.session.get(
2992
+ f"{self.service_url}/publicapi/v1/python/executions/{execution_id}/attachments",
2993
+ headers=self.headers,
2994
+ timeout=timeout,
2995
+ )
2996
+ r.raise_for_status()
2997
+ return r.json()
2998
+ except requests.exceptions.RequestException as e:
2999
+ raise self._format_request_error(e)
3000
+
3001
+ def download_attachment(self, name: str, output_path: str = None,
3002
+ return_type: str = "bytes", timeout: int = 120):
3003
+ """
3004
+ Download a file attached to the Moose conversation that launched this script, by name,
3005
+ WITHOUT inlining its bytes into your script source. Use this for any conversation
3006
+ attachment too large to paste in — inlining data into the script is bounded by the
3007
+ per-turn output budget (a few KB), but this streams the file into the container.
3008
+
3009
+ Only works inside a Moose-triggered cloud execution (uses WORKBOOK_EXECUTION_ID). Call
3010
+ list_attachments() first to get exact names. Automatically reauthenticates if the
3011
+ session is expired or missing. Prints an error and returns None on failure.
3012
+
3013
+ Parameters
3014
+ ----------
3015
+ name : str
3016
+ Exact attachment filename (from list_attachments()).
3017
+ output_path : str, optional
3018
+ Where to save the file when return_type='path'. Defaults to a temp file.
3019
+ return_type : str, optional
3020
+ 'bytes' (default) returns the raw bytes; 'path' writes the file to disk and returns
3021
+ the path (best for large files); 'text' returns a UTF-8-decoded str.
3022
+ timeout : int, optional
3023
+ Request timeout in seconds. Default: 120.
3024
+
3025
+ Returns
3026
+ -------
3027
+ bytes | str | None
3028
+ The attachment content per return_type, or None on failure.
3029
+ """
3030
+ try:
3031
+ return self._retry_on_auth_failure(
3032
+ self._download_attachment, name, output_path, return_type, timeout)
3033
+ except Exception as e:
3034
+ print(f"⚠️ download_attachment failed: {e}")
3035
+ return None
3036
+
3037
+ def _download_attachment(self, name: str, output_path: str, return_type: str, timeout: int):
3038
+ if not self.headers:
3039
+ raise RuntimeError("Authentication required. Call authenticate() first.")
3040
+ self._refresh_token_if_needed()
3041
+
3042
+ execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
3043
+ if not execution_id:
3044
+ raise RuntimeError(
3045
+ "download_attachment is only available inside a cloud execution (WORKBOOK_EXECUTION_ID is not set)."
3046
+ )
3047
+
3048
+ url = (f"{self.service_url}/publicapi/v1/python/executions/{execution_id}"
3049
+ f"/attachments/{quote(name)}")
3050
+
3051
+ try:
3052
+ with self.session.get(url, headers=self.headers, timeout=timeout, stream=True) as r:
3053
+ r.raise_for_status()
3054
+ if return_type == "path":
3055
+ path = output_path or os.path.join(tempfile.gettempdir(), os.path.basename(name))
3056
+ with open(path, "wb") as f:
3057
+ for chunk in r.iter_content(chunk_size=1024 * 1024):
3058
+ if chunk:
3059
+ f.write(chunk)
3060
+ return path
3061
+ content = r.content
3062
+ if return_type == "text":
3063
+ return content.decode("utf-8", errors="replace")
3064
+ return content
3065
+ except requests.exceptions.RequestException as e:
3066
+ raise self._format_request_error(e)
3067
+
2669
3068
  # ─────────────────────────────────────────
2670
3069
  # Job management (async operation polling)
2671
3070
  # ─────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: micantis
3
- Version: 1.2.3
3
+ Version: 1.2.5
4
4
  Summary: Package to simplify Micantis API usage
5
5
  Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
6
6
  License-Expression: MIT
@@ -548,6 +548,55 @@ assert result.success, result.errors
548
548
 
549
549
  > **Rename rules:** CellTest names must be unique and may only contain letters, digits, and `_ - . ( ) space`. CellData names must be unique; renaming a CellData also auto-updates the owning CellTest's DataSourceKey.
550
550
 
551
+ ## Write Data Metadata
552
+ Assign or update metadata on a **data set** (CellData record), the same way as cells, using either:
553
+ - the built-in field `"name"` (renames the data set) or `"dataSourceKey"`
554
+ - the human-readable field name of a user-defined property (e.g., "Active Cycle Test?")
555
+ - or the internal propertyDefinitionId (UUID)
556
+
557
+ User-defined property names are resolved against the data-set metadata definitions, which you can list with `api.list_data_metadata_definitions()`.
558
+
559
+ **Per-entity atomic:** all changes targeting a single data set commit or rollback together; other data sets are unaffected. Returns a `BatchUpdateResult` with `.success` and `.errors`, identical to `write_cell_metadata`.
560
+
561
+ #### 📘 Examples
562
+
563
+ ```python
564
+ # Example 1: Set a user-defined property on specific data sets
565
+ changes = [
566
+ {"id": "your-cell-data-guid-here", "field": "Active Cycle Test?", "value": True},
567
+ ]
568
+
569
+ result = api.write_data_metadata(changes=changes)
570
+ if not result:
571
+ for e in result.errors:
572
+ print(f"{e['itemId']}: {e['error']}")
573
+ ```
574
+
575
+ ```python
576
+ # Example 2: Rename a data set and update its dataSourceKey
577
+ changes = [
578
+ {"id": "your-cell-data-guid-here", "field": "name", "value": "Formation_Run_42"},
579
+ {"id": "your-cell-data-guid-here", "field": "dataSourceKey", "value": "Formation"},
580
+ ]
581
+
582
+ result = api.write_data_metadata(changes=changes)
583
+ assert result.success, result.errors
584
+ ```
585
+
586
+ ```python
587
+ # Example 3: Update using propertyDefinitionId (advanced)
588
+ changes = [
589
+ {
590
+ "id": "your-cell-data-guid-here",
591
+ "propertyDefinitionId": "your-property-definition-guid",
592
+ "value": "true"
593
+ }
594
+ ]
595
+
596
+ result = api.write_data_metadata(changes=changes)
597
+ assert result.success, result.errors
598
+ ```
599
+
551
600
  ## Stitch Data
552
601
  Combine multiple data sets into a single stitched data set. This is useful for creating continuous test data from multiple separate test runs.
553
602
 
@@ -964,14 +1013,16 @@ for group in dupes:
964
1013
  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.
965
1014
 
966
1015
  #### Parameters
967
- - `execution_id`: **str (required)**
968
- The execution ID (GUID) of the running or completed execution
1016
+ - `execution_id`: **str (optional inside cloud executions)**
1017
+ 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`
969
1018
  - `file_path`: **str (required)**
970
1019
  Local path to the file to upload. Maximum size: 50 MB
971
1020
  - `title`: **str (required)**
972
1021
  Human-readable title for the artifact
973
1022
  - `filename`: **str (optional)**
974
1023
  Override the filename stored on the server. If not provided, derived from the title and file extension
1024
+ - `mime`: **str (optional)**
1025
+ Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
975
1026
 
976
1027
  #### Returns
977
1028
  - Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
@@ -979,22 +1030,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
979
1030
  #### 📘 Examples
980
1031
 
981
1032
  ```python
982
- # Upload a plot generated during a Python execution
983
- result = api.upload_execution_artifact(
984
- execution_id="your-execution-guid",
985
- file_path="voltage_plot.png",
986
- title="Voltage vs Time"
987
- )
1033
+ # Inside a cloud execution execution_id is resolved from the environment
1034
+ result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
988
1035
  print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
989
1036
  ```
990
1037
 
991
1038
  ```python
992
- # Upload a CSV results file with a custom filename
1039
+ # Explicit execution ID with a custom filename and content type
993
1040
  result = api.upload_execution_artifact(
994
1041
  execution_id="your-execution-guid",
995
1042
  file_path="results.csv",
996
1043
  title="Cycle Summary Results",
997
- filename="cycle_summary_2025.csv"
1044
+ filename="cycle_summary_2025.csv",
1045
+ mime="text/csv"
998
1046
  )
999
1047
  ```
1000
1048
 
@@ -9,4 +9,5 @@ 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_upload_execution_artifact.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "micantis"
3
- version = "1.2.3"
3
+ version = "1.2.5"
4
4
  description = "Package to simplify Micantis API usage"
5
5
  authors = [
6
6
  { name = "Mykela DeLuca", email = "mykela.deluca@micantis.io" }
@@ -6,12 +6,14 @@ Mocks the HTTP layer so it can run anywhere without a live server. Exercises:
6
6
  - 207 Multi-Status → BatchUpdateResult(success=False, errors=[...])
7
7
  - 400 Bad Request → BatchUpdateResult(success=False, errors=[...])
8
8
  - Truthiness (`if result:` works)
9
- - Rejection of unknown fields (client-side validation)
9
+ - User-property name resolution and propertyDefinitionId pass-through
10
10
 
11
11
  Run: pytest
12
12
  """
13
13
  from unittest.mock import MagicMock
14
14
 
15
+ import pandas as pd
16
+
15
17
  from micantis.api_wrapper import MicantisAPI, BatchUpdateResult
16
18
 
17
19
 
@@ -115,18 +117,55 @@ def test_write_data_207_returns_partial():
115
117
  print("PASS: data/updatemetadata 207 → partial")
116
118
 
117
119
 
118
- def test_write_data_rejects_unknown_field():
120
+ def test_write_data_unknown_property_name_raises():
121
+ # A non-built-in field is treated as a user-property name and resolved against the
122
+ # CellData metadata definitions; a name with no matching definition raises.
119
123
  api = _make_api()
124
+ api._list_data_metadata_definitions = lambda return_format, timeout: pd.DataFrame(
125
+ {"name": ["Active Cycle Test?"], "id": ["def-1"]}
126
+ )
120
127
  try:
121
128
  api._write_data_metadata(
122
- [{"id": "x", "field": "Technician", "value": "foo"}], timeout=5,
129
+ [{"id": "x", "field": "Nonexistent Property", "value": "foo"}], timeout=5,
123
130
  )
124
131
  except RuntimeError as e:
125
- assert "Technician" in str(e)
126
- assert "name" in str(e) and "dataSourceKey" in str(e)
127
- print("PASS: data/updatemetadata rejects unknown field client-side")
132
+ assert "Nonexistent Property" in str(e)
133
+ assert "not found in metadata definitions" in str(e)
134
+ print("PASS: data/updatemetadata raises on unknown user-property name")
128
135
  return
129
- raise AssertionError("expected RuntimeError on unknown field")
136
+ raise AssertionError("expected RuntimeError on unknown property name")
137
+
138
+
139
+ def test_write_data_resolves_property_name():
140
+ # A user-property name is resolved to its definition ID and sent as propertyDefinitionId.
141
+ api = _make_api()
142
+ api._list_data_metadata_definitions = lambda return_format, timeout: pd.DataFrame(
143
+ {"name": ["Active Cycle Test?"], "id": ["def-1"]}
144
+ )
145
+ api.session.post.return_value = _mock_response(204)
146
+ result = api._write_data_metadata(
147
+ [{"id": "x", "field": "Active Cycle Test?", "value": True}], timeout=5,
148
+ )
149
+ assert result.success is True
150
+ sent = api.session.post.call_args.kwargs["json"]["changes"][0]
151
+ assert sent == {"id": "x", "value": "True", "propertyDefinitionId": "def-1"}
152
+ print("PASS: data/updatemetadata resolves user-property name → propertyDefinitionId")
153
+
154
+
155
+ def test_write_data_accepts_property_definition_id():
156
+ # A propertyDefinitionId is forwarded directly, with no definitions lookup.
157
+ api = _make_api()
158
+ api._list_data_metadata_definitions = MagicMock(
159
+ side_effect=AssertionError("should not look up definitions when an id is supplied")
160
+ )
161
+ api.session.post.return_value = _mock_response(204)
162
+ result = api._write_data_metadata(
163
+ [{"id": "x", "propertyDefinitionId": "def-9", "value": "true"}], timeout=5,
164
+ )
165
+ assert result.success is True
166
+ sent = api.session.post.call_args.kwargs["json"]["changes"][0]
167
+ assert sent == {"id": "x", "value": "true", "propertyDefinitionId": "def-9"}
168
+ print("PASS: data/updatemetadata forwards propertyDefinitionId directly")
130
169
 
131
170
 
132
171
  def test_name_is_accepted_as_builtin():
@@ -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