fleet-python 0.2.75b1__tar.gz → 0.2.75b2__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.
Files changed (92) hide show
  1. {fleet_python-0.2.75b1/fleet_python.egg-info → fleet_python-0.2.75b2}/PKG-INFO +1 -1
  2. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/resources/sqlite.py +156 -84
  3. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/resources/sqlite.py +156 -84
  4. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/db.py +153 -77
  5. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2/fleet_python.egg-info}/PKG-INFO +1 -1
  6. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/pyproject.toml +1 -1
  7. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/tests/test_expect_only.py +283 -16
  8. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/LICENSE +0 -0
  9. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/README.md +0 -0
  10. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/diff_example.py +0 -0
  11. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/dsl_example.py +0 -0
  12. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example.py +0 -0
  13. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/exampleResume.py +0 -0
  14. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_account.py +0 -0
  15. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_action_log.py +0 -0
  16. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_client.py +0 -0
  17. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_mcp_anthropic.py +0 -0
  18. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_mcp_openai.py +0 -0
  19. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_sync.py +0 -0
  20. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_task.py +0 -0
  21. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_tasks.py +0 -0
  22. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/example_verifier.py +0 -0
  23. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/export_tasks.py +0 -0
  24. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/fetch_tasks.py +0 -0
  25. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/gemini_example.py +0 -0
  26. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/import_tasks.py +0 -0
  27. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/iterate_verifiers.py +0 -0
  28. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/json_tasks_example.py +0 -0
  29. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/nova_act_example.py +0 -0
  30. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/openai_example.py +0 -0
  31. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/openai_simple_example.py +0 -0
  32. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/query_builder_example.py +0 -0
  33. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/quickstart.py +0 -0
  34. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/examples/test_cdp_logging.py +0 -0
  35. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/__init__.py +0 -0
  36. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/__init__.py +0 -0
  37. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/base.py +0 -0
  38. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/client.py +0 -0
  39. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/env/__init__.py +0 -0
  40. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/env/client.py +0 -0
  41. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/exceptions.py +0 -0
  42. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/global_client.py +0 -0
  43. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/instance/__init__.py +0 -0
  44. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/instance/base.py +0 -0
  45. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/instance/client.py +0 -0
  46. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/models.py +0 -0
  47. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/resources/__init__.py +0 -0
  48. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/resources/base.py +0 -0
  49. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/resources/browser.py +0 -0
  50. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/resources/mcp.py +0 -0
  51. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/tasks.py +0 -0
  52. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/verifiers/__init__.py +0 -0
  53. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/verifiers/bundler.py +0 -0
  54. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/_async/verifiers/verifier.py +0 -0
  55. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/base.py +0 -0
  56. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/client.py +0 -0
  57. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/config.py +0 -0
  58. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/env/__init__.py +0 -0
  59. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/env/client.py +0 -0
  60. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/exceptions.py +0 -0
  61. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/global_client.py +0 -0
  62. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/instance/__init__.py +0 -0
  63. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/instance/base.py +0 -0
  64. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/instance/client.py +0 -0
  65. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/instance/models.py +0 -0
  66. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/models.py +0 -0
  67. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/resources/__init__.py +0 -0
  68. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/resources/base.py +0 -0
  69. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/resources/browser.py +0 -0
  70. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/resources/mcp.py +0 -0
  71. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/tasks.py +0 -0
  72. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/types.py +0 -0
  73. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/__init__.py +0 -0
  74. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/bundler.py +0 -0
  75. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/code.py +0 -0
  76. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/decorator.py +0 -0
  77. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/parse.py +0 -0
  78. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/sql_differ.py +0 -0
  79. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet/verifiers/verifier.py +0 -0
  80. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet_python.egg-info/SOURCES.txt +0 -0
  81. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet_python.egg-info/dependency_links.txt +0 -0
  82. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet_python.egg-info/requires.txt +0 -0
  83. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/fleet_python.egg-info/top_level.txt +0 -0
  84. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/scripts/fix_sync_imports.py +0 -0
  85. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/scripts/unasync.py +0 -0
  86. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/setup.cfg +0 -0
  87. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/tests/__init__.py +0 -0
  88. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/tests/test_app_method.py +0 -0
  89. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/tests/test_instance_dispatch.py +0 -0
  90. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/tests/test_sqlite_resource_dual_mode.py +0 -0
  91. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  92. {fleet_python-0.2.75b1 → fleet_python-0.2.75b2}/tests/test_verifier_from_string.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.75b1
3
+ Version: 0.2.75b2
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -896,23 +896,28 @@ class AsyncSnapshotDiff:
896
896
  ):
897
897
  """Validate a collected diff against allowed changes with field-level spec support.
898
898
 
899
- This version supports:
900
- 1. Bulk field specs for additions: {"table": "t", "pk": 1, "fields": [("name", "value"), ("status", ...)]}
899
+ This version supports explicit change types via the "type" field:
900
+ 1. Insert specs: {"table": "t", "pk": 1, "type": "insert", "fields": [("name", "value"), ("status", ...)]}
901
901
  - ("name", value): check that field equals value
902
902
  - ("name", None): check that field is SQL NULL
903
903
  - ("name", ...): don't check the value, just acknowledge the field exists
904
- 2. Deletion specs:
905
- - Without field validation: {"table": "t", "pk": 1, "before": True}
906
- - With field validation: {"table": "t", "pk": 1, "fields": [...], "before": True}
907
- 3. Bulk field specs for modifications: {"table": "t", "pk": 1, "fields": [("name", "new_value"), ...]}
908
- - Same semantics as additions, but applied to the changed fields
909
- - Every changed field must be accounted for in the fields list
910
- 4. Whole-row specs:
904
+ 2. Modify specs: {"table": "t", "pk": 1, "type": "modify", "resulting_fields": [...], "no_other_changes": True/False}
905
+ - Uses "resulting_fields" (not "fields") to be explicit about what's being checked
906
+ - "no_other_changes" is REQUIRED and must be True or False:
907
+ - True: Every changed field must be in resulting_fields (strict mode)
908
+ - False: Only check fields in resulting_fields match, ignore other changes
909
+ - ("field_name", value): check that after value equals value
910
+ - ("field_name", None): check that after value is SQL NULL
911
+ - ("field_name", ...): don't check value, just acknowledge field changed
912
+ 3. Delete specs:
913
+ - Without field validation: {"table": "t", "pk": 1, "type": "delete"}
914
+ - With field validation: {"table": "t", "pk": 1, "type": "delete", "fields": [...]}
915
+ 4. Whole-row specs (legacy):
911
916
  - For additions: {"table": "t", "pk": 1, "fields": None, "after": "__added__"}
912
917
  - For deletions: {"table": "t", "pk": 1, "fields": None, "after": "__removed__"}
913
918
 
914
- When using "fields", every field (for additions: all row fields;
915
- for modifications: all changed fields) must be accounted for in the list.
919
+ When using "fields" for inserts, every field must be accounted for in the list.
920
+ For modifications, use "resulting_fields" with explicit "no_other_changes".
916
921
  For deletions with "fields", all specified fields are validated against the deleted row.
917
922
  """
918
923
 
@@ -942,28 +947,41 @@ class AsyncSnapshotDiff:
942
947
  return True
943
948
  return False
944
949
 
945
- def _get_fields_spec(
946
- table: str, row_id: Any, for_deletion: bool = False
950
+ def _get_fields_spec_for_type(
951
+ table: str, row_id: Any, change_type: str
947
952
  ) -> Optional[List[Tuple[str, Any]]]:
948
- """Get the bulk fields spec for a given table/row if it exists."""
953
+ """Get the bulk fields spec for a given table/row/type if it exists.
954
+
955
+ Args:
956
+ table: The table name
957
+ row_id: The primary key value
958
+ change_type: One of "insert", "modify", or "delete"
959
+
960
+ Note: For "modify" type, use _get_modify_spec instead.
961
+ """
949
962
  for allowed in allowed_changes:
950
963
  allowed_pk = allowed.get("pk")
951
964
  pk_match = (
952
965
  str(allowed_pk) == str(row_id) if allowed_pk is not None else False
953
966
  )
954
- if allowed["table"] == table and pk_match and "fields" in allowed:
955
- # For deletions, require "before": True
956
- if for_deletion:
957
- if allowed.get("before") is True:
958
- return allowed["fields"]
959
- else:
960
- # For additions, accept specs without "before": True
961
- if allowed.get("before") is not True:
962
- return allowed["fields"]
967
+ if (
968
+ allowed["table"] == table
969
+ and pk_match
970
+ and allowed.get("type") == change_type
971
+ and "fields" in allowed
972
+ ):
973
+ return allowed["fields"]
963
974
  return None
964
975
 
965
- def _is_deletion_allowed(table: str, row_id: Any) -> bool:
966
- """Check if a deletion is allowed via 'before': True spec (with or without fields)."""
976
+ def _get_modify_spec(table: str, row_id: Any) -> Optional[Dict[str, Any]]:
977
+ """Get the modify spec for a given table/row if it exists.
978
+
979
+ Returns the full spec dict containing:
980
+ - resulting_fields: List of field tuples
981
+ - no_other_changes: Boolean (required)
982
+
983
+ Returns None if no modify spec found.
984
+ """
967
985
  for allowed in allowed_changes:
968
986
  allowed_pk = allowed.get("pk")
969
987
  pk_match = (
@@ -972,7 +990,22 @@ class AsyncSnapshotDiff:
972
990
  if (
973
991
  allowed["table"] == table
974
992
  and pk_match
975
- and allowed.get("before") is True
993
+ and allowed.get("type") == "modify"
994
+ ):
995
+ return allowed
996
+ return None
997
+
998
+ def _is_type_allowed(table: str, row_id: Any, change_type: str) -> bool:
999
+ """Check if a change type is allowed for the given table/row (with or without fields)."""
1000
+ for allowed in allowed_changes:
1001
+ allowed_pk = allowed.get("pk")
1002
+ pk_match = (
1003
+ str(allowed_pk) == str(row_id) if allowed_pk is not None else False
1004
+ )
1005
+ if (
1006
+ allowed["table"] == table
1007
+ and pk_match
1008
+ and allowed.get("type") == change_type
976
1009
  ):
977
1010
  return True
978
1011
  return False
@@ -1045,19 +1078,28 @@ class AsyncSnapshotDiff:
1045
1078
  table: str,
1046
1079
  row_id: Any,
1047
1080
  row_changes: Dict[str, Dict[str, Any]],
1048
- fields_spec: List[Tuple[str, Any]],
1081
+ resulting_fields: List[Tuple[str, Any]],
1082
+ no_other_changes: bool,
1049
1083
  ) -> Optional[List[Tuple[str, Any, str]]]:
1050
- """Validate a modification against a bulk fields spec.
1084
+ """Validate a modification against a resulting_fields spec.
1051
1085
 
1052
1086
  Returns None if validation passes, or a list of (field, actual_value, issue)
1053
1087
  tuples for mismatches.
1088
+
1089
+ Args:
1090
+ table: The table name
1091
+ row_id: The row primary key
1092
+ row_changes: Dict of field_name -> {"before": ..., "after": ...}
1093
+ resulting_fields: List of field tuples to validate
1094
+ no_other_changes: If True, all changed fields must be in resulting_fields.
1095
+ If False, only validate fields in resulting_fields, ignore others.
1054
1096
 
1055
1097
  Field spec semantics for modifications:
1056
1098
  - ("field_name", value): check that after value equals value
1057
1099
  - ("field_name", None): check that after value is SQL NULL
1058
1100
  - ("field_name", ...): don't check value, just acknowledge field changed
1059
1101
  """
1060
- spec_map = _parse_fields_spec(fields_spec)
1102
+ spec_map = _parse_fields_spec(resulting_fields)
1061
1103
  unmatched_fields: List[Tuple[str, Any, str]] = []
1062
1104
 
1063
1105
  for field_name, vals in row_changes.items():
@@ -1068,10 +1110,13 @@ class AsyncSnapshotDiff:
1068
1110
  after_value = vals["after"]
1069
1111
 
1070
1112
  if field_name not in spec_map:
1071
- # Changed field not in spec - this is an error
1072
- unmatched_fields.append(
1073
- (field_name, after_value, "NOT_IN_FIELDS_SPEC")
1074
- )
1113
+ # Changed field not in spec
1114
+ if no_other_changes:
1115
+ # Strict mode: all changed fields must be accounted for
1116
+ unmatched_fields.append(
1117
+ (field_name, after_value, "NOT_IN_RESULTING_FIELDS")
1118
+ )
1119
+ # If no_other_changes=False, ignore fields not in spec
1075
1120
  else:
1076
1121
  should_check, expected_value = spec_map[field_name]
1077
1122
  if should_check and not _values_equivalent(
@@ -1084,20 +1129,6 @@ class AsyncSnapshotDiff:
1084
1129
 
1085
1130
  return unmatched_fields if unmatched_fields else None
1086
1131
 
1087
- def _get_modification_fields_spec(
1088
- table: str, row_id: Any
1089
- ) -> Optional[List[Tuple[str, Any]]]:
1090
- """Get the bulk fields spec for a modification if it exists."""
1091
- for allowed in allowed_changes:
1092
- allowed_pk = allowed.get("pk")
1093
- pk_match = (
1094
- str(allowed_pk) == str(row_id) if allowed_pk is not None else False
1095
- )
1096
- if allowed["table"] == table and pk_match and "fields" in allowed:
1097
- # For modifications, accept specs without "before": True
1098
- if allowed.get("before") is not True:
1099
- return allowed["fields"]
1100
- return None
1101
1132
 
1102
1133
  # Collect all unexpected changes for detailed reporting
1103
1134
  unexpected_changes = []
@@ -1106,28 +1137,49 @@ class AsyncSnapshotDiff:
1106
1137
  for row in report.get("modified_rows", []):
1107
1138
  row_changes = row["changes"]
1108
1139
 
1109
- # Check for bulk fields spec for modifications
1110
- fields_spec = _get_modification_fields_spec(tbl, row["row_id"])
1111
- if fields_spec is not None:
1112
- unmatched = _validate_modification_with_fields_spec(
1113
- tbl, row["row_id"], row_changes, fields_spec
1114
- )
1115
- if unmatched:
1116
- unexpected_changes.append(
1117
- {
1118
- "type": "modification",
1119
- "table": tbl,
1120
- "row_id": row["row_id"],
1121
- "field": None,
1122
- "before": None,
1123
- "after": None,
1124
- "full_row": row,
1125
- "unmatched_fields": unmatched,
1126
- }
1140
+ # Check for modify spec with resulting_fields
1141
+ modify_spec = _get_modify_spec(tbl, row["row_id"])
1142
+ if modify_spec is not None:
1143
+ resulting_fields = modify_spec.get("resulting_fields")
1144
+ if resulting_fields is not None:
1145
+ # Validate that no_other_changes is provided
1146
+ if "no_other_changes" not in modify_spec:
1147
+ raise ValueError(
1148
+ f"Modify spec for table '{tbl}' pk={row['row_id']} "
1149
+ f"has 'resulting_fields' but missing required 'no_other_changes' field. "
1150
+ f"Set 'no_other_changes': True to verify no other fields changed, "
1151
+ f"or 'no_other_changes': False to only check the specified fields."
1152
+ )
1153
+ no_other_changes = modify_spec["no_other_changes"]
1154
+ if not isinstance(no_other_changes, bool):
1155
+ raise ValueError(
1156
+ f"Modify spec for table '{tbl}' pk={row['row_id']} "
1157
+ f"has 'no_other_changes' but it must be a boolean (True or False), "
1158
+ f"got {type(no_other_changes).__name__}: {repr(no_other_changes)}"
1159
+ )
1160
+
1161
+ unmatched = _validate_modification_with_fields_spec(
1162
+ tbl, row["row_id"], row_changes, resulting_fields, no_other_changes
1127
1163
  )
1128
- continue # Skip to next row
1164
+ if unmatched:
1165
+ unexpected_changes.append(
1166
+ {
1167
+ "type": "modification",
1168
+ "table": tbl,
1169
+ "row_id": row["row_id"],
1170
+ "field": None,
1171
+ "before": None,
1172
+ "after": None,
1173
+ "full_row": row,
1174
+ "unmatched_fields": unmatched,
1175
+ }
1176
+ )
1177
+ continue # Skip to next row
1178
+ else:
1179
+ # Modify spec without resulting_fields - just allow the modification
1180
+ continue # Skip to next row
1129
1181
 
1130
- # Fall back to single-field specs
1182
+ # Fall back to single-field specs (legacy)
1131
1183
  for f, vals in row_changes.items():
1132
1184
  if self.ignore_config.should_ignore_field(tbl, f):
1133
1185
  continue
@@ -1147,8 +1199,8 @@ class AsyncSnapshotDiff:
1147
1199
  for row in report.get("added_rows", []):
1148
1200
  row_data = row.get("data", {})
1149
1201
 
1150
- # Check for bulk fields spec
1151
- fields_spec = _get_fields_spec(tbl, row["row_id"], for_deletion=False)
1202
+ # Check for bulk fields spec (type: "insert")
1203
+ fields_spec = _get_fields_spec_for_type(tbl, row["row_id"], "insert")
1152
1204
  if fields_spec is not None:
1153
1205
  unmatched = _validate_row_with_fields_spec(
1154
1206
  tbl, row["row_id"], row_data, fields_spec
@@ -1167,7 +1219,11 @@ class AsyncSnapshotDiff:
1167
1219
  )
1168
1220
  continue # Skip to next row
1169
1221
 
1170
- # Check for whole-row spec
1222
+ # Check if insertion is allowed without field validation
1223
+ if _is_type_allowed(tbl, row["row_id"], "insert"):
1224
+ continue # Insertion is allowed, skip to next row
1225
+
1226
+ # Check for whole-row spec (legacy)
1171
1227
  whole_row_allowed = _is_change_allowed(
1172
1228
  tbl, row["row_id"], None, "__added__"
1173
1229
  )
@@ -1187,8 +1243,8 @@ class AsyncSnapshotDiff:
1187
1243
  for row in report.get("removed_rows", []):
1188
1244
  row_data = row.get("data", {})
1189
1245
 
1190
- # Check for bulk fields spec with "before": True
1191
- fields_spec = _get_fields_spec(tbl, row["row_id"], for_deletion=True)
1246
+ # Check for bulk fields spec (type: "delete")
1247
+ fields_spec = _get_fields_spec_for_type(tbl, row["row_id"], "delete")
1192
1248
  if fields_spec is not None:
1193
1249
  unmatched = _validate_row_with_fields_spec(
1194
1250
  tbl, row["row_id"], row_data, fields_spec
@@ -1207,11 +1263,11 @@ class AsyncSnapshotDiff:
1207
1263
  )
1208
1264
  continue # Skip to next row
1209
1265
 
1210
- # Check for "before": True spec without fields (allows deletion without field validation)
1211
- if _is_deletion_allowed(tbl, row["row_id"]):
1266
+ # Check if deletion is allowed without field validation
1267
+ if _is_type_allowed(tbl, row["row_id"], "delete"):
1212
1268
  continue # Deletion is allowed, skip to next row
1213
1269
 
1214
- # Check for whole-row spec
1270
+ # Check for whole-row spec (legacy)
1215
1271
  whole_row_allowed = _is_change_allowed(
1216
1272
  tbl, row["row_id"], None, "__removed__"
1217
1273
  )
@@ -1292,27 +1348,43 @@ class AsyncSnapshotDiff:
1292
1348
  error_lines.append("Allowed changes were:")
1293
1349
  if allowed_changes:
1294
1350
  for i, allowed in enumerate(allowed_changes[:3], 1):
1295
- if "fields" in allowed:
1296
- # Show bulk fields spec
1351
+ change_type = allowed.get("type", "unspecified")
1352
+
1353
+ # For modify type, use resulting_fields
1354
+ if change_type == "modify" and "resulting_fields" in allowed and allowed["resulting_fields"] is not None:
1297
1355
  fields_summary = ", ".join(
1298
1356
  f[0] if len(f) == 1 else f"{f[0]}={'NOT_CHECKED' if f[1] is ... else repr(f[1])}"
1299
- for f in allowed["fields"][:10]
1357
+ for f in allowed["resulting_fields"][:3]
1300
1358
  )
1301
- if len(allowed["fields"]) > 10:
1302
- fields_summary += (
1303
- f", ... +{len(allowed['fields']) - 10} more"
1304
- )
1359
+ if len(allowed["resulting_fields"]) > 3:
1360
+ fields_summary += f", ... +{len(allowed['resulting_fields']) - 3} more"
1361
+ no_other = allowed.get("no_other_changes", "NOT_SET")
1362
+ error_lines.append(
1363
+ f" {i}. Table: {allowed.get('table')}, "
1364
+ f"ID: {allowed.get('pk')}, "
1365
+ f"Type: {change_type}, "
1366
+ f"resulting_fields: [{fields_summary}], "
1367
+ f"no_other_changes: {no_other}"
1368
+ )
1369
+ elif "fields" in allowed and allowed["fields"] is not None:
1370
+ # Show bulk fields spec (for insert/delete)
1371
+ fields_summary = ", ".join(
1372
+ f[0] if len(f) == 1 else f"{f[0]}={'NOT_CHECKED' if f[1] is ... else repr(f[1])}"
1373
+ for f in allowed["fields"][:3]
1374
+ )
1375
+ if len(allowed["fields"]) > 3:
1376
+ fields_summary += f", ... +{len(allowed['fields']) - 3} more"
1305
1377
  error_lines.append(
1306
1378
  f" {i}. Table: {allowed.get('table')}, "
1307
1379
  f"ID: {allowed.get('pk')}, "
1380
+ f"Type: {change_type}, "
1308
1381
  f"Fields: [{fields_summary}]"
1309
1382
  )
1310
1383
  else:
1311
1384
  error_lines.append(
1312
1385
  f" {i}. Table: {allowed.get('table')}, "
1313
1386
  f"ID: {allowed.get('pk')}, "
1314
- f"Field: {allowed.get('field')}, "
1315
- f"After: {repr(allowed.get('after'))}"
1387
+ f"Type: {change_type}"
1316
1388
  )
1317
1389
  if len(allowed_changes) > 3:
1318
1390
  error_lines.append(