ainative-python 2.0.0__py3-none-any.whl

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.
@@ -0,0 +1,292 @@
1
+ """
2
+ Database Diff Logic
3
+
4
+ Computes differences between local and cloud database states including
5
+ schema, data, and vectors.
6
+ """
7
+
8
+ from typing import Dict, Any, List, Optional, Set
9
+ from difflib import unified_diff
10
+ import json
11
+
12
+ from ..client import AINativeClient
13
+
14
+
15
+ class DatabaseDiff:
16
+ """Compute differences between local and cloud databases."""
17
+
18
+ def __init__(self, local_client: AINativeClient, cloud_client: AINativeClient):
19
+ """
20
+ Initialize database differ.
21
+
22
+ Args:
23
+ local_client: Client connected to local API
24
+ cloud_client: Client connected to cloud API
25
+ """
26
+ self.local = local_client
27
+ self.cloud = cloud_client
28
+
29
+ def compute_schema_diff(self) -> Dict[str, Any]:
30
+ """
31
+ Compute schema differences between local and cloud.
32
+
33
+ Returns:
34
+ Dictionary containing:
35
+ - tables_to_create: List of tables in local but not cloud
36
+ - tables_to_drop: List of tables in cloud but not local
37
+ - tables_to_alter: List of tables with schema changes
38
+ """
39
+ # Fetch table lists
40
+ try:
41
+ local_tables = self._fetch_tables(self.local)
42
+ cloud_tables = self._fetch_tables(self.cloud)
43
+ except Exception as e:
44
+ return {
45
+ "error": f"Failed to fetch tables: {str(e)}",
46
+ "tables_to_create": [],
47
+ "tables_to_drop": [],
48
+ "tables_to_alter": []
49
+ }
50
+
51
+ local_names = {t["table_name"] for t in local_tables}
52
+ cloud_names = {t["table_name"] for t in cloud_tables}
53
+
54
+ # Tables to create (in local but not cloud)
55
+ tables_to_create = []
56
+ for table in local_tables:
57
+ if table["table_name"] not in cloud_names:
58
+ tables_to_create.append({
59
+ "table_name": table["table_name"],
60
+ "schema": table.get("schema", {}),
61
+ "description": table.get("description")
62
+ })
63
+
64
+ # Tables to drop (in cloud but not local)
65
+ tables_to_drop = []
66
+ for table in cloud_tables:
67
+ if table["table_name"] not in local_names:
68
+ tables_to_drop.append({
69
+ "table_name": table["table_name"]
70
+ })
71
+
72
+ # Tables to alter (schema changes)
73
+ tables_to_alter = []
74
+ local_map = {t["table_name"]: t for t in local_tables}
75
+ cloud_map = {t["table_name"]: t for t in cloud_tables}
76
+
77
+ for name in local_names & cloud_names:
78
+ local_schema = local_map[name].get("schema", {})
79
+ cloud_schema = cloud_map[name].get("schema", {})
80
+
81
+ if local_schema != cloud_schema:
82
+ # Compute field changes
83
+ local_fields = set(local_schema.get("fields", {}).keys())
84
+ cloud_fields = set(cloud_schema.get("fields", {}).keys())
85
+
86
+ added_fields = local_fields - cloud_fields
87
+ removed_fields = cloud_fields - local_fields
88
+
89
+ # Check for type changes in existing fields
90
+ type_changes = []
91
+ for field in local_fields & cloud_fields:
92
+ local_type = local_schema.get("fields", {}).get(field)
93
+ cloud_type = cloud_schema.get("fields", {}).get(field)
94
+ if local_type != cloud_type:
95
+ type_changes.append({
96
+ "field": field,
97
+ "old_type": cloud_type,
98
+ "new_type": local_type
99
+ })
100
+
101
+ tables_to_alter.append({
102
+ "table_name": name,
103
+ "added_fields": list(added_fields),
104
+ "removed_fields": list(removed_fields),
105
+ "type_changes": type_changes,
106
+ "local_schema": local_schema,
107
+ "cloud_schema": cloud_schema
108
+ })
109
+
110
+ return {
111
+ "tables_to_create": tables_to_create,
112
+ "tables_to_drop": tables_to_drop,
113
+ "tables_to_alter": tables_to_alter
114
+ }
115
+
116
+ def compute_data_diff(self) -> Dict[str, Any]:
117
+ """
118
+ Compute data differences between local and cloud.
119
+
120
+ Returns:
121
+ Dictionary containing row counts and changes for each table
122
+ """
123
+ try:
124
+ local_tables = self._fetch_tables(self.local)
125
+ cloud_tables = self._fetch_tables(self.cloud)
126
+ except Exception as e:
127
+ return {
128
+ "error": f"Failed to fetch tables: {str(e)}",
129
+ "total_new_rows": 0,
130
+ "total_updated_rows": 0,
131
+ "total_deleted_rows": 0,
132
+ "table_details": []
133
+ }
134
+
135
+ local_map = {t["table_name"]: t for t in local_tables}
136
+ cloud_map = {t["table_name"]: t for t in cloud_tables}
137
+
138
+ table_details = []
139
+ total_new = 0
140
+ total_updated = 0
141
+ total_deleted = 0
142
+
143
+ # Compare tables that exist in both
144
+ for name in set(local_map.keys()) & set(cloud_map.keys()):
145
+ local_count = local_map[name].get("row_count", 0)
146
+ cloud_count = cloud_map[name].get("row_count", 0)
147
+
148
+ # Simple heuristic: if local has more rows, they're "new"
149
+ # In reality, we'd need to compare actual row data
150
+ new_rows = max(0, local_count - cloud_count)
151
+ deleted_rows = max(0, cloud_count - local_count)
152
+
153
+ if new_rows > 0 or deleted_rows > 0:
154
+ table_details.append({
155
+ "table_name": name,
156
+ "local_count": local_count,
157
+ "cloud_count": cloud_count,
158
+ "new_rows": new_rows,
159
+ "deleted_rows": deleted_rows,
160
+ "updated_rows": 0 # Would require row-level comparison
161
+ })
162
+
163
+ total_new += new_rows
164
+ total_deleted += deleted_rows
165
+
166
+ # Tables that only exist locally (all rows are "new")
167
+ for name in set(local_map.keys()) - set(cloud_map.keys()):
168
+ local_count = local_map[name].get("row_count", 0)
169
+ if local_count > 0:
170
+ table_details.append({
171
+ "table_name": name,
172
+ "local_count": local_count,
173
+ "cloud_count": 0,
174
+ "new_rows": local_count,
175
+ "deleted_rows": 0,
176
+ "updated_rows": 0
177
+ })
178
+ total_new += local_count
179
+
180
+ return {
181
+ "total_new_rows": total_new,
182
+ "total_updated_rows": total_updated,
183
+ "total_deleted_rows": total_deleted,
184
+ "table_details": table_details
185
+ }
186
+
187
+ def compute_vectors_diff(self) -> Dict[str, Any]:
188
+ """
189
+ Compute vector differences between local and cloud.
190
+
191
+ Returns:
192
+ Dictionary containing vector statistics and changes
193
+ """
194
+ try:
195
+ local_stats = self._fetch_vector_stats(self.local)
196
+ cloud_stats = self._fetch_vector_stats(self.cloud)
197
+ except Exception as e:
198
+ return {
199
+ "error": f"Failed to fetch vector stats: {str(e)}",
200
+ "total_upserts": 0,
201
+ "total_deletes": 0,
202
+ "namespace_details": []
203
+ }
204
+
205
+ # Extract namespace stats
206
+ local_namespaces = local_stats.get("namespaces", {})
207
+ cloud_namespaces = cloud_stats.get("namespaces", {})
208
+
209
+ namespace_details = []
210
+ total_upserts = 0
211
+ total_deletes = 0
212
+
213
+ # All namespaces
214
+ all_namespaces = set(local_namespaces.keys()) | set(cloud_namespaces.keys())
215
+
216
+ for ns in all_namespaces:
217
+ local_count = local_namespaces.get(ns, {}).get("vector_count", 0)
218
+ cloud_count = cloud_namespaces.get(ns, {}).get("vector_count", 0)
219
+
220
+ upserts = max(0, local_count - cloud_count)
221
+ deletes = max(0, cloud_count - local_count)
222
+
223
+ if upserts > 0 or deletes > 0:
224
+ namespace_details.append({
225
+ "namespace": ns,
226
+ "local_count": local_count,
227
+ "cloud_count": cloud_count,
228
+ "upserts": upserts,
229
+ "deletes": deletes
230
+ })
231
+
232
+ total_upserts += upserts
233
+ total_deletes += deletes
234
+
235
+ return {
236
+ "total_upserts": total_upserts,
237
+ "total_deletes": total_deletes,
238
+ "namespace_details": namespace_details,
239
+ "local_total": local_stats.get("total_vectors", 0),
240
+ "cloud_total": cloud_stats.get("total_vectors", 0)
241
+ }
242
+
243
+ def _fetch_tables(self, client: AINativeClient) -> List[Dict[str, Any]]:
244
+ """
245
+ Fetch all tables from a client.
246
+
247
+ Args:
248
+ client: AINative client instance
249
+
250
+ Returns:
251
+ List of table dictionaries
252
+ """
253
+ try:
254
+ result = client.zerodb.tables.list_tables(limit=1000)
255
+ return result.get("tables", [])
256
+ except Exception as e:
257
+ # If tables endpoint fails, return empty list
258
+ return []
259
+
260
+ def _fetch_vector_stats(self, client: AINativeClient) -> Dict[str, Any]:
261
+ """
262
+ Fetch vector statistics from a client.
263
+
264
+ Args:
265
+ client: AINative client instance
266
+
267
+ Returns:
268
+ Dictionary with vector statistics
269
+ """
270
+ try:
271
+ # Get project ID from client's organization context
272
+ # First, try to get list of projects
273
+ projects_result = client.zerodb.projects.list(limit=1)
274
+ projects = projects_result.get("projects", [])
275
+
276
+ if not projects:
277
+ # No projects available
278
+ return {
279
+ "total_vectors": 0,
280
+ "namespaces": {}
281
+ }
282
+
283
+ # Use the first project for stats
284
+ project_id = projects[0].get("id")
285
+ result = client.zerodb.vectors.describe_index_stats(project_id=project_id)
286
+ return result
287
+ except Exception as e:
288
+ # Return empty stats if not available
289
+ return {
290
+ "total_vectors": 0,
291
+ "namespaces": {}
292
+ }
@@ -0,0 +1,227 @@
1
+ """
2
+ Diff Formatters
3
+
4
+ Rich console formatters for displaying database diffs with colors.
5
+ """
6
+
7
+ from typing import Dict, Any, List
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.panel import Panel
11
+ from rich.text import Text
12
+
13
+
14
+ console = Console()
15
+
16
+
17
+ class DiffFormatter:
18
+ """Format database diffs for rich console output."""
19
+
20
+ def __init__(self):
21
+ """Initialize formatter."""
22
+ self.console = console
23
+
24
+ def format_schema_diff(self, schema_diff: Dict[str, Any]):
25
+ """
26
+ Format and display schema differences.
27
+
28
+ Args:
29
+ schema_diff: Schema diff dictionary from DatabaseDiff
30
+ """
31
+ if schema_diff.get("error"):
32
+ self.console.print(f"[red]Schema Error:[/red] {schema_diff['error']}")
33
+ return
34
+
35
+ tables_to_create = schema_diff.get("tables_to_create", [])
36
+ tables_to_drop = schema_diff.get("tables_to_drop", [])
37
+ tables_to_alter = schema_diff.get("tables_to_alter", [])
38
+
39
+ if not (tables_to_create or tables_to_drop or tables_to_alter):
40
+ self.console.print("[dim]Schema: No changes[/dim]")
41
+ return
42
+
43
+ # Header
44
+ self.console.print("\n[bold]Schema:[/bold]")
45
+
46
+ # Tables to create
47
+ if tables_to_create:
48
+ for table in tables_to_create:
49
+ fields = table.get("schema", {}).get("fields", {})
50
+ field_str = ", ".join(
51
+ f"{name}: {ftype}" for name, ftype in fields.items()
52
+ )
53
+ self.console.print(
54
+ f" [green]+[/green] Create table: [bold]{table['table_name']}[/bold] "
55
+ f"({field_str})"
56
+ )
57
+
58
+ # Tables to alter
59
+ if tables_to_alter:
60
+ for table in tables_to_alter:
61
+ table_name = table["table_name"]
62
+ added = table.get("added_fields", [])
63
+ removed = table.get("removed_fields", [])
64
+ type_changes = table.get("type_changes", [])
65
+
66
+ changes = []
67
+ if added:
68
+ changes.append(f"add columns: {', '.join(added)}")
69
+ if removed:
70
+ changes.append(f"remove columns: {', '.join(removed)}")
71
+ if type_changes:
72
+ type_str = ", ".join(
73
+ f"{tc['field']} ({tc['old_type']} → {tc['new_type']})"
74
+ for tc in type_changes
75
+ )
76
+ changes.append(f"change types: {type_str}")
77
+
78
+ change_desc = "; ".join(changes)
79
+ self.console.print(
80
+ f" [yellow]~[/yellow] Alter table: [bold]{table_name}[/bold] "
81
+ f"({change_desc})"
82
+ )
83
+
84
+ # Tables to drop
85
+ if tables_to_drop:
86
+ for table in tables_to_drop:
87
+ self.console.print(
88
+ f" [red]-[/red] Drop table: [bold]{table['table_name']}[/bold]"
89
+ )
90
+
91
+ def format_data_diff(self, data_diff: Dict[str, Any]):
92
+ """
93
+ Format and display data differences.
94
+
95
+ Args:
96
+ data_diff: Data diff dictionary from DatabaseDiff
97
+ """
98
+ if data_diff.get("error"):
99
+ self.console.print(f"[red]Data Error:[/red] {data_diff['error']}")
100
+ return
101
+
102
+ total_new = data_diff.get("total_new_rows", 0)
103
+ total_updated = data_diff.get("total_updated_rows", 0)
104
+ total_deleted = data_diff.get("total_deleted_rows", 0)
105
+ table_details = data_diff.get("table_details", [])
106
+
107
+ if total_new == 0 and total_updated == 0 and total_deleted == 0:
108
+ self.console.print("[dim]Data: No changes[/dim]")
109
+ return
110
+
111
+ # Header
112
+ self.console.print("\n[bold]Data:[/bold]")
113
+
114
+ # Summary
115
+ if total_new > 0:
116
+ self.console.print(f" [green]+[/green] {total_new} new rows")
117
+ if total_updated > 0:
118
+ self.console.print(f" [yellow]~[/yellow] {total_updated} updated rows")
119
+ if total_deleted > 0:
120
+ self.console.print(f" [red]-[/red] {total_deleted} deleted rows")
121
+
122
+ # Table-level details (if there are multiple tables)
123
+ if len(table_details) > 1:
124
+ self.console.print("\n[dim] By table:[/dim]")
125
+ for detail in table_details:
126
+ parts = []
127
+ if detail["new_rows"] > 0:
128
+ parts.append(f"[green]+{detail['new_rows']}[/green]")
129
+ if detail["updated_rows"] > 0:
130
+ parts.append(f"[yellow]~{detail['updated_rows']}[/yellow]")
131
+ if detail["deleted_rows"] > 0:
132
+ parts.append(f"[red]-{detail['deleted_rows']}[/red]")
133
+
134
+ changes = " ".join(parts)
135
+ self.console.print(
136
+ f" {detail['table_name']}: {changes} "
137
+ f"[dim](local: {detail['local_count']}, "
138
+ f"cloud: {detail['cloud_count']})[/dim]"
139
+ )
140
+
141
+ def format_vectors_diff(self, vectors_diff: Dict[str, Any]):
142
+ """
143
+ Format and display vector differences.
144
+
145
+ Args:
146
+ vectors_diff: Vectors diff dictionary from DatabaseDiff
147
+ """
148
+ if vectors_diff.get("error"):
149
+ self.console.print(f"[red]Vectors Error:[/red] {vectors_diff['error']}")
150
+ return
151
+
152
+ total_upserts = vectors_diff.get("total_upserts", 0)
153
+ total_deletes = vectors_diff.get("total_deletes", 0)
154
+ namespace_details = vectors_diff.get("namespace_details", [])
155
+
156
+ if total_upserts == 0 and total_deletes == 0:
157
+ self.console.print("[dim]Vectors: No changes[/dim]")
158
+ return
159
+
160
+ # Header
161
+ self.console.print("\n[bold]Vectors:[/bold]")
162
+
163
+ # Summary
164
+ if total_upserts > 0:
165
+ self.console.print(f" [green]+[/green] Upsert {total_upserts} embeddings")
166
+ if total_deletes > 0:
167
+ self.console.print(f" [red]-[/red] Delete {total_deletes} stale vectors")
168
+
169
+ # Namespace details (if multiple namespaces)
170
+ if len(namespace_details) > 1:
171
+ self.console.print("\n[dim] By namespace:[/dim]")
172
+ for detail in namespace_details:
173
+ parts = []
174
+ if detail["upserts"] > 0:
175
+ parts.append(f"[green]+{detail['upserts']}[/green]")
176
+ if detail["deletes"] > 0:
177
+ parts.append(f"[red]-{detail['deletes']}[/red]")
178
+
179
+ changes = " ".join(parts)
180
+ self.console.print(
181
+ f" {detail['namespace']}: {changes} "
182
+ f"[dim](local: {detail['local_count']}, "
183
+ f"cloud: {detail['cloud_count']})[/dim]"
184
+ )
185
+
186
+ def format_summary_table(
187
+ self,
188
+ schema_diff: Dict[str, Any],
189
+ data_diff: Dict[str, Any],
190
+ vectors_diff: Dict[str, Any]
191
+ ):
192
+ """
193
+ Format an overall summary table.
194
+
195
+ Args:
196
+ schema_diff: Schema diff dictionary
197
+ data_diff: Data diff dictionary
198
+ vectors_diff: Vectors diff dictionary
199
+ """
200
+ table = Table(title="Sync Plan Summary", show_header=True)
201
+ table.add_column("Category", style="cyan")
202
+ table.add_column("Changes", justify="right")
203
+
204
+ # Schema
205
+ schema_changes = (
206
+ len(schema_diff.get("tables_to_create", [])) +
207
+ len(schema_diff.get("tables_to_alter", [])) +
208
+ len(schema_diff.get("tables_to_drop", []))
209
+ )
210
+ table.add_row("Schema", str(schema_changes))
211
+
212
+ # Data
213
+ data_changes = (
214
+ data_diff.get("total_new_rows", 0) +
215
+ data_diff.get("total_updated_rows", 0) +
216
+ data_diff.get("total_deleted_rows", 0)
217
+ )
218
+ table.add_row("Data Rows", str(data_changes))
219
+
220
+ # Vectors
221
+ vector_changes = (
222
+ vectors_diff.get("total_upserts", 0) +
223
+ vectors_diff.get("total_deletes", 0)
224
+ )
225
+ table.add_row("Vectors", str(vector_changes))
226
+
227
+ self.console.print(table)