micantis 1.2.2__tar.gz → 1.2.3__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.3
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,12 +542,12 @@ changes = [
535
542
  }
536
543
  ]
537
544
 
538
- api.write_cell_metadata(changes=changes)
539
-
540
- # Verify the changes
541
- api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
545
+ result = api.write_cell_metadata(changes=changes)
546
+ assert result.success, result.errors
542
547
  ```
543
548
 
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
+
544
551
  ## Stitch Data
545
552
  Combine multiple data sets into a single stitched data set. This is useful for creating continuous test data from multiple separate test runs.
546
553
 
@@ -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,12 +522,12 @@ changes = [
535
522
  }
536
523
  ]
537
524
 
538
- api.write_cell_metadata(changes=changes)
539
-
540
- # Verify the changes
541
- api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
525
+ result = api.write_cell_metadata(changes=changes)
526
+ assert result.success, result.errors
542
527
  ```
543
528
 
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
+
544
531
  ## Stitch Data
545
532
  Combine multiple data sets into a single stitched data set. This is useful for creating continuous test data from multiple separate test runs.
546
533
 
@@ -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.
@@ -967,30 +995,74 @@ class MicantisAPI:
967
995
  except requests.exceptions.RequestException as e:
968
996
  raise self._format_request_error(e)
969
997
 
998
+ # Built-in fields that the public /cells/updatemetadata endpoint understands directly
999
+ # (i.e. mapped to an OrderByColumn on the server side, not to a UserPropertyDefinition).
1000
+ _BUILTIN_CELL_METADATA_FIELDS = {"dataSourceKey", "name"}
1001
+
1002
+ # Built-in fields that the public /data/updatemetadata endpoint understands directly.
1003
+ _BUILTIN_DATA_METADATA_FIELDS = {"dataSourceKey", "name"}
1004
+
970
1005
  def write_cell_metadata(self, changes: list, timeout: int = 30):
971
1006
  """
972
1007
  Wrapper for POST /cells/updatemetadata endpoint.
973
- Accepts either a metadata 'field' (human-readable name) or 'propertyDefinitionId'.
1008
+
1009
+ Each change targets either a built-in field (supported: "name",
1010
+ "dataSourceKey") or a user-defined metadata property. For user-defined
1011
+ properties, pass either a human-readable 'field' name (resolved via
1012
+ metadata definitions) or a 'propertyDefinitionId' UUID.
1013
+
1014
+ Built-in field names take precedence over user-defined names; if you have a
1015
+ user-defined property literally named "name" or "dataSourceKey", use
1016
+ propertyDefinitionId to disambiguate.
1017
+
1018
+ Renaming a cell ("name" field) enforces name-uniqueness and a character
1019
+ whitelist (letters, digits, `_ - . ( ) space`); renames that fail these
1020
+ checks return an error without applying the change.
1021
+
1022
+ **Per-entity atomicity:** all changes targeting the same cell commit or
1023
+ rollback together. Across cells, each entity is independent — if one
1024
+ cell's changes fail, others still persist. The result indicates which
1025
+ cells failed; successful cells' changes are already saved.
974
1026
 
975
1027
  Automatically reauthenticates if the session is expired or missing.
976
- Prints an error message and returns None if the request ultimately fails.
1028
+ Prints a message and returns None if the network request fails entirely.
977
1029
 
978
1030
  Parameters
979
1031
  ----------
980
1032
  changes : list of dict
981
1033
  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)
1034
+ - "id": str (cell test GUID)
1035
+ - One of:
1036
+ - "field": "name" or "dataSourceKey" (built-in fields)
1037
+ - "field": "<metadata name>" (user-defined, e.g. "Weight (g)")
1038
+ - "propertyDefinitionId": "<UUID>" (user-defined)
1039
+ - "value": any (will be cast to str). For multi-valued CellTest
1040
+ DataSourceKeys, pass a comma-separated string (e.g. "Formation,CycleLife").
986
1041
 
987
1042
  timeout : int, optional
988
1043
  Timeout in seconds for the request. Default is 30.
989
1044
 
990
1045
  Returns
991
1046
  -------
992
- bool or None
993
- True if successful (204 No Content), or None if an error occurs.
1047
+ BatchUpdateResult or None
1048
+ A `BatchUpdateResult` whose `.success` is True iff every entity
1049
+ committed (HTTP 204). On partial (207) or full failure (400), the
1050
+ result is falsy and `.errors` lists the per-entity failures as
1051
+ dicts with keys `itemId` and `error`. Returns None on a network-
1052
+ level error.
1053
+
1054
+ Examples
1055
+ --------
1056
+ >>> result = api.write_cell_metadata([
1057
+ ... {"id": cell_id, "field": "name", "value": "A-EVE-15P-Cell-04"},
1058
+ ... {"id": cell_id, "field": "dataSourceKey", "value": "Formation"},
1059
+ ... {"id": cell_id, "field": "Technician", "value": "Alice"},
1060
+ ... ])
1061
+ >>> if result:
1062
+ ... print("all good")
1063
+ ... else:
1064
+ ... for e in result.errors:
1065
+ ... print(f"{e['itemId']}: {e['error']}")
994
1066
  """
995
1067
  try:
996
1068
  return self._retry_on_auth_failure(self._write_cell_metadata, changes, timeout)
@@ -1012,8 +1084,12 @@ class MicantisAPI:
1012
1084
 
1013
1085
  self._refresh_token_if_needed()
1014
1086
 
1015
- # Build lookup: field name -> propertyDefinitionId (only if any change uses field names)
1016
- needs_lookup = any("field" in change for change in changes)
1087
+ # Only look up user-property definitions if at least one change uses a 'field' that
1088
+ # isn't a built-in (lookup is a separate round-trip and unnecessary otherwise).
1089
+ needs_lookup = any(
1090
+ "field" in change and change["field"] not in self._BUILTIN_CELL_METADATA_FIELDS
1091
+ for change in changes
1092
+ )
1017
1093
  if needs_lookup:
1018
1094
  metadata_df = self._list_cell_metadata_definitions('df', timeout)
1019
1095
  name_to_id = dict(zip(metadata_df['name'], metadata_df['id']))
@@ -1022,37 +1098,148 @@ class MicantisAPI:
1022
1098
 
1023
1099
  formatted_changes = []
1024
1100
  for change in changes:
1025
- if "propertyDefinitionId" in change:
1026
- property_id = change["propertyDefinitionId"]
1101
+ item = {"id": change["id"], "value": str(change["value"])}
1102
+
1103
+ if "field" in change and change["field"] in self._BUILTIN_CELL_METADATA_FIELDS:
1104
+ item["field"] = change["field"]
1105
+ elif "propertyDefinitionId" in change:
1106
+ item["propertyDefinitionId"] = change["propertyDefinitionId"]
1027
1107
  elif "field" in change:
1028
1108
  field_name = change["field"]
1029
1109
  if field_name not in name_to_id:
1030
1110
  raise RuntimeError(f"Field name '{field_name}' not found in metadata definitions.")
1031
- property_id = name_to_id[field_name]
1111
+ item["propertyDefinitionId"] = name_to_id[field_name]
1032
1112
  else:
1033
1113
  raise RuntimeError("Each change must include either 'field' or 'propertyDefinitionId'.")
1034
1114
 
1115
+ formatted_changes.append(item)
1116
+
1117
+ payload = {"changes": formatted_changes}
1118
+
1119
+ try:
1120
+ response = self.session.post(
1121
+ f"{self.service_url}/publicapi/v1/cells/updatemetadata",
1122
+ headers=self.headers,
1123
+ json=payload,
1124
+ timeout=timeout
1125
+ )
1126
+
1127
+ if response.status_code == 204:
1128
+ print("✅ Metadata update successful!")
1129
+ return BatchUpdateResult(success=True)
1130
+ if response.status_code in (207, 400):
1131
+ body = response.json()
1132
+ errors = body.get("errors", []) if isinstance(body, dict) else []
1133
+ if response.status_code == 207:
1134
+ print(f"⚠️ Metadata update partial: {len(errors)} of {len(formatted_changes)} entities failed.")
1135
+ else:
1136
+ print(f"⚠️ Metadata update failed for all entities.")
1137
+ return BatchUpdateResult(success=False, errors=errors)
1138
+ raise RuntimeError(f"Metadata update failed: {response.status_code} - {response.text}")
1139
+
1140
+ except requests.exceptions.RequestException as e:
1141
+ raise self._format_request_error(e)
1142
+
1143
+ def write_data_metadata(self, changes: list, timeout: int = 30):
1144
+ """
1145
+ Wrapper for POST /data/updatemetadata endpoint.
1146
+
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.
1150
+
1151
+ Renaming a data set ("name" field) enforces name-uniqueness; the change is
1152
+ rejected if another data set already has the target name. The owning
1153
+ CellTest's DataSourceKey is updated automatically to match the new name
1154
+ where applicable.
1155
+
1156
+ **Per-entity atomicity:** all changes targeting the same data set commit
1157
+ or rollback together. Across data sets, each entity is independent — if
1158
+ one data set's changes fail, others still persist. The result indicates
1159
+ which data sets failed; successful ones are already saved.
1160
+
1161
+ Automatically reauthenticates if the session is expired or missing.
1162
+ Prints a message and returns None if the network request fails entirely.
1163
+
1164
+ Parameters
1165
+ ----------
1166
+ changes : list of dict
1167
+ A list of metadata change dictionaries. Each dictionary must contain:
1168
+ - "id": str (cell data GUID)
1169
+ - "field": "name" or "dataSourceKey"
1170
+ - "value": any (will be cast to str)
1171
+
1172
+ timeout : int, optional
1173
+ Timeout in seconds for the request. Default is 30.
1174
+
1175
+ Returns
1176
+ -------
1177
+ BatchUpdateResult or None
1178
+ A `BatchUpdateResult` whose `.success` is True iff every entity
1179
+ committed (HTTP 204). On partial (207) or full failure (400), the
1180
+ result is falsy and `.errors` lists the per-entity failures as
1181
+ dicts with keys `itemId` and `error`. Returns None on a network-
1182
+ level error.
1183
+
1184
+ Examples
1185
+ --------
1186
+ >>> 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"},
1189
+ ... ])
1190
+ >>> if not result:
1191
+ ... for e in result.errors:
1192
+ ... print(f"{e['itemId']}: {e['error']}")
1193
+ """
1194
+ try:
1195
+ return self._retry_on_auth_failure(self._write_data_metadata, changes, timeout)
1196
+ except Exception as e:
1197
+ print(f"⚠️ write_data_metadata failed: {e}")
1198
+ return None
1199
+
1200
+ def _write_data_metadata(self, changes: list, timeout: int):
1201
+ if not self.headers:
1202
+ raise RuntimeError("Authentication required. Call authenticate() first.")
1203
+
1204
+ self._refresh_token_if_needed()
1205
+
1206
+ formatted_changes = []
1207
+ 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
+ )
1035
1215
  formatted_changes.append({
1036
1216
  "id": change["id"],
1037
- "propertyDefinitionId": property_id,
1038
- "value": str(change["value"])
1217
+ "field": field,
1218
+ "value": str(change["value"]),
1039
1219
  })
1040
1220
 
1041
1221
  payload = {"changes": formatted_changes}
1042
1222
 
1043
1223
  try:
1044
1224
  response = self.session.post(
1045
- f"{self.service_url}/publicapi/v1/cells/updatemetadata",
1225
+ f"{self.service_url}/publicapi/v1/data/updatemetadata",
1046
1226
  headers=self.headers,
1047
1227
  json=payload,
1048
1228
  timeout=timeout
1049
1229
  )
1050
1230
 
1051
1231
  if response.status_code == 204:
1052
- print("✅ Metadata update successful!")
1053
- return True
1054
- else:
1055
- raise RuntimeError(f"Metadata update failed: {response.status_code} - {response.text}")
1232
+ print("✅ Data metadata update successful!")
1233
+ return BatchUpdateResult(success=True)
1234
+ if response.status_code in (207, 400):
1235
+ body = response.json()
1236
+ errors = body.get("errors", []) if isinstance(body, dict) else []
1237
+ if response.status_code == 207:
1238
+ print(f"⚠️ Data metadata update partial: {len(errors)} of {len(formatted_changes)} entities failed.")
1239
+ else:
1240
+ print(f"⚠️ Data metadata update failed for all entities.")
1241
+ return BatchUpdateResult(success=False, errors=errors)
1242
+ raise RuntimeError(f"Data metadata update failed: {response.status_code} - {response.text}")
1056
1243
 
1057
1244
  except requests.exceptions.RequestException as e:
1058
1245
  raise self._format_request_error(e)
@@ -2026,110 +2213,6 @@ class MicantisAPI:
2026
2213
  cycle, step_kind, step_index)
2027
2214
  return action
2028
2215
 
2029
- @staticmethod
2030
- def rename_column(source_column_index, new_name, name=None):
2031
- """Build a rename-column action for clean_data (MetadataEdit mode).
2032
-
2033
- Rename a column's display Name — what graphs, tables, and reports look up
2034
- by YAxisName. Targets the ORIGINAL source column index; action order does
2035
- not shift index meaning.
2036
-
2037
- Parameters
2038
- ----------
2039
- source_column_index : int
2040
- 0-based index into the ORIGINAL source column list.
2041
- new_name : str
2042
- New display name. Preserve exact spacing / punctuation expected by
2043
- downstream lookups (e.g. "Temp 1 (C)" with two spaces).
2044
- name : str, optional
2045
- Descriptive name for this action.
2046
- """
2047
- action = {"$type": "rename",
2048
- "sourceColumnIndex": int(source_column_index),
2049
- "newName": new_name}
2050
- if name is not None:
2051
- action["name"] = name
2052
- return action
2053
-
2054
- @staticmethod
2055
- def change_column_kind(source_column_index, new_kind, name=None):
2056
- """Build a change-column-kind action for clean_data (MetadataEdit mode).
2057
-
2058
- Change a column's YAxisKind (cycle-tester aux) or ImportItemKind (logger)
2059
- — controls which kind-based lookups match the column. Targets the ORIGINAL
2060
- source column index.
2061
-
2062
- Parameters
2063
- ----------
2064
- source_column_index : int
2065
- 0-based index into the ORIGINAL source column list.
2066
- new_kind : str
2067
- Exact enum value name (e.g. 'Voltage', 'AuxTemperature').
2068
- name : str, optional
2069
- Descriptive name for this action.
2070
- """
2071
- action = {"$type": "kind",
2072
- "sourceColumnIndex": int(source_column_index),
2073
- "newKind": new_kind}
2074
- if name is not None:
2075
- action["name"] = name
2076
- return action
2077
-
2078
- @staticmethod
2079
- def reorder_columns(source_column_indices, name=None):
2080
- """Build a reorder-columns action for clean_data (MetadataEdit mode).
2081
-
2082
- Declarative final column layout: source columns appear in the cleaned
2083
- output in the order listed; any source index not listed is dropped.
2084
- Combines drop + reorder in one action. At most one per definition.
2085
-
2086
- Parameters
2087
- ----------
2088
- source_column_indices : list[int]
2089
- Ordered list of 0-based source column indices to keep. Omit an index
2090
- to drop that column. Example: [0, 1, 3, 4, 5] drops source column 2.
2091
- name : str, optional
2092
- Descriptive name for this action.
2093
- """
2094
- action = {"$type": "reorderColumns",
2095
- "sourceColumnIndices": [int(i) for i in source_column_indices]}
2096
- if name is not None:
2097
- action["name"] = name
2098
- return action
2099
-
2100
- @staticmethod
2101
- def edit_logger_step(line=None, lines=None, columns=None, name=None):
2102
- """Build an edit-logger-timestep action for clean_data (logger-data analog of modify_step/bulk_modify).
2103
-
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.
2107
-
2108
- Parameters
2109
- ----------
2110
- line : int, optional
2111
- Single line number to edit.
2112
- lines : list of (int, int) tuples, optional
2113
- Line number ranges to edit, e.g. [(100, 200)]. End may be None for open-ended.
2114
- columns : dict of {int: float}
2115
- Column edits as {column_index: value}. Column index is 0-based (0-9).
2116
- name : str, optional
2117
- Descriptive name for this action.
2118
- """
2119
- if line is None and not lines:
2120
- raise ValueError("edit_logger_step requires either line or lines.")
2121
- if not columns:
2122
- raise ValueError("edit_logger_step requires columns.")
2123
- action = {"$type": "editLogger"}
2124
- if name is not None:
2125
- action["name"] = name
2126
- if line is not None:
2127
- action["lineNumber"] = line
2128
- if lines:
2129
- action["lineNumberRanges"] = _ranges(lines)
2130
- action["columns"] = [{"columnIndex": int(k), "value": float(v)} for k, v in columns.items()]
2131
- return action
2132
-
2133
2216
  def clean_data(self,
2134
2217
  source_ids: list,
2135
2218
  mode: str = 'CycleAutoFixup',
@@ -2148,19 +2231,14 @@ class MicantisAPI:
2148
2231
  source_ids : list of str
2149
2232
  List of cell data IDs (GUIDs) to clean.
2150
2233
  mode : str, optional
2151
- Cleaning mode. One of 'CycleAutoFixup', 'FilterCycles', 'Parametric', 'MetadataEdit'.
2152
- Default: 'CycleAutoFixup'. When ``actions`` is provided and mode is still the default,
2153
- it auto-sets to 'Parametric'. For 'MetadataEdit', pass mode explicitly.
2234
+ Cleaning mode. One of 'CycleAutoFixup', 'FilterCycles', 'Parametric'.
2235
+ Default: 'CycleAutoFixup'. Auto-set to 'Parametric' when actions is provided.
2154
2236
  filter_cycles_definition : dict, optional
2155
2237
  Required when mode is 'FilterCycles'. Definition for filtering cycles.
2156
2238
  actions : list, optional
2157
- For Parametric mode: list of dicts from api.remove_steps(), api.modify_step(),
2158
- api.bulk_modify(), api.edit_logger_step().
2159
- For MetadataEdit mode: list of dicts from api.rename_column(),
2160
- api.change_column_kind(), api.reorder_columns().
2161
- Actions are applied in the order given.
2162
-
2163
- Parametric example (cycle-tester)::
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::
2164
2242
 
2165
2243
  api.clean_data(
2166
2244
  source_ids=[id],
@@ -2170,29 +2248,6 @@ class MicantisAPI:
2170
2248
  api.bulk_modify(lines=[(100, 5000)], cycle=1),
2171
2249
  ]
2172
2250
  )
2173
-
2174
- Parametric example (DataLoggerData)::
2175
-
2176
- api.clean_data(
2177
- source_ids=[logger_id],
2178
- actions=[
2179
- api.remove_steps(lines=[(1, 3)]),
2180
- api.edit_logger_step(line=10, columns={0: 1.5}),
2181
- ]
2182
- )
2183
-
2184
- MetadataEdit example (rename + drop — works on both CT and logger)::
2185
-
2186
- api.clean_data(
2187
- source_ids=[id],
2188
- mode='MetadataEdit',
2189
- actions=[
2190
- api.rename_column(source_column_index=3, new_name='Temp 1 (C)'),
2191
- api.rename_column(source_column_index=4, new_name='Temp 2 (C)'),
2192
- api.rename_column(source_column_index=5, new_name='Temp 3 (C)'),
2193
- api.reorder_columns([0, 1, 3, 4, 5]), # drops source column 2
2194
- ]
2195
- )
2196
2251
  allow_async : bool, optional
2197
2252
  If True, runs asynchronously and returns job ID. Default: False.
2198
2253
  timeout : int, optional
@@ -2205,8 +2260,7 @@ class MicantisAPI:
2205
2260
  If allow_async is True: {'job_id': str}
2206
2261
  Returns None if an error occurs.
2207
2262
  """
2208
- # Auto-set mode only if the caller left the default; preserves explicit 'MetadataEdit' etc.
2209
- if actions is not None and mode == 'CycleAutoFixup':
2263
+ if actions is not None:
2210
2264
  mode = 'Parametric'
2211
2265
 
2212
2266
  try:
@@ -2237,10 +2291,8 @@ class MicantisAPI:
2237
2291
 
2238
2292
  if mode == 'Parametric' and not actions:
2239
2293
  raise ValueError("actions is required when mode is 'Parametric'.")
2240
- if mode == 'MetadataEdit' and not actions:
2241
- raise ValueError("actions is required when mode is 'MetadataEdit'.")
2242
2294
 
2243
- _mode_map = {'CycleAutoFixup': 1, 'Parametric': 2, 'FilterCycles': 4, 'MetadataEdit': 5}
2295
+ _mode_map = {'CycleAutoFixup': 1, 'Parametric': 2, 'FilterCycles': 4}
2244
2296
  if mode not in _mode_map:
2245
2297
  raise ValueError(f"mode must be one of {list(_mode_map)}, got '{mode}'")
2246
2298
 
@@ -2252,10 +2304,7 @@ class MicantisAPI:
2252
2304
  if filter_cycles_definition is not None:
2253
2305
  payload["filterCyclesDefinition"] = filter_cycles_definition
2254
2306
  if actions is not None:
2255
- if mode == 'MetadataEdit':
2256
- payload["metadataEditDefinition"] = {"actions": actions}
2257
- else:
2258
- payload["parametricDefinition"] = {"actions": actions}
2307
+ payload["parametricDefinition"] = {"actions": actions}
2259
2308
 
2260
2309
  url = f"{self.service_url}/publicapi/v1/data/clean"
2261
2310
  if allow_async:
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: micantis
3
+ Version: 1.2.3
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,12 +542,12 @@ changes = [
518
542
  }
519
543
  ]
520
544
 
521
- api.write_cell_metadata(changes=changes)
522
-
523
- # Verify the changes
524
- api.get_cell_metadata(cell_ids=["your-cell-test-guid-here"], metadata=['Weight (g)', 'Technician'])
545
+ result = api.write_cell_metadata(changes=changes)
546
+ assert result.success, result.errors
525
547
  ```
526
548
 
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
+
527
551
  ## Stitch Data
528
552
  Combine multiple data sets into a single stitched data set. This is useful for creating continuous test data from multiple separate test runs.
529
553
 
@@ -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.3"
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,135 @@
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
+ - Rejection of unknown fields (client-side validation)
10
+
11
+ Run: pytest
12
+ """
13
+ from unittest.mock import MagicMock
14
+
15
+ from micantis.api_wrapper import MicantisAPI, BatchUpdateResult
16
+
17
+
18
+ def _make_api():
19
+ api = MicantisAPI.__new__(MicantisAPI)
20
+ api.service_url = "https://fake.test"
21
+ api.headers = {"Authorization": "Bearer fake"}
22
+ api.session = MagicMock()
23
+ api._refresh_token_if_needed = lambda: None
24
+ return api
25
+
26
+
27
+ def _mock_response(status_code, body=None):
28
+ resp = MagicMock()
29
+ resp.status_code = status_code
30
+ resp.json = MagicMock(return_value=body or {})
31
+ resp.text = str(body) if body else ""
32
+ return resp
33
+
34
+
35
+ def test_batch_update_result_truthiness():
36
+ r_ok = BatchUpdateResult(success=True)
37
+ assert bool(r_ok) is True
38
+ assert r_ok.errors == []
39
+ assert "success=True" in repr(r_ok)
40
+
41
+ r_fail = BatchUpdateResult(success=False, errors=[{"itemId": "x", "error": "e"}])
42
+ assert bool(r_fail) is False
43
+ assert len(r_fail.errors) == 1
44
+ assert r_fail.errors[0]["itemId"] == "x"
45
+ assert "success=False" in repr(r_fail)
46
+ print("PASS: BatchUpdateResult truthiness + attributes")
47
+
48
+
49
+ def test_write_cell_204_returns_success():
50
+ api = _make_api()
51
+ api.session.post.return_value = _mock_response(204)
52
+ result = api._write_cell_metadata(
53
+ [{"id": "a", "field": "name", "value": "new"}], timeout=5,
54
+ )
55
+ assert isinstance(result, BatchUpdateResult)
56
+ assert result.success is True
57
+ assert bool(result) is True
58
+ assert result.errors == []
59
+ print("PASS: 204 → BatchUpdateResult(success=True)")
60
+
61
+
62
+ def test_write_cell_207_returns_partial():
63
+ api = _make_api()
64
+ body = {"errors": [{"itemId": "a", "error": "name exists"}]}
65
+ api.session.post.return_value = _mock_response(207, body)
66
+ result = api._write_cell_metadata(
67
+ [
68
+ {"id": "a", "field": "name", "value": "collision"},
69
+ {"id": "b", "field": "name", "value": "fresh"},
70
+ ],
71
+ timeout=5,
72
+ )
73
+ assert isinstance(result, BatchUpdateResult)
74
+ assert result.success is False
75
+ assert bool(result) is False
76
+ assert len(result.errors) == 1
77
+ assert result.errors[0]["itemId"] == "a"
78
+ assert "name exists" in result.errors[0]["error"]
79
+ print("PASS: 207 → BatchUpdateResult(success=False, errors=[...])")
80
+
81
+
82
+ def test_write_cell_400_returns_failure():
83
+ api = _make_api()
84
+ body = {"errors": [{"itemId": "a", "error": "name exists"}]}
85
+ api.session.post.return_value = _mock_response(400, body)
86
+ result = api._write_cell_metadata(
87
+ [{"id": "a", "field": "name", "value": "collision"}], timeout=5,
88
+ )
89
+ assert isinstance(result, BatchUpdateResult)
90
+ assert result.success is False
91
+ assert len(result.errors) == 1
92
+ print("PASS: 400 → BatchUpdateResult(success=False, errors=[...])")
93
+
94
+
95
+ def test_write_data_204_returns_success():
96
+ api = _make_api()
97
+ api.session.post.return_value = _mock_response(204)
98
+ result = api._write_data_metadata(
99
+ [{"id": "x", "field": "name", "value": "new"}], timeout=5,
100
+ )
101
+ assert isinstance(result, BatchUpdateResult)
102
+ assert result.success is True
103
+ print("PASS: data/updatemetadata 204 → success")
104
+
105
+
106
+ def test_write_data_207_returns_partial():
107
+ api = _make_api()
108
+ body = {"errors": [{"itemId": "x", "error": "name exists"}]}
109
+ api.session.post.return_value = _mock_response(207, body)
110
+ result = api._write_data_metadata(
111
+ [{"id": "x", "field": "name", "value": "collision"}], timeout=5,
112
+ )
113
+ assert result.success is False
114
+ assert result.errors[0]["itemId"] == "x"
115
+ print("PASS: data/updatemetadata 207 → partial")
116
+
117
+
118
+ def test_write_data_rejects_unknown_field():
119
+ api = _make_api()
120
+ try:
121
+ api._write_data_metadata(
122
+ [{"id": "x", "field": "Technician", "value": "foo"}], timeout=5,
123
+ )
124
+ 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")
128
+ return
129
+ raise AssertionError("expected RuntimeError on unknown field")
130
+
131
+
132
+ def test_name_is_accepted_as_builtin():
133
+ # Ensure the previously-missing client-side allowlist now accepts "name"
134
+ assert "name" in MicantisAPI._BUILTIN_CELL_METADATA_FIELDS
135
+ assert "name" in MicantisAPI._BUILTIN_DATA_METADATA_FIELDS
File without changes
File without changes
File without changes
File without changes