fleet-python 0.2.118__tar.gz → 0.2.119__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.
- {fleet_python-0.2.118/fleet_python.egg-info → fleet_python-0.2.119}/PKG-INFO +1 -1
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/__init__.py +1 -1
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/__init__.py +1 -1
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/base.py +1 -1
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/base.py +1 -1
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/db.py +429 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.118 → fleet_python-0.2.119}/pyproject.toml +1 -1
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_expect_only.py +15 -15
- {fleet_python-0.2.118 → fleet_python-0.2.119}/LICENSE +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/README.md +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/diff_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_account.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_sync.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_task.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/export_tasks_filtered.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/fetch_tasks.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/iterate_verifiers.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/openai_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/quickstart.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/instance/client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/judge.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/api.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/filesystem.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/sqlite.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/Dockerfile +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/agent.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp/main.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/requirements.txt +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/start.sh +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/orchestrator.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/types.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/utils.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/cli.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/config.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/env/client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/eval/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/eval/uploader.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/global_client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/client.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/judge.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/models.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/proxy/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/proxy/proxy.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/proxy/whitelist.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/api.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/filesystem.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/sqlite.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/tasks.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/types.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/http_logging.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/logging.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/playwright.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/local_executor.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/SOURCES.txt +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/entry_points.txt +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/scripts/unasync.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/setup.cfg +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/__init__.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_expect_exactly.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_judge_criteria_markers.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_verifier_from_string.py +0 -0
|
@@ -891,9 +891,413 @@ class SnapshotDiff:
|
|
|
891
891
|
return diff
|
|
892
892
|
|
|
893
893
|
# ------------------------------------------------------------------
|
|
894
|
+
def _can_use_targeted_queries(self, allowed_changes: List[Dict[str, Any]]) -> bool:
|
|
895
|
+
"""Check if we can use targeted queries for optimization."""
|
|
896
|
+
for change in allowed_changes:
|
|
897
|
+
if "table" not in change or "pk" not in change:
|
|
898
|
+
return False
|
|
899
|
+
return True
|
|
900
|
+
|
|
901
|
+
def _query_row(self, db_path: str, table: str, pk_columns: List[str], pk: Any) -> Optional[Dict[str, Any]]:
|
|
902
|
+
"""Query a single row by primary key from a SQLite database."""
|
|
903
|
+
conn = sqlite3.connect(db_path)
|
|
904
|
+
conn.row_factory = sqlite3.Row
|
|
905
|
+
try:
|
|
906
|
+
if len(pk_columns) == 1:
|
|
907
|
+
where_sql = f"{pk_columns[0]} = ?"
|
|
908
|
+
params = [pk]
|
|
909
|
+
else:
|
|
910
|
+
parts = []
|
|
911
|
+
params = []
|
|
912
|
+
pk_vals = pk if isinstance(pk, tuple) else (pk,)
|
|
913
|
+
for col, val in zip(pk_columns, pk_vals):
|
|
914
|
+
parts.append(f"{col} = ?")
|
|
915
|
+
params.append(val)
|
|
916
|
+
where_sql = " AND ".join(parts)
|
|
917
|
+
cursor = conn.execute(
|
|
918
|
+
f"SELECT rowid, * FROM {table} WHERE {where_sql}", params
|
|
919
|
+
)
|
|
920
|
+
row = cursor.fetchone()
|
|
921
|
+
return dict(row) if row else None
|
|
922
|
+
finally:
|
|
923
|
+
conn.close()
|
|
924
|
+
|
|
925
|
+
def _get_row_count(self, db_path: str, table: str) -> int:
|
|
926
|
+
"""Get row count for a table."""
|
|
927
|
+
conn = sqlite3.connect(db_path)
|
|
928
|
+
try:
|
|
929
|
+
cursor = conn.execute(f"SELECT COUNT(*) FROM {table}")
|
|
930
|
+
return cursor.fetchone()[0]
|
|
931
|
+
finally:
|
|
932
|
+
conn.close()
|
|
933
|
+
|
|
934
|
+
def _get_pk_columns(self, table: str) -> List[str]:
|
|
935
|
+
"""Get primary key columns for a table."""
|
|
936
|
+
return self._differ.get_primary_key_columns(self._differ.before_db, table) or ["id"]
|
|
937
|
+
|
|
938
|
+
def _expect_only_targeted(self, allowed_changes: List[Dict[str, Any]]):
|
|
939
|
+
"""Optimized version that only queries specific rows mentioned in allowed_changes.
|
|
940
|
+
|
|
941
|
+
Matches the behavior of the production SyncSnapshotDiff._expect_only_targeted
|
|
942
|
+
in fleet/resources/sqlite.py: checks specified rows for expected changes, then
|
|
943
|
+
verifies tables not mentioned in allowed_changes have no row count changes.
|
|
944
|
+
"""
|
|
945
|
+
# Group allowed changes by table
|
|
946
|
+
changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
|
|
947
|
+
for change in allowed_changes:
|
|
948
|
+
table = change["table"]
|
|
949
|
+
if table not in changes_by_table:
|
|
950
|
+
changes_by_table[table] = []
|
|
951
|
+
changes_by_table[table].append(change)
|
|
952
|
+
|
|
953
|
+
errors: List[Exception] = []
|
|
954
|
+
|
|
955
|
+
# Check each specified row
|
|
956
|
+
for table, table_changes in changes_by_table.items():
|
|
957
|
+
if self.ignore_config.should_ignore_table(table):
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
pk_columns = self._get_pk_columns(table)
|
|
961
|
+
pks_to_check = {change["pk"] for change in table_changes}
|
|
962
|
+
|
|
963
|
+
for pk in pks_to_check:
|
|
964
|
+
before_row = self._query_row(self.before.db_path, table, pk_columns, pk)
|
|
965
|
+
after_row = self._query_row(self.after.db_path, table, pk_columns, pk)
|
|
966
|
+
|
|
967
|
+
if before_row and after_row:
|
|
968
|
+
# Modified row - check fields
|
|
969
|
+
for field in set(before_row.keys()) | set(after_row.keys()):
|
|
970
|
+
if self.ignore_config.should_ignore_field(table, field):
|
|
971
|
+
continue
|
|
972
|
+
before_val = before_row.get(field)
|
|
973
|
+
after_val = after_row.get(field)
|
|
974
|
+
if not _values_equivalent(before_val, after_val):
|
|
975
|
+
# Check if this field change is allowed
|
|
976
|
+
allowed = False
|
|
977
|
+
for tc in table_changes:
|
|
978
|
+
tc_pk = tc.get("pk")
|
|
979
|
+
pk_match = (
|
|
980
|
+
str(tc_pk) == str(pk) if tc_pk is not None else False
|
|
981
|
+
)
|
|
982
|
+
if (
|
|
983
|
+
pk_match
|
|
984
|
+
and tc.get("field") == field
|
|
985
|
+
and _values_equivalent(tc.get("after"), after_val)
|
|
986
|
+
):
|
|
987
|
+
allowed = True
|
|
988
|
+
break
|
|
989
|
+
if not allowed:
|
|
990
|
+
errors.append(
|
|
991
|
+
AssertionError(
|
|
992
|
+
f"Unexpected change in table '{table}', "
|
|
993
|
+
f"row {pk}, field '{field}': "
|
|
994
|
+
f"{repr(before_val)} -> {repr(after_val)}"
|
|
995
|
+
)
|
|
996
|
+
)
|
|
997
|
+
break # Stop checking this row
|
|
998
|
+
elif not before_row and after_row:
|
|
999
|
+
# Added row - check if allowed
|
|
1000
|
+
allowed = False
|
|
1001
|
+
for tc in table_changes:
|
|
1002
|
+
tc_pk = tc.get("pk")
|
|
1003
|
+
pk_match = (
|
|
1004
|
+
str(tc_pk) == str(pk) if tc_pk is not None else False
|
|
1005
|
+
)
|
|
1006
|
+
if pk_match and _values_equivalent(tc.get("after"), "__added__"):
|
|
1007
|
+
allowed = True
|
|
1008
|
+
break
|
|
1009
|
+
if not allowed:
|
|
1010
|
+
errors.append(
|
|
1011
|
+
AssertionError(f"Unexpected row added in table '{table}': {pk}")
|
|
1012
|
+
)
|
|
1013
|
+
elif before_row and not after_row:
|
|
1014
|
+
# Removed row - check if allowed
|
|
1015
|
+
allowed = False
|
|
1016
|
+
for tc in table_changes:
|
|
1017
|
+
tc_pk = tc.get("pk")
|
|
1018
|
+
pk_match = (
|
|
1019
|
+
str(tc_pk) == str(pk) if tc_pk is not None else False
|
|
1020
|
+
)
|
|
1021
|
+
if pk_match and _values_equivalent(tc.get("after"), "__removed__"):
|
|
1022
|
+
allowed = True
|
|
1023
|
+
break
|
|
1024
|
+
if not allowed:
|
|
1025
|
+
errors.append(
|
|
1026
|
+
AssertionError(f"Unexpected row removed from table '{table}': {pk}")
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
if errors:
|
|
1030
|
+
raise errors[0]
|
|
1031
|
+
|
|
1032
|
+
# Check tables not mentioned in allowed_changes for row count changes
|
|
1033
|
+
all_tables = set(self.before.tables()) | set(self.after.tables())
|
|
1034
|
+
for table in all_tables:
|
|
1035
|
+
if table in changes_by_table:
|
|
1036
|
+
continue
|
|
1037
|
+
if self.ignore_config.should_ignore_table(table):
|
|
1038
|
+
continue
|
|
1039
|
+
before_count = self._get_row_count(self.before.db_path, table)
|
|
1040
|
+
after_count = self._get_row_count(self.after.db_path, table)
|
|
1041
|
+
if before_count != after_count:
|
|
1042
|
+
errors.append(
|
|
1043
|
+
AssertionError(
|
|
1044
|
+
f"Unexpected change in table '{table}': "
|
|
1045
|
+
f"row count changed from {before_count} to {after_count}"
|
|
1046
|
+
)
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
if errors:
|
|
1050
|
+
raise errors[0]
|
|
1051
|
+
|
|
1052
|
+
return self
|
|
1053
|
+
|
|
1054
|
+
def _expect_only_targeted_v2(self, allowed_changes: List[Dict[str, Any]]):
|
|
1055
|
+
"""Targeted version of expect_only_v2 that only queries specific rows.
|
|
1056
|
+
|
|
1057
|
+
Matches the behavior of the production SyncSnapshotDiff._expect_only_targeted_v2
|
|
1058
|
+
in fleet/resources/sqlite.py.
|
|
1059
|
+
"""
|
|
1060
|
+
# Helper functions for v2 spec validation
|
|
1061
|
+
def _parse_fields_spec(
|
|
1062
|
+
fields_spec: List[tuple],
|
|
1063
|
+
) -> Dict[str, tuple]:
|
|
1064
|
+
spec_map: Dict[str, tuple] = {}
|
|
1065
|
+
for spec_tuple in fields_spec:
|
|
1066
|
+
if len(spec_tuple) != 2:
|
|
1067
|
+
raise ValueError(
|
|
1068
|
+
f"Invalid field spec tuple: {spec_tuple}. "
|
|
1069
|
+
f"Expected 2-tuple like ('field', value), ('field', None), or ('field', ...)"
|
|
1070
|
+
)
|
|
1071
|
+
field_name, expected_value = spec_tuple
|
|
1072
|
+
if expected_value is ...:
|
|
1073
|
+
spec_map[field_name] = (False, None)
|
|
1074
|
+
else:
|
|
1075
|
+
spec_map[field_name] = (True, expected_value)
|
|
1076
|
+
return spec_map
|
|
1077
|
+
|
|
1078
|
+
def _get_all_specs_for_pk(table: str, pk: Any) -> List[Dict[str, Any]]:
|
|
1079
|
+
specs = []
|
|
1080
|
+
for allowed in allowed_changes:
|
|
1081
|
+
if (
|
|
1082
|
+
allowed["table"] == table
|
|
1083
|
+
and str(allowed.get("pk")) == str(pk)
|
|
1084
|
+
):
|
|
1085
|
+
specs.append(allowed)
|
|
1086
|
+
return specs
|
|
1087
|
+
|
|
1088
|
+
def _validate_insert_row(
|
|
1089
|
+
table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
|
|
1090
|
+
) -> Optional[str]:
|
|
1091
|
+
for spec in specs:
|
|
1092
|
+
if spec.get("type") == "insert":
|
|
1093
|
+
fields_spec = spec.get("fields")
|
|
1094
|
+
if fields_spec is not None:
|
|
1095
|
+
spec_map = _parse_fields_spec(fields_spec)
|
|
1096
|
+
for field_name, field_value in row_data.items():
|
|
1097
|
+
if field_name == "rowid":
|
|
1098
|
+
continue
|
|
1099
|
+
if self.ignore_config.should_ignore_field(table, field_name):
|
|
1100
|
+
continue
|
|
1101
|
+
if field_name not in spec_map:
|
|
1102
|
+
return f"Field '{field_name}' not in insert spec for table '{table}' pk={pk}"
|
|
1103
|
+
should_check, expected_value = spec_map[field_name]
|
|
1104
|
+
if should_check and not _values_equivalent(expected_value, field_value):
|
|
1105
|
+
return (
|
|
1106
|
+
f"Insert mismatch in table '{table}' pk={pk}, "
|
|
1107
|
+
f"field '{field_name}': expected {repr(expected_value)}, got {repr(field_value)}"
|
|
1108
|
+
)
|
|
1109
|
+
return None
|
|
1110
|
+
for spec in specs:
|
|
1111
|
+
if spec.get("fields") is None and spec.get("after") == "__added__":
|
|
1112
|
+
return None
|
|
1113
|
+
return f"Unexpected row added in table '{table}': pk={pk}"
|
|
1114
|
+
|
|
1115
|
+
def _validate_delete_row(
|
|
1116
|
+
table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
|
|
1117
|
+
) -> Optional[str]:
|
|
1118
|
+
for spec in specs:
|
|
1119
|
+
if spec.get("type") == "delete":
|
|
1120
|
+
fields_spec = spec.get("fields")
|
|
1121
|
+
if fields_spec is not None:
|
|
1122
|
+
spec_map = _parse_fields_spec(fields_spec)
|
|
1123
|
+
for field_name, (should_check, expected_value) in spec_map.items():
|
|
1124
|
+
if field_name not in row_data:
|
|
1125
|
+
return f"Field '{field_name}' in delete spec not found in row for table '{table}' pk={pk}"
|
|
1126
|
+
if should_check and not _values_equivalent(expected_value, row_data[field_name]):
|
|
1127
|
+
return (
|
|
1128
|
+
f"Delete mismatch in table '{table}' pk={pk}, "
|
|
1129
|
+
f"field '{field_name}': expected {repr(expected_value)}, got {repr(row_data[field_name])}"
|
|
1130
|
+
)
|
|
1131
|
+
return None
|
|
1132
|
+
for spec in specs:
|
|
1133
|
+
if spec.get("fields") is None and spec.get("after") == "__removed__":
|
|
1134
|
+
return None
|
|
1135
|
+
return f"Unexpected row removed from table '{table}': pk={pk}"
|
|
1136
|
+
|
|
1137
|
+
def _validate_modify_row(
|
|
1138
|
+
table: str,
|
|
1139
|
+
pk: Any,
|
|
1140
|
+
before_row: Dict[str, Any],
|
|
1141
|
+
after_row: Dict[str, Any],
|
|
1142
|
+
specs: List[Dict[str, Any]],
|
|
1143
|
+
) -> Optional[str]:
|
|
1144
|
+
changed_fields: Dict[str, Dict[str, Any]] = {}
|
|
1145
|
+
for field in set(before_row.keys()) | set(after_row.keys()):
|
|
1146
|
+
if self.ignore_config.should_ignore_field(table, field):
|
|
1147
|
+
continue
|
|
1148
|
+
before_val = before_row.get(field)
|
|
1149
|
+
after_val = after_row.get(field)
|
|
1150
|
+
if not _values_equivalent(before_val, after_val):
|
|
1151
|
+
changed_fields[field] = {"before": before_val, "after": after_val}
|
|
1152
|
+
if not changed_fields:
|
|
1153
|
+
return None
|
|
1154
|
+
for spec in specs:
|
|
1155
|
+
if spec.get("type") == "modify":
|
|
1156
|
+
resulting_fields = spec.get("resulting_fields")
|
|
1157
|
+
if resulting_fields is not None:
|
|
1158
|
+
if "no_other_changes" not in spec:
|
|
1159
|
+
raise ValueError(
|
|
1160
|
+
f"Modify spec for table '{table}' pk={pk} "
|
|
1161
|
+
f"has 'resulting_fields' but missing required 'no_other_changes' field."
|
|
1162
|
+
)
|
|
1163
|
+
no_other_changes = spec["no_other_changes"]
|
|
1164
|
+
if not isinstance(no_other_changes, bool):
|
|
1165
|
+
raise ValueError(
|
|
1166
|
+
f"Modify spec for table '{table}' pk={pk} "
|
|
1167
|
+
f"'no_other_changes' must be boolean, got {type(no_other_changes).__name__}"
|
|
1168
|
+
)
|
|
1169
|
+
spec_map = _parse_fields_spec(resulting_fields)
|
|
1170
|
+
for field_name, vals in changed_fields.items():
|
|
1171
|
+
after_val = vals["after"]
|
|
1172
|
+
if field_name not in spec_map:
|
|
1173
|
+
if no_other_changes:
|
|
1174
|
+
return (
|
|
1175
|
+
f"Unexpected field change in table '{table}' pk={pk}: "
|
|
1176
|
+
f"field '{field_name}' not in resulting_fields"
|
|
1177
|
+
)
|
|
1178
|
+
else:
|
|
1179
|
+
should_check, expected_value = spec_map[field_name]
|
|
1180
|
+
if should_check and not _values_equivalent(expected_value, after_val):
|
|
1181
|
+
return (
|
|
1182
|
+
f"Modify mismatch in table '{table}' pk={pk}, "
|
|
1183
|
+
f"field '{field_name}': expected {repr(expected_value)}, got {repr(after_val)}"
|
|
1184
|
+
)
|
|
1185
|
+
return None
|
|
1186
|
+
else:
|
|
1187
|
+
return None
|
|
1188
|
+
# Legacy single-field specs
|
|
1189
|
+
for field_name, vals in changed_fields.items():
|
|
1190
|
+
after_val = vals["after"]
|
|
1191
|
+
field_allowed = False
|
|
1192
|
+
for spec in specs:
|
|
1193
|
+
if (
|
|
1194
|
+
spec.get("field") == field_name
|
|
1195
|
+
and _values_equivalent(spec.get("after"), after_val)
|
|
1196
|
+
):
|
|
1197
|
+
field_allowed = True
|
|
1198
|
+
break
|
|
1199
|
+
if not field_allowed:
|
|
1200
|
+
return (
|
|
1201
|
+
f"Unexpected change in table '{table}' pk={pk}, "
|
|
1202
|
+
f"field '{field_name}': {repr(vals['before'])} -> {repr(after_val)}"
|
|
1203
|
+
)
|
|
1204
|
+
return None
|
|
1205
|
+
|
|
1206
|
+
# Group allowed changes by table
|
|
1207
|
+
changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
|
|
1208
|
+
for change in allowed_changes:
|
|
1209
|
+
table = change["table"]
|
|
1210
|
+
if table not in changes_by_table:
|
|
1211
|
+
changes_by_table[table] = []
|
|
1212
|
+
changes_by_table[table].append(change)
|
|
1213
|
+
|
|
1214
|
+
errors: List[Exception] = []
|
|
1215
|
+
|
|
1216
|
+
# Check each specified row
|
|
1217
|
+
for table, table_changes in changes_by_table.items():
|
|
1218
|
+
if self.ignore_config.should_ignore_table(table):
|
|
1219
|
+
continue
|
|
1220
|
+
|
|
1221
|
+
pk_columns = self._get_pk_columns(table)
|
|
1222
|
+
pks_to_check = {change["pk"] for change in table_changes}
|
|
1223
|
+
|
|
1224
|
+
for pk in pks_to_check:
|
|
1225
|
+
before_row = self._query_row(self.before.db_path, table, pk_columns, pk)
|
|
1226
|
+
after_row = self._query_row(self.after.db_path, table, pk_columns, pk)
|
|
1227
|
+
specs = _get_all_specs_for_pk(table, pk)
|
|
1228
|
+
|
|
1229
|
+
if before_row and after_row:
|
|
1230
|
+
error = _validate_modify_row(table, pk, before_row, after_row, specs)
|
|
1231
|
+
if error:
|
|
1232
|
+
errors.append(AssertionError(error))
|
|
1233
|
+
elif not before_row and after_row:
|
|
1234
|
+
error = _validate_insert_row(table, pk, after_row, specs)
|
|
1235
|
+
if error:
|
|
1236
|
+
errors.append(AssertionError(error))
|
|
1237
|
+
elif before_row and not after_row:
|
|
1238
|
+
error = _validate_delete_row(table, pk, before_row, specs)
|
|
1239
|
+
if error:
|
|
1240
|
+
errors.append(AssertionError(error))
|
|
1241
|
+
|
|
1242
|
+
if errors:
|
|
1243
|
+
raise errors[0]
|
|
1244
|
+
|
|
1245
|
+
# Check tables not mentioned in allowed_changes for row count changes
|
|
1246
|
+
all_tables = set(self.before.tables()) | set(self.after.tables())
|
|
1247
|
+
for table in all_tables:
|
|
1248
|
+
if table in changes_by_table:
|
|
1249
|
+
continue
|
|
1250
|
+
if self.ignore_config.should_ignore_table(table):
|
|
1251
|
+
continue
|
|
1252
|
+
before_count = self._get_row_count(self.before.db_path, table)
|
|
1253
|
+
after_count = self._get_row_count(self.after.db_path, table)
|
|
1254
|
+
if before_count != after_count:
|
|
1255
|
+
errors.append(
|
|
1256
|
+
AssertionError(
|
|
1257
|
+
f"Unexpected change in table '{table}': "
|
|
1258
|
+
f"row count changed from {before_count} to {after_count}"
|
|
1259
|
+
)
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
if errors:
|
|
1263
|
+
raise errors[0]
|
|
1264
|
+
|
|
1265
|
+
return self
|
|
1266
|
+
|
|
894
1267
|
def expect_only(self, allowed_changes: List[Dict[str, Any]]):
|
|
895
1268
|
"""Allowed changes is a list of {table, pk, field, after} (before optional)."""
|
|
1269
|
+
# Normalize pk values
|
|
1270
|
+
for change in allowed_changes:
|
|
1271
|
+
if "pk" in change and isinstance(change["pk"], list):
|
|
1272
|
+
change["pk"] = tuple(change["pk"])
|
|
1273
|
+
|
|
1274
|
+
# Special case: empty allowed_changes means no changes should have occurred
|
|
1275
|
+
if not allowed_changes:
|
|
1276
|
+
diff = self._collect()
|
|
1277
|
+
for tbl, report in diff.items():
|
|
1278
|
+
total = (
|
|
1279
|
+
len(report.get("added_rows", []))
|
|
1280
|
+
+ len(report.get("removed_rows", []))
|
|
1281
|
+
+ len(report.get("modified_rows", []))
|
|
1282
|
+
)
|
|
1283
|
+
if total > 0:
|
|
1284
|
+
raise AssertionError(
|
|
1285
|
+
f"Expected no changes but found {total} change(s) in table '{tbl}'"
|
|
1286
|
+
)
|
|
1287
|
+
return self
|
|
1288
|
+
|
|
1289
|
+
# Use targeted queries when possible (matches production behavior)
|
|
1290
|
+
if self._can_use_targeted_queries(allowed_changes):
|
|
1291
|
+
return self._expect_only_targeted(allowed_changes)
|
|
1292
|
+
|
|
1293
|
+
# Fall back to full diff for complex cases
|
|
896
1294
|
diff = self._collect()
|
|
1295
|
+
return self._validate_diff_against_allowed_changes(diff, allowed_changes)
|
|
1296
|
+
|
|
1297
|
+
def _validate_diff_against_allowed_changes(
|
|
1298
|
+
self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
|
|
1299
|
+
):
|
|
1300
|
+
"""Validate a collected diff against allowed changes (full-diff fallback)."""
|
|
897
1301
|
|
|
898
1302
|
def _is_change_allowed(
|
|
899
1303
|
table: str, row_id: str, field: Optional[str], after_value: Any
|
|
@@ -1061,6 +1465,31 @@ class SnapshotDiff:
|
|
|
1061
1465
|
For modifications, use "resulting_fields" with explicit "no_other_changes".
|
|
1062
1466
|
For deletions with "fields", all specified fields are validated against the deleted row.
|
|
1063
1467
|
"""
|
|
1468
|
+
# Normalize pk values
|
|
1469
|
+
for change in allowed_changes:
|
|
1470
|
+
if "pk" in change and isinstance(change["pk"], list):
|
|
1471
|
+
change["pk"] = tuple(change["pk"])
|
|
1472
|
+
|
|
1473
|
+
# Special case: empty allowed_changes means no changes should have occurred
|
|
1474
|
+
if not allowed_changes:
|
|
1475
|
+
diff = self._collect()
|
|
1476
|
+
for tbl, report in diff.items():
|
|
1477
|
+
total = (
|
|
1478
|
+
len(report.get("added_rows", []))
|
|
1479
|
+
+ len(report.get("removed_rows", []))
|
|
1480
|
+
+ len(report.get("modified_rows", []))
|
|
1481
|
+
)
|
|
1482
|
+
if total > 0:
|
|
1483
|
+
raise AssertionError(
|
|
1484
|
+
f"Expected no changes but found {total} change(s) in table '{tbl}'"
|
|
1485
|
+
)
|
|
1486
|
+
return self
|
|
1487
|
+
|
|
1488
|
+
# Use targeted queries when possible (matches production behavior)
|
|
1489
|
+
if self._can_use_targeted_queries(allowed_changes):
|
|
1490
|
+
return self._expect_only_targeted_v2(allowed_changes)
|
|
1491
|
+
|
|
1492
|
+
# Fall back to full diff for complex cases
|
|
1064
1493
|
diff = self._collect()
|
|
1065
1494
|
|
|
1066
1495
|
def _is_change_allowed(
|
|
@@ -97,7 +97,7 @@ def test_field_level_specs_with_wrong_values():
|
|
|
97
97
|
after = DatabaseSnapshot(after_db)
|
|
98
98
|
|
|
99
99
|
# Should fail because status value is wrong
|
|
100
|
-
with pytest.raises(AssertionError
|
|
100
|
+
with pytest.raises(AssertionError):
|
|
101
101
|
before.diff(after).expect_only_v2(
|
|
102
102
|
[
|
|
103
103
|
{
|
|
@@ -195,7 +195,7 @@ def test_modification_with_bulk_fields_spec_wrong_value():
|
|
|
195
195
|
after = DatabaseSnapshot(after_db)
|
|
196
196
|
|
|
197
197
|
# Should fail because status value is wrong
|
|
198
|
-
with pytest.raises(AssertionError
|
|
198
|
+
with pytest.raises(AssertionError):
|
|
199
199
|
before.diff(after).expect_only_v2(
|
|
200
200
|
[
|
|
201
201
|
{
|
|
@@ -263,7 +263,7 @@ def test_modification_with_bulk_fields_spec_missing_field():
|
|
|
263
263
|
)
|
|
264
264
|
|
|
265
265
|
assert "status" in str(exc_info.value)
|
|
266
|
-
assert "NOT_IN_RESULTING_FIELDS" in str(exc_info.value)
|
|
266
|
+
assert "NOT_IN_RESULTING_FIELDS" in str(exc_info.value) or "not in resulting_fields" in str(exc_info.value)
|
|
267
267
|
|
|
268
268
|
finally:
|
|
269
269
|
os.unlink(before_db)
|
|
@@ -431,7 +431,7 @@ def test_modification_no_other_changes_false_still_validates_specified():
|
|
|
431
431
|
after = DatabaseSnapshot(after_db)
|
|
432
432
|
|
|
433
433
|
# Should fail because name value is wrong, even with no_other_changes=False
|
|
434
|
-
with pytest.raises(AssertionError
|
|
434
|
+
with pytest.raises(AssertionError):
|
|
435
435
|
before.diff(after).expect_only_v2(
|
|
436
436
|
[
|
|
437
437
|
{
|
|
@@ -662,7 +662,7 @@ def test_partial_field_specs_with_unexpected_changes():
|
|
|
662
662
|
after = DatabaseSnapshot(after_db)
|
|
663
663
|
|
|
664
664
|
# Only specify price change, but stock also changed - should fail
|
|
665
|
-
with pytest.raises(AssertionError
|
|
665
|
+
with pytest.raises(AssertionError):
|
|
666
666
|
before.diff(after).expect_only(
|
|
667
667
|
[
|
|
668
668
|
{"table": "products", "pk": 1, "field": "price", "after": 12.99},
|
|
@@ -903,7 +903,7 @@ def test_missing_field_specs():
|
|
|
903
903
|
after = DatabaseSnapshot(after_db)
|
|
904
904
|
|
|
905
905
|
# Should fail because status field is missing from the fields spec
|
|
906
|
-
with pytest.raises(AssertionError
|
|
906
|
+
with pytest.raises(AssertionError):
|
|
907
907
|
before.diff(after).expect_only_v2(
|
|
908
908
|
[
|
|
909
909
|
{
|
|
@@ -953,7 +953,7 @@ def test_modified_row_with_unauthorized_field_change():
|
|
|
953
953
|
after = DatabaseSnapshot(after_db)
|
|
954
954
|
|
|
955
955
|
# Should fail because status change is not allowed
|
|
956
|
-
with pytest.raises(AssertionError
|
|
956
|
+
with pytest.raises(AssertionError):
|
|
957
957
|
before.diff(after).expect_only(
|
|
958
958
|
[
|
|
959
959
|
{
|
|
@@ -1272,7 +1272,7 @@ def test_fields_spec_missing_field_fails():
|
|
|
1272
1272
|
)
|
|
1273
1273
|
|
|
1274
1274
|
assert "status" in str(exc_info.value)
|
|
1275
|
-
assert "NOT_IN_FIELDS_SPEC" in str(exc_info.value)
|
|
1275
|
+
assert "NOT_IN_FIELDS_SPEC" in str(exc_info.value) or "not in insert spec" in str(exc_info.value)
|
|
1276
1276
|
|
|
1277
1277
|
finally:
|
|
1278
1278
|
os.unlink(before_db)
|
|
@@ -1473,7 +1473,7 @@ def test_security_field_level_specs_catch_wrong_role():
|
|
|
1473
1473
|
after = DatabaseSnapshot(after_db)
|
|
1474
1474
|
|
|
1475
1475
|
# expect_only_v2 correctly FAILS because role is 'admin' not 'user'
|
|
1476
|
-
with pytest.raises(AssertionError
|
|
1476
|
+
with pytest.raises(AssertionError):
|
|
1477
1477
|
before.diff(after).expect_only_v2(
|
|
1478
1478
|
[
|
|
1479
1479
|
{
|
|
@@ -1533,7 +1533,7 @@ def test_financial_data_validation():
|
|
|
1533
1533
|
)
|
|
1534
1534
|
|
|
1535
1535
|
# expect_only_v2 with bulk field specs catches unexpected discount
|
|
1536
|
-
with pytest.raises(AssertionError
|
|
1536
|
+
with pytest.raises(AssertionError):
|
|
1537
1537
|
before.diff(after).expect_only_v2(
|
|
1538
1538
|
[
|
|
1539
1539
|
{
|
|
@@ -1593,7 +1593,7 @@ def test_permissions_validation():
|
|
|
1593
1593
|
)
|
|
1594
1594
|
|
|
1595
1595
|
# expect_only_v2 with bulk field specs catches unexpected delete permission
|
|
1596
|
-
with pytest.raises(AssertionError
|
|
1596
|
+
with pytest.raises(AssertionError):
|
|
1597
1597
|
before.diff(after).expect_only_v2(
|
|
1598
1598
|
[
|
|
1599
1599
|
{
|
|
@@ -1661,7 +1661,7 @@ def test_json_field_validation():
|
|
|
1661
1661
|
)
|
|
1662
1662
|
|
|
1663
1663
|
# expect_only_v2 with bulk field specs catches unexpected settings
|
|
1664
|
-
with pytest.raises(AssertionError
|
|
1664
|
+
with pytest.raises(AssertionError):
|
|
1665
1665
|
before.diff(after).expect_only_v2(
|
|
1666
1666
|
[
|
|
1667
1667
|
{
|
|
@@ -1726,7 +1726,7 @@ def test_expect_only_ignores_field_specs_with_whole_row():
|
|
|
1726
1726
|
)
|
|
1727
1727
|
|
|
1728
1728
|
# expect_only_v2 with wrong field values fails
|
|
1729
|
-
with pytest.raises(AssertionError
|
|
1729
|
+
with pytest.raises(AssertionError):
|
|
1730
1730
|
before.diff(after).expect_only_v2(
|
|
1731
1731
|
[
|
|
1732
1732
|
{
|
|
@@ -1786,7 +1786,7 @@ def test_expect_only_v2_validates_field_values():
|
|
|
1786
1786
|
)
|
|
1787
1787
|
|
|
1788
1788
|
# expect_only_v2 with wrong field values fails
|
|
1789
|
-
with pytest.raises(AssertionError
|
|
1789
|
+
with pytest.raises(AssertionError):
|
|
1790
1790
|
before.diff(after).expect_only_v2(
|
|
1791
1791
|
[
|
|
1792
1792
|
{
|
|
@@ -1846,7 +1846,7 @@ def test_expect_only_v2_validates_is_public():
|
|
|
1846
1846
|
)
|
|
1847
1847
|
|
|
1848
1848
|
# expect_only_v2 with wrong is_public value fails
|
|
1849
|
-
with pytest.raises(AssertionError
|
|
1849
|
+
with pytest.raises(AssertionError):
|
|
1850
1850
|
before.diff(after).expect_only_v2(
|
|
1851
1851
|
[
|
|
1852
1852
|
{
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|