micantis 1.2.2__tar.gz → 1.2.4__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.2
3
+ Version: 1.2.4
4
4
  Summary: Package to simplify Micantis API usage
5
5
  Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
6
6
  License-Expression: MIT
@@ -13,6 +13,9 @@ Requires-Dist: msal
13
13
  Requires-Dist: azure-identity
14
14
  Provides-Extra: parquet
15
15
  Requires-Dist: pyarrow>=16.0.0; extra == "parquet"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=7; extra == "test"
18
+ Requires-Dist: pytest-mock>=3; extra == "test"
16
19
  Dynamic: license-file
17
20
 
18
21
  # Micantis API Wrapper
@@ -499,27 +502,31 @@ combined_df.head()
499
502
 
500
503
  ## Write Cell Metadata
501
504
  Micantis lets you programmatically assign or update metadata for each cell using either:
502
- - the human-readable field name (e.g., "Technician", "Weight (g)")
505
+ - the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
506
+ - the human-readable field name of a user-defined property (e.g., "Technician", "Weight (g)")
503
507
  - or the internal propertyDefinitionId (UUID)
504
508
 
509
+ **Per-entity atomic:** all changes targeting a single cell commit or rollback together. If any change for a cell fails, none of that cell's changes persist; other cells are unaffected. The call returns a `BatchUpdateResult`:
510
+
511
+ - `result.success` — `True` if every cell committed
512
+ - `result.errors` — list of `{itemId, error}` dicts for the cells whose changes were rolled back
513
+ - `if result:` — truthy iff full success (use `.success` or truthiness, not `result == True`)
514
+
505
515
  #### 📘 Examples
506
516
 
507
517
  ```python
508
- # Example 1: Update the technician field for a cell
518
+ # Example 1: Rename a cell and update its dataSourceKey + a user property
509
519
  changes = [
510
- {
511
- "id": "your-cell-test-guid-here", # cell test GUID
512
- "field": "Technician",
513
- "value": "Mykela"
514
- },
515
- {
516
- "id": "your-cell-test-guid-here",
517
- "field": "Weight (g)",
518
- "value": 98.7
519
- }
520
+ {"id": "your-cell-test-guid-here", "field": "name", "value": "A-EVE-15P-Cell-04"},
521
+ {"id": "your-cell-test-guid-here", "field": "dataSourceKey", "value": "Formation"},
522
+ {"id": "your-cell-test-guid-here", "field": "Technician", "value": "Mykela"},
523
+ {"id": "your-cell-test-guid-here", "field": "Weight (g)", "value": 98.7},
520
524
  ]
521
525
 
522
- api.write_cell_metadata(changes=changes)
526
+ result = api.write_cell_metadata(changes=changes)
527
+ if not result:
528
+ for e in result.errors:
529
+ print(f"{e['itemId']}: {e['error']}")
523
530
 
524
531
  # Verify the changes
525
532
  api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
@@ -535,10 +542,59 @@ changes = [
535
542
  }
536
543
  ]
537
544
 
538
- api.write_cell_metadata(changes=changes)
545
+ result = api.write_cell_metadata(changes=changes)
546
+ assert result.success, result.errors
547
+ ```
539
548
 
540
- # Verify the changes
541
- api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
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
+
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
542
598
  ```
543
599
 
544
600
  ## Stitch Data
@@ -1,20 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: micantis
3
- Version: 1.2.2
4
- Summary: Package to simplify Micantis API usage
5
- Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
6
- License-Expression: MIT
7
- Requires-Python: >=3.7
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: requests
11
- Requires-Dist: pandas
12
- Requires-Dist: msal
13
- Requires-Dist: azure-identity
14
- Provides-Extra: parquet
15
- Requires-Dist: pyarrow>=16.0.0; extra == "parquet"
16
- Dynamic: license-file
17
-
18
1
  # Micantis API Wrapper
19
2
 
20
3
  A lightweight Python wrapper for interacting with the Micantis API plus some helpful utilities.
@@ -499,27 +482,31 @@ combined_df.head()
499
482
 
500
483
  ## Write Cell Metadata
501
484
  Micantis lets you programmatically assign or update metadata for each cell using either:
502
- - the human-readable field name (e.g., "Technician", "Weight (g)")
485
+ - the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
486
+ - the human-readable field name of a user-defined property (e.g., "Technician", "Weight (g)")
503
487
  - or the internal propertyDefinitionId (UUID)
504
488
 
489
+ **Per-entity atomic:** all changes targeting a single cell commit or rollback together. If any change for a cell fails, none of that cell's changes persist; other cells are unaffected. The call returns a `BatchUpdateResult`:
490
+
491
+ - `result.success` — `True` if every cell committed
492
+ - `result.errors` — list of `{itemId, error}` dicts for the cells whose changes were rolled back
493
+ - `if result:` — truthy iff full success (use `.success` or truthiness, not `result == True`)
494
+
505
495
  #### 📘 Examples
506
496
 
507
497
  ```python
508
- # Example 1: Update the technician field for a cell
498
+ # Example 1: Rename a cell and update its dataSourceKey + a user property
509
499
  changes = [
510
- {
511
- "id": "your-cell-test-guid-here", # cell test GUID
512
- "field": "Technician",
513
- "value": "Mykela"
514
- },
515
- {
516
- "id": "your-cell-test-guid-here",
517
- "field": "Weight (g)",
518
- "value": 98.7
519
- }
500
+ {"id": "your-cell-test-guid-here", "field": "name", "value": "A-EVE-15P-Cell-04"},
501
+ {"id": "your-cell-test-guid-here", "field": "dataSourceKey", "value": "Formation"},
502
+ {"id": "your-cell-test-guid-here", "field": "Technician", "value": "Mykela"},
503
+ {"id": "your-cell-test-guid-here", "field": "Weight (g)", "value": 98.7},
520
504
  ]
521
505
 
522
- api.write_cell_metadata(changes=changes)
506
+ result = api.write_cell_metadata(changes=changes)
507
+ if not result:
508
+ for e in result.errors:
509
+ print(f"{e['itemId']}: {e['error']}")
523
510
 
524
511
  # Verify the changes
525
512
  api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
@@ -535,10 +522,59 @@ changes = [
535
522
  }
536
523
  ]
537
524
 
538
- api.write_cell_metadata(changes=changes)
525
+ result = api.write_cell_metadata(changes=changes)
526
+ assert result.success, result.errors
527
+ ```
539
528
 
540
- # Verify the changes
541
- api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
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
+
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
542
578
  ```
543
579
 
544
580
  ## Stitch Data
@@ -43,6 +43,34 @@ def _apply_field_overrides(action, voltage, current, charge_capacity,
43
43
  action[key] = val
44
44
 
45
45
 
46
+ class BatchUpdateResult:
47
+ """
48
+ Result of a batch metadata update call (`write_cell_metadata` / `write_data_metadata`).
49
+
50
+ Truthy iff every entity's changes were applied. On partial or full failure, inspect
51
+ `errors` for per-entity details; successful entities' changes are already persisted.
52
+
53
+ Attributes
54
+ ----------
55
+ success : bool
56
+ True iff every entity's changes committed (HTTP 204).
57
+ errors : list of dict
58
+ Per-entity errors. Each dict has keys `itemId` (str) and `error` (str).
59
+ Empty list when `success` is True.
60
+ """
61
+ def __init__(self, success: bool, errors: list = None):
62
+ self.success = success
63
+ self.errors = errors or []
64
+
65
+ def __bool__(self):
66
+ return self.success
67
+
68
+ def __repr__(self):
69
+ if self.success:
70
+ return "BatchUpdateResult(success=True)"
71
+ return f"BatchUpdateResult(success=False, errors={self.errors!r})"
72
+
73
+
46
74
  class MicantisAPI:
47
75
  """
48
76
  A client for interacting with the Micantis API.
@@ -856,6 +884,59 @@ class MicantisAPI:
856
884
  except requests.exceptions.RequestException as e:
857
885
  raise self._format_request_error(e)
858
886
 
887
+ def list_data_metadata_definitions(self, return_format='df', timeout: int = 30):
888
+ """
889
+ Returns a list of cell data metadata types.
890
+
891
+ Parameters
892
+ ----------
893
+ return_format : str, optional
894
+ Return format: 'df' for DataFrame (default), anything else for raw list.
895
+ timeout : int, optional
896
+ Request timeout in seconds. Default: 30.
897
+ """
898
+ try:
899
+ return self._retry_on_auth_failure(
900
+ self._list_data_metadata_definitions, return_format, timeout
901
+ )
902
+ except Exception as e:
903
+ print(f"⚠️ list_data_metadata_definitions failed: {e}")
904
+ return None
905
+
906
+ def _list_data_metadata_definitions(self, return_format: str, timeout: int):
907
+ """
908
+ Internal helper for list_data_metadata_definitions. Does not handle retries or printing.
909
+
910
+ Raises
911
+ ------
912
+ RuntimeError
913
+ If the request fails or authentication is missing.
914
+ """
915
+ if not self.headers:
916
+ raise RuntimeError("Authentication required. Call authenticate() first.")
917
+
918
+ self._refresh_token_if_needed()
919
+
920
+ try:
921
+ # Make the GET request
922
+ response = self.session.get(
923
+ f"{self.service_url}/publicapi/v1/metadata/list/data",
924
+ headers=self.headers,
925
+ timeout=timeout,
926
+ )
927
+ response.raise_for_status()
928
+
929
+ item = response.json()
930
+ if return_format == 'df':
931
+ df = pd.DataFrame(item)
932
+ return df
933
+
934
+ else:
935
+ return item
936
+
937
+ except requests.exceptions.RequestException as e:
938
+ raise self._format_request_error(e)
939
+
859
940
  def get_cell_metadata(self, cell_ids: list, metadata: list = None, return_images: bool = False, timeout: int = 30):
860
941
  """
861
942
  Fetch metadata for specific cells, using either metadata names or IDs.
@@ -967,30 +1048,74 @@ class MicantisAPI:
967
1048
  except requests.exceptions.RequestException as e:
968
1049
  raise self._format_request_error(e)
969
1050
 
1051
+ # Built-in fields that the public /cells/updatemetadata endpoint understands directly
1052
+ # (i.e. mapped to an OrderByColumn on the server side, not to a UserPropertyDefinition).
1053
+ _BUILTIN_CELL_METADATA_FIELDS = {"dataSourceKey", "name"}
1054
+
1055
+ # Built-in fields that the public /data/updatemetadata endpoint understands directly.
1056
+ _BUILTIN_DATA_METADATA_FIELDS = {"dataSourceKey", "name"}
1057
+
970
1058
  def write_cell_metadata(self, changes: list, timeout: int = 30):
971
1059
  """
972
1060
  Wrapper for POST /cells/updatemetadata endpoint.
973
- Accepts either a metadata 'field' (human-readable name) or 'propertyDefinitionId'.
1061
+
1062
+ Each change targets either a built-in field (supported: "name",
1063
+ "dataSourceKey") or a user-defined metadata property. For user-defined
1064
+ properties, pass either a human-readable 'field' name (resolved via
1065
+ metadata definitions) or a 'propertyDefinitionId' UUID.
1066
+
1067
+ Built-in field names take precedence over user-defined names; if you have a
1068
+ user-defined property literally named "name" or "dataSourceKey", use
1069
+ propertyDefinitionId to disambiguate.
1070
+
1071
+ Renaming a cell ("name" field) enforces name-uniqueness and a character
1072
+ whitelist (letters, digits, `_ - . ( ) space`); renames that fail these
1073
+ checks return an error without applying the change.
1074
+
1075
+ **Per-entity atomicity:** all changes targeting the same cell commit or
1076
+ rollback together. Across cells, each entity is independent — if one
1077
+ cell's changes fail, others still persist. The result indicates which
1078
+ cells failed; successful cells' changes are already saved.
974
1079
 
975
1080
  Automatically reauthenticates if the session is expired or missing.
976
- Prints an error message and returns None if the request ultimately fails.
1081
+ Prints a message and returns None if the network request fails entirely.
977
1082
 
978
1083
  Parameters
979
1084
  ----------
980
1085
  changes : list of dict
981
1086
  A list of metadata change dictionaries. Each dictionary must contain:
982
- - "id": str
983
- - "field": str (human-readable name, e.g., "Weight (g)") OR
984
- - "propertyDefinitionId": str (UUID)
985
- - "value": any (will be cast to str)
1087
+ - "id": str (cell test GUID)
1088
+ - One of:
1089
+ - "field": "name" or "dataSourceKey" (built-in fields)
1090
+ - "field": "<metadata name>" (user-defined, e.g. "Weight (g)")
1091
+ - "propertyDefinitionId": "<UUID>" (user-defined)
1092
+ - "value": any (will be cast to str). For multi-valued CellTest
1093
+ DataSourceKeys, pass a comma-separated string (e.g. "Formation,CycleLife").
986
1094
 
987
1095
  timeout : int, optional
988
1096
  Timeout in seconds for the request. Default is 30.
989
1097
 
990
1098
  Returns
991
1099
  -------
992
- bool or None
993
- True if successful (204 No Content), or None if an error occurs.
1100
+ BatchUpdateResult or None
1101
+ A `BatchUpdateResult` whose `.success` is True iff every entity
1102
+ committed (HTTP 204). On partial (207) or full failure (400), the
1103
+ result is falsy and `.errors` lists the per-entity failures as
1104
+ dicts with keys `itemId` and `error`. Returns None on a network-
1105
+ level error.
1106
+
1107
+ Examples
1108
+ --------
1109
+ >>> result = api.write_cell_metadata([
1110
+ ... {"id": cell_id, "field": "name", "value": "A-EVE-15P-Cell-04"},
1111
+ ... {"id": cell_id, "field": "dataSourceKey", "value": "Formation"},
1112
+ ... {"id": cell_id, "field": "Technician", "value": "Alice"},
1113
+ ... ])
1114
+ >>> if result:
1115
+ ... print("all good")
1116
+ ... else:
1117
+ ... for e in result.errors:
1118
+ ... print(f"{e['itemId']}: {e['error']}")
994
1119
  """
995
1120
  try:
996
1121
  return self._retry_on_auth_failure(self._write_cell_metadata, changes, timeout)
@@ -1012,8 +1137,12 @@ class MicantisAPI:
1012
1137
 
1013
1138
  self._refresh_token_if_needed()
1014
1139
 
1015
- # Build lookup: field name -> propertyDefinitionId (only if any change uses field names)
1016
- needs_lookup = any("field" in change for change in changes)
1140
+ # Only look up user-property definitions if at least one change uses a 'field' that
1141
+ # isn't a built-in (lookup is a separate round-trip and unnecessary otherwise).
1142
+ needs_lookup = any(
1143
+ "field" in change and change["field"] not in self._BUILTIN_CELL_METADATA_FIELDS
1144
+ for change in changes
1145
+ )
1017
1146
  if needs_lookup:
1018
1147
  metadata_df = self._list_cell_metadata_definitions('df', timeout)
1019
1148
  name_to_id = dict(zip(metadata_df['name'], metadata_df['id']))
@@ -1022,21 +1151,21 @@ class MicantisAPI:
1022
1151
 
1023
1152
  formatted_changes = []
1024
1153
  for change in changes:
1025
- if "propertyDefinitionId" in change:
1026
- property_id = change["propertyDefinitionId"]
1154
+ item = {"id": change["id"], "value": str(change["value"])}
1155
+
1156
+ if "field" in change and change["field"] in self._BUILTIN_CELL_METADATA_FIELDS:
1157
+ item["field"] = change["field"]
1158
+ elif "propertyDefinitionId" in change:
1159
+ item["propertyDefinitionId"] = change["propertyDefinitionId"]
1027
1160
  elif "field" in change:
1028
1161
  field_name = change["field"]
1029
1162
  if field_name not in name_to_id:
1030
1163
  raise RuntimeError(f"Field name '{field_name}' not found in metadata definitions.")
1031
- property_id = name_to_id[field_name]
1164
+ item["propertyDefinitionId"] = name_to_id[field_name]
1032
1165
  else:
1033
1166
  raise RuntimeError("Each change must include either 'field' or 'propertyDefinitionId'.")
1034
1167
 
1035
- formatted_changes.append({
1036
- "id": change["id"],
1037
- "propertyDefinitionId": property_id,
1038
- "value": str(change["value"])
1039
- })
1168
+ formatted_changes.append(item)
1040
1169
 
1041
1170
  payload = {"changes": formatted_changes}
1042
1171
 
@@ -1050,9 +1179,144 @@ class MicantisAPI:
1050
1179
 
1051
1180
  if response.status_code == 204:
1052
1181
  print("✅ Metadata update successful!")
1053
- return True
1182
+ return BatchUpdateResult(success=True)
1183
+ if response.status_code in (207, 400):
1184
+ body = response.json()
1185
+ errors = body.get("errors", []) if isinstance(body, dict) else []
1186
+ if response.status_code == 207:
1187
+ print(f"⚠️ Metadata update partial: {len(errors)} of {len(formatted_changes)} entities failed.")
1188
+ else:
1189
+ print(f"⚠️ Metadata update failed for all entities.")
1190
+ return BatchUpdateResult(success=False, errors=errors)
1191
+ raise RuntimeError(f"Metadata update failed: {response.status_code} - {response.text}")
1192
+
1193
+ except requests.exceptions.RequestException as e:
1194
+ raise self._format_request_error(e)
1195
+
1196
+ def write_data_metadata(self, changes: list, timeout: int = 30):
1197
+ """
1198
+ Wrapper for POST /data/updatemetadata endpoint.
1199
+
1200
+ Each change targets either a built-in field (supported: "name",
1201
+ "dataSourceKey") or a user-defined metadata property. For user-defined
1202
+ properties, pass either a human-readable 'field' name (resolved via
1203
+ metadata definitions) or a 'propertyDefinitionId' UUID.
1204
+
1205
+ Built-in field names take precedence over user-defined names; if you have a
1206
+ user-defined property literally named "name" or "dataSourceKey", use
1207
+ propertyDefinitionId to disambiguate.
1208
+
1209
+ Renaming a data set ("name" field) enforces name-uniqueness; the change is
1210
+ rejected if another data set already has the target name. The owning
1211
+ CellTest's DataSourceKey is updated automatically to match the new name
1212
+ where applicable.
1213
+
1214
+ **Per-entity atomicity:** all changes targeting the same data set commit
1215
+ or rollback together. Across data sets, each entity is independent — if
1216
+ one data set's changes fail, others still persist. The result indicates
1217
+ which data sets failed; successful ones are already saved.
1218
+
1219
+ Automatically reauthenticates if the session is expired or missing.
1220
+ Prints a message and returns None if the network request fails entirely.
1221
+
1222
+ Parameters
1223
+ ----------
1224
+ changes : list of dict
1225
+ A list of metadata change dictionaries. Each dictionary must contain:
1226
+ - "id": str (cell data GUID)
1227
+ - One of:
1228
+ - "field": "name" or "dataSourceKey" (built-in fields)
1229
+ - "field": "<metadata name>" (user-defined, e.g. "Active Cycle Test?")
1230
+ - "propertyDefinitionId": "<UUID>" (user-defined)
1231
+ - "value": any (will be cast to str)
1232
+
1233
+ timeout : int, optional
1234
+ Timeout in seconds for the request. Default is 30.
1235
+
1236
+ Returns
1237
+ -------
1238
+ BatchUpdateResult or None
1239
+ A `BatchUpdateResult` whose `.success` is True iff every entity
1240
+ committed (HTTP 204). On partial (207) or full failure (400), the
1241
+ result is falsy and `.errors` lists the per-entity failures as
1242
+ dicts with keys `itemId` and `error`. Returns None on a network-
1243
+ level error.
1244
+
1245
+ Examples
1246
+ --------
1247
+ >>> result = api.write_data_metadata([
1248
+ ... {"id": cell_data_id, "field": "name", "value": "Formation_Run_42"},
1249
+ ... {"id": cell_data_id, "field": "dataSourceKey", "value": "Formation"},
1250
+ ... {"id": cell_data_id, "field": "Active Cycle Test?", "value": True},
1251
+ ... ])
1252
+ >>> if not result:
1253
+ ... for e in result.errors:
1254
+ ... print(f"{e['itemId']}: {e['error']}")
1255
+ """
1256
+ try:
1257
+ return self._retry_on_auth_failure(self._write_data_metadata, changes, timeout)
1258
+ except Exception as e:
1259
+ print(f"⚠️ write_data_metadata failed: {e}")
1260
+ return None
1261
+
1262
+ def _write_data_metadata(self, changes: list, timeout: int):
1263
+ if not self.headers:
1264
+ raise RuntimeError("Authentication required. Call authenticate() first.")
1265
+
1266
+ self._refresh_token_if_needed()
1267
+
1268
+ # Only look up user-property definitions if at least one change uses a 'field' that
1269
+ # isn't a built-in (lookup is a separate round-trip and unnecessary otherwise).
1270
+ needs_lookup = any(
1271
+ "field" in change and change["field"] not in self._BUILTIN_DATA_METADATA_FIELDS
1272
+ for change in changes
1273
+ )
1274
+ if needs_lookup:
1275
+ metadata_df = self._list_data_metadata_definitions('df', timeout)
1276
+ name_to_id = dict(zip(metadata_df['name'], metadata_df['id']))
1277
+ else:
1278
+ name_to_id = {}
1279
+
1280
+ formatted_changes = []
1281
+ for change in changes:
1282
+ item = {"id": change["id"], "value": str(change["value"])}
1283
+
1284
+ if "field" in change and change["field"] in self._BUILTIN_DATA_METADATA_FIELDS:
1285
+ item["field"] = change["field"]
1286
+ elif "propertyDefinitionId" in change:
1287
+ item["propertyDefinitionId"] = change["propertyDefinitionId"]
1288
+ elif "field" in change:
1289
+ field_name = change["field"]
1290
+ if field_name not in name_to_id:
1291
+ raise RuntimeError(f"Field name '{field_name}' not found in metadata definitions.")
1292
+ item["propertyDefinitionId"] = name_to_id[field_name]
1054
1293
  else:
1055
- raise RuntimeError(f"Metadata update failed: {response.status_code} - {response.text}")
1294
+ raise RuntimeError("Each change must include either 'field' or 'propertyDefinitionId'.")
1295
+
1296
+ formatted_changes.append(item)
1297
+
1298
+ payload = {"changes": formatted_changes}
1299
+
1300
+ try:
1301
+ response = self.session.post(
1302
+ f"{self.service_url}/publicapi/v1/data/updatemetadata",
1303
+ headers=self.headers,
1304
+ json=payload,
1305
+ timeout=timeout
1306
+ )
1307
+
1308
+ if response.status_code == 204:
1309
+ print("✅ Data metadata update successful!")
1310
+ return BatchUpdateResult(success=True)
1311
+ if response.status_code in (207, 400):
1312
+ body = response.json()
1313
+ errors = body.get("errors", []) if isinstance(body, dict) else []
1314
+ if response.status_code == 207:
1315
+ print(f"⚠️ Data metadata update partial: {len(errors)} of {len(formatted_changes)} entities failed.")
1316
+ else:
1317
+ print(f"⚠️ Data metadata update failed for all entities.")
1318
+ return BatchUpdateResult(success=False, errors=errors)
1319
+ raise RuntimeError(f"Data metadata update failed: {response.status_code} - {response.text}")
1056
1320
 
1057
1321
  except requests.exceptions.RequestException as e:
1058
1322
  raise self._format_request_error(e)
@@ -2101,9 +2365,9 @@ class MicantisAPI:
2101
2365
  def edit_logger_step(line=None, lines=None, columns=None, name=None):
2102
2366
  """Build an edit-logger-timestep action for clean_data (logger-data analog of modify_step/bulk_modify).
2103
2367
 
2104
- Edit Column1..Column10 values by 0-based column index on rows matched by
2105
- ``line`` and/or ``lines`` (union). Use on DataLoggerData sources only; for
2106
- cycle-tester data use modify_step / bulk_modify.
2368
+ Edit column values by 0-based column index on rows matched by ``line`` and/or
2369
+ ``lines`` (union). Use on DataLoggerData sources only; for cycle-tester data
2370
+ use modify_step / bulk_modify.
2107
2371
 
2108
2372
  Parameters
2109
2373
  ----------
@@ -2112,7 +2376,8 @@ class MicantisAPI:
2112
2376
  lines : list of (int, int) tuples, optional
2113
2377
  Line number ranges to edit, e.g. [(100, 200)]. End may be None for open-ended.
2114
2378
  columns : dict of {int: float}
2115
- Column edits as {column_index: value}. Column index is 0-based (0-9).
2379
+ Column edits as {column_index: value}. Column index is 0-based and
2380
+ must refer to a valid column in the source ImportDefinition.
2116
2381
  name : str, optional
2117
2382
  Descriptive name for this action.
2118
2383
  """
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: micantis
3
+ Version: 1.2.4
4
+ Summary: Package to simplify Micantis API usage
5
+ Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.7
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: requests
11
+ Requires-Dist: pandas
12
+ Requires-Dist: msal
13
+ Requires-Dist: azure-identity
14
+ Provides-Extra: parquet
15
+ Requires-Dist: pyarrow>=16.0.0; extra == "parquet"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=7; extra == "test"
18
+ Requires-Dist: pytest-mock>=3; extra == "test"
19
+ Dynamic: license-file
20
+
1
21
  # Micantis API Wrapper
2
22
 
3
23
  A lightweight Python wrapper for interacting with the Micantis API plus some helpful utilities.
@@ -482,27 +502,31 @@ combined_df.head()
482
502
 
483
503
  ## Write Cell Metadata
484
504
  Micantis lets you programmatically assign or update metadata for each cell using either:
485
- - the human-readable field name (e.g., "Technician", "Weight (g)")
505
+ - the built-in field `"name"` (renames the cell) or `"dataSourceKey"`
506
+ - the human-readable field name of a user-defined property (e.g., "Technician", "Weight (g)")
486
507
  - or the internal propertyDefinitionId (UUID)
487
508
 
509
+ **Per-entity atomic:** all changes targeting a single cell commit or rollback together. If any change for a cell fails, none of that cell's changes persist; other cells are unaffected. The call returns a `BatchUpdateResult`:
510
+
511
+ - `result.success` — `True` if every cell committed
512
+ - `result.errors` — list of `{itemId, error}` dicts for the cells whose changes were rolled back
513
+ - `if result:` — truthy iff full success (use `.success` or truthiness, not `result == True`)
514
+
488
515
  #### 📘 Examples
489
516
 
490
517
  ```python
491
- # Example 1: Update the technician field for a cell
518
+ # Example 1: Rename a cell and update its dataSourceKey + a user property
492
519
  changes = [
493
- {
494
- "id": "your-cell-test-guid-here", # cell test GUID
495
- "field": "Technician",
496
- "value": "Mykela"
497
- },
498
- {
499
- "id": "your-cell-test-guid-here",
500
- "field": "Weight (g)",
501
- "value": 98.7
502
- }
520
+ {"id": "your-cell-test-guid-here", "field": "name", "value": "A-EVE-15P-Cell-04"},
521
+ {"id": "your-cell-test-guid-here", "field": "dataSourceKey", "value": "Formation"},
522
+ {"id": "your-cell-test-guid-here", "field": "Technician", "value": "Mykela"},
523
+ {"id": "your-cell-test-guid-here", "field": "Weight (g)", "value": 98.7},
503
524
  ]
504
525
 
505
- api.write_cell_metadata(changes=changes)
526
+ result = api.write_cell_metadata(changes=changes)
527
+ if not result:
528
+ for e in result.errors:
529
+ print(f"{e['itemId']}: {e['error']}")
506
530
 
507
531
  # Verify the changes
508
532
  api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
@@ -518,10 +542,59 @@ changes = [
518
542
  }
519
543
  ]
520
544
 
521
- api.write_cell_metadata(changes=changes)
545
+ result = api.write_cell_metadata(changes=changes)
546
+ assert result.success, result.errors
547
+ ```
522
548
 
523
- # Verify the changes
524
- api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
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
+
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
525
598
  ```
526
599
 
527
600
  ## Stitch Data
@@ -8,4 +8,5 @@ micantis.egg-info/PKG-INFO
8
8
  micantis.egg-info/SOURCES.txt
9
9
  micantis.egg-info/dependency_links.txt
10
10
  micantis.egg-info/requires.txt
11
- micantis.egg-info/top_level.txt
11
+ micantis.egg-info/top_level.txt
12
+ tests/test_batch_update_result.py
@@ -5,3 +5,7 @@ azure-identity
5
5
 
6
6
  [parquet]
7
7
  pyarrow>=16.0.0
8
+
9
+ [test]
10
+ pytest>=7
11
+ pytest-mock>=3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "micantis"
3
- version = "1.2.2"
3
+ version = "1.2.4"
4
4
  description = "Package to simplify Micantis API usage"
5
5
  authors = [
6
6
  { name = "Mykela DeLuca", email = "mykela.deluca@micantis.io" }
@@ -18,4 +18,14 @@ dependencies = [
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
- parquet = ["pyarrow>=16.0.0"]
21
+ parquet = ["pyarrow>=16.0.0"]
22
+ test = [
23
+ "pytest>=7",
24
+ "pytest-mock>=3",
25
+ ]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+ python_files = ["test_*.py"]
30
+ python_classes = ["Test*"]
31
+ python_functions = ["test_*"]
@@ -0,0 +1,174 @@
1
+ """
2
+ Unit tests for BatchUpdateResult parsing in write_cell_metadata / write_data_metadata.
3
+
4
+ Mocks the HTTP layer so it can run anywhere without a live server. Exercises:
5
+ - 204 No Content → BatchUpdateResult(success=True)
6
+ - 207 Multi-Status → BatchUpdateResult(success=False, errors=[...])
7
+ - 400 Bad Request → BatchUpdateResult(success=False, errors=[...])
8
+ - Truthiness (`if result:` works)
9
+ - User-property name resolution and propertyDefinitionId pass-through
10
+
11
+ Run: pytest
12
+ """
13
+ from unittest.mock import MagicMock
14
+
15
+ import pandas as pd
16
+
17
+ from micantis.api_wrapper import MicantisAPI, BatchUpdateResult
18
+
19
+
20
+ def _make_api():
21
+ api = MicantisAPI.__new__(MicantisAPI)
22
+ api.service_url = "https://fake.test"
23
+ api.headers = {"Authorization": "Bearer fake"}
24
+ api.session = MagicMock()
25
+ api._refresh_token_if_needed = lambda: None
26
+ return api
27
+
28
+
29
+ def _mock_response(status_code, body=None):
30
+ resp = MagicMock()
31
+ resp.status_code = status_code
32
+ resp.json = MagicMock(return_value=body or {})
33
+ resp.text = str(body) if body else ""
34
+ return resp
35
+
36
+
37
+ def test_batch_update_result_truthiness():
38
+ r_ok = BatchUpdateResult(success=True)
39
+ assert bool(r_ok) is True
40
+ assert r_ok.errors == []
41
+ assert "success=True" in repr(r_ok)
42
+
43
+ r_fail = BatchUpdateResult(success=False, errors=[{"itemId": "x", "error": "e"}])
44
+ assert bool(r_fail) is False
45
+ assert len(r_fail.errors) == 1
46
+ assert r_fail.errors[0]["itemId"] == "x"
47
+ assert "success=False" in repr(r_fail)
48
+ print("PASS: BatchUpdateResult truthiness + attributes")
49
+
50
+
51
+ def test_write_cell_204_returns_success():
52
+ api = _make_api()
53
+ api.session.post.return_value = _mock_response(204)
54
+ result = api._write_cell_metadata(
55
+ [{"id": "a", "field": "name", "value": "new"}], timeout=5,
56
+ )
57
+ assert isinstance(result, BatchUpdateResult)
58
+ assert result.success is True
59
+ assert bool(result) is True
60
+ assert result.errors == []
61
+ print("PASS: 204 → BatchUpdateResult(success=True)")
62
+
63
+
64
+ def test_write_cell_207_returns_partial():
65
+ api = _make_api()
66
+ body = {"errors": [{"itemId": "a", "error": "name exists"}]}
67
+ api.session.post.return_value = _mock_response(207, body)
68
+ result = api._write_cell_metadata(
69
+ [
70
+ {"id": "a", "field": "name", "value": "collision"},
71
+ {"id": "b", "field": "name", "value": "fresh"},
72
+ ],
73
+ timeout=5,
74
+ )
75
+ assert isinstance(result, BatchUpdateResult)
76
+ assert result.success is False
77
+ assert bool(result) is False
78
+ assert len(result.errors) == 1
79
+ assert result.errors[0]["itemId"] == "a"
80
+ assert "name exists" in result.errors[0]["error"]
81
+ print("PASS: 207 → BatchUpdateResult(success=False, errors=[...])")
82
+
83
+
84
+ def test_write_cell_400_returns_failure():
85
+ api = _make_api()
86
+ body = {"errors": [{"itemId": "a", "error": "name exists"}]}
87
+ api.session.post.return_value = _mock_response(400, body)
88
+ result = api._write_cell_metadata(
89
+ [{"id": "a", "field": "name", "value": "collision"}], timeout=5,
90
+ )
91
+ assert isinstance(result, BatchUpdateResult)
92
+ assert result.success is False
93
+ assert len(result.errors) == 1
94
+ print("PASS: 400 → BatchUpdateResult(success=False, errors=[...])")
95
+
96
+
97
+ def test_write_data_204_returns_success():
98
+ api = _make_api()
99
+ api.session.post.return_value = _mock_response(204)
100
+ result = api._write_data_metadata(
101
+ [{"id": "x", "field": "name", "value": "new"}], timeout=5,
102
+ )
103
+ assert isinstance(result, BatchUpdateResult)
104
+ assert result.success is True
105
+ print("PASS: data/updatemetadata 204 → success")
106
+
107
+
108
+ def test_write_data_207_returns_partial():
109
+ api = _make_api()
110
+ body = {"errors": [{"itemId": "x", "error": "name exists"}]}
111
+ api.session.post.return_value = _mock_response(207, body)
112
+ result = api._write_data_metadata(
113
+ [{"id": "x", "field": "name", "value": "collision"}], timeout=5,
114
+ )
115
+ assert result.success is False
116
+ assert result.errors[0]["itemId"] == "x"
117
+ print("PASS: data/updatemetadata 207 → partial")
118
+
119
+
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.
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
+ )
127
+ try:
128
+ api._write_data_metadata(
129
+ [{"id": "x", "field": "Nonexistent Property", "value": "foo"}], timeout=5,
130
+ )
131
+ except RuntimeError as e:
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")
135
+ return
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")
169
+
170
+
171
+ def test_name_is_accepted_as_builtin():
172
+ # Ensure the previously-missing client-side allowlist now accepts "name"
173
+ assert "name" in MicantisAPI._BUILTIN_CELL_METADATA_FIELDS
174
+ assert "name" in MicantisAPI._BUILTIN_DATA_METADATA_FIELDS
File without changes
File without changes
File without changes
File without changes