tuskdata 0.1.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.
Files changed (66) hide show
  1. tusk/__init__.py +3 -0
  2. tusk/admin/__init__.py +42 -0
  3. tusk/admin/backup.py +331 -0
  4. tusk/admin/extensions.py +148 -0
  5. tusk/admin/maintenance.py +216 -0
  6. tusk/admin/monitoring.py +422 -0
  7. tusk/admin/pitr.py +487 -0
  8. tusk/admin/processes.py +87 -0
  9. tusk/admin/roles.py +276 -0
  10. tusk/admin/settings.py +185 -0
  11. tusk/admin/stats.py +73 -0
  12. tusk/cli.py +521 -0
  13. tusk/cluster/__init__.py +15 -0
  14. tusk/cluster/models.py +68 -0
  15. tusk/cluster/scheduler.py +337 -0
  16. tusk/cluster/worker.py +241 -0
  17. tusk/core/__init__.py +62 -0
  18. tusk/core/auth.py +801 -0
  19. tusk/core/config.py +163 -0
  20. tusk/core/connection.py +148 -0
  21. tusk/core/deps.py +84 -0
  22. tusk/core/files.py +151 -0
  23. tusk/core/geo.py +503 -0
  24. tusk/core/history.py +261 -0
  25. tusk/core/logging.py +33 -0
  26. tusk/core/result.py +40 -0
  27. tusk/core/scheduled_tasks.py +271 -0
  28. tusk/core/scheduler.py +229 -0
  29. tusk/core/workspace.py +154 -0
  30. tusk/engines/__init__.py +6 -0
  31. tusk/engines/duckdb_engine.py +312 -0
  32. tusk/engines/polars_engine.py +873 -0
  33. tusk/engines/postgres.py +191 -0
  34. tusk/engines/sqlite.py +87 -0
  35. tusk/studio/__init__.py +1 -0
  36. tusk/studio/app.py +83 -0
  37. tusk/studio/routes/__init__.py +28 -0
  38. tusk/studio/routes/admin.py +765 -0
  39. tusk/studio/routes/api.py +450 -0
  40. tusk/studio/routes/auth.py +545 -0
  41. tusk/studio/routes/cluster.py +446 -0
  42. tusk/studio/routes/data.py +696 -0
  43. tusk/studio/routes/files.py +385 -0
  44. tusk/studio/routes/pages.py +58 -0
  45. tusk/studio/routes/scheduler.py +170 -0
  46. tusk/studio/routes/settings.py +97 -0
  47. tusk/studio/static/.gitkeep +0 -0
  48. tusk/studio/static/admin.js +1473 -0
  49. tusk/studio/static/cluster.js +422 -0
  50. tusk/studio/static/data.js +1973 -0
  51. tusk/studio/static/profile.js +126 -0
  52. tusk/studio/static/studio.js +2608 -0
  53. tusk/studio/static/styles.css +379 -0
  54. tusk/studio/static/users.js +326 -0
  55. tusk/studio/templates/admin.html +524 -0
  56. tusk/studio/templates/base.html +206 -0
  57. tusk/studio/templates/cluster.html +182 -0
  58. tusk/studio/templates/data.html +560 -0
  59. tusk/studio/templates/index.html +294 -0
  60. tusk/studio/templates/login.html +149 -0
  61. tusk/studio/templates/profile.html +87 -0
  62. tusk/studio/templates/users.html +179 -0
  63. tuskdata-0.1.0.dist-info/METADATA +38 -0
  64. tuskdata-0.1.0.dist-info/RECORD +66 -0
  65. tuskdata-0.1.0.dist-info/WHEEL +4 -0
  66. tuskdata-0.1.0.dist-info/entry_points.txt +2 -0
tusk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Tusk - Modern data platform"""
2
+
3
+ __version__ = "0.1.0"
tusk/admin/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """PostgreSQL administration module"""
2
+
3
+ from tusk.admin.stats import get_server_stats, ServerStats
4
+ from tusk.admin.processes import get_active_queries, kill_query, ActiveQuery
5
+ from tusk.admin.backup import create_backup, list_backups, get_backup_path
6
+ from tusk.admin.extensions import (
7
+ get_extensions,
8
+ get_installed_extensions,
9
+ install_extension,
10
+ uninstall_extension,
11
+ Extension,
12
+ )
13
+ from tusk.admin.maintenance import (
14
+ get_locks,
15
+ get_all_locks,
16
+ get_table_bloat,
17
+ vacuum_table,
18
+ analyze_table,
19
+ reindex_table,
20
+ )
21
+
22
+ __all__ = [
23
+ "get_server_stats",
24
+ "ServerStats",
25
+ "get_active_queries",
26
+ "kill_query",
27
+ "ActiveQuery",
28
+ "create_backup",
29
+ "list_backups",
30
+ "get_backup_path",
31
+ "get_extensions",
32
+ "get_installed_extensions",
33
+ "install_extension",
34
+ "uninstall_extension",
35
+ "Extension",
36
+ "get_locks",
37
+ "get_all_locks",
38
+ "get_table_bloat",
39
+ "vacuum_table",
40
+ "analyze_table",
41
+ "reindex_table",
42
+ ]
tusk/admin/backup.py ADDED
@@ -0,0 +1,331 @@
1
+ """Backup and restore functionality for PostgreSQL"""
2
+
3
+ import subprocess
4
+ import os
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+
8
+ from tusk.core.connection import ConnectionConfig, TUSK_DIR
9
+ from tusk.core.config import get_config
10
+
11
+ BACKUP_DIR = TUSK_DIR / "backups"
12
+
13
+
14
+ def _get_pg_bin_search_paths() -> list[Path]:
15
+ """Get list of paths to search for PostgreSQL binaries"""
16
+ paths = []
17
+ seen_resolved = set()
18
+
19
+ def add_path(p: Path):
20
+ """Add path if not already seen (resolves symlinks to avoid duplicates)"""
21
+ try:
22
+ resolved = p.resolve()
23
+ if resolved not in seen_resolved and resolved.exists():
24
+ seen_resolved.add(resolved)
25
+ paths.append(p)
26
+ except Exception:
27
+ pass
28
+
29
+ # Postgres.app (macOS) - check latest first
30
+ add_path(Path("/Applications/Postgres.app/Contents/Versions/latest/bin"))
31
+
32
+ # Add versioned Postgres.app paths
33
+ pg_app_versions = Path("/Applications/Postgres.app/Contents/Versions")
34
+ if pg_app_versions.exists():
35
+ for version_dir in sorted(pg_app_versions.iterdir(), reverse=True):
36
+ if version_dir.name != "latest" and version_dir.is_dir():
37
+ add_path(version_dir / "bin")
38
+
39
+ # Homebrew (macOS)
40
+ for p in [
41
+ Path("/opt/homebrew/opt/postgresql/bin"),
42
+ Path("/opt/homebrew/bin"),
43
+ Path("/usr/local/opt/postgresql/bin"),
44
+ Path("/usr/local/bin"),
45
+ ]:
46
+ add_path(p)
47
+
48
+ # Linux common paths
49
+ for p in [
50
+ Path("/usr/lib/postgresql/16/bin"),
51
+ Path("/usr/lib/postgresql/15/bin"),
52
+ Path("/usr/lib/postgresql/14/bin"),
53
+ Path("/usr/pgsql-16/bin"),
54
+ Path("/usr/pgsql-15/bin"),
55
+ Path("/usr/bin"),
56
+ ]:
57
+ add_path(p)
58
+
59
+ return paths
60
+
61
+
62
+ def _find_pg_binary(name: str) -> str:
63
+ """Find a PostgreSQL binary (pg_dump, psql, etc.)
64
+
65
+ Priority:
66
+ 1. User-configured pg_bin_path in ~/.tusk/config.toml
67
+ 2. System PATH
68
+ 3. Common installation locations (Postgres.app, Homebrew, etc.)
69
+ """
70
+ import shutil
71
+
72
+ # First check user-configured path
73
+ config = get_config()
74
+ if config.pg_bin_path:
75
+ configured_path = Path(config.pg_bin_path) / name
76
+ if configured_path.exists() and configured_path.is_file():
77
+ return str(configured_path)
78
+
79
+ # Then check if it's in PATH
80
+ if shutil.which(name):
81
+ return name
82
+
83
+ # Search in common locations
84
+ for search_path in _get_pg_bin_search_paths():
85
+ binary_path = search_path / name
86
+ if binary_path.exists() and binary_path.is_file():
87
+ return str(binary_path)
88
+
89
+ # Fallback to just the name (will fail with clear error if not found)
90
+ return name
91
+
92
+
93
+ def get_pg_dump_path() -> str:
94
+ """Get the path to pg_dump binary"""
95
+ return _find_pg_binary("pg_dump")
96
+
97
+
98
+ def get_psql_path() -> str:
99
+ """Get the path to psql binary"""
100
+ return _find_pg_binary("psql")
101
+
102
+
103
+ def create_backup(config: ConnectionConfig) -> tuple[bool, str, Path | None]:
104
+ """Create a pg_dump backup of the database
105
+
106
+ Returns: (success, message, filepath)
107
+ """
108
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
109
+
110
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
111
+ filename = f"{config.database}_{timestamp}.sql.gz"
112
+ filepath = BACKUP_DIR / filename
113
+
114
+ pg_dump = get_pg_dump_path()
115
+
116
+ cmd = [
117
+ pg_dump,
118
+ "-h", config.host or "localhost",
119
+ "-p", str(config.port),
120
+ "-U", config.user or "postgres",
121
+ "-d", config.database or "postgres",
122
+ "--format=plain",
123
+ ]
124
+
125
+ env = os.environ.copy()
126
+ if config.password:
127
+ env["PGPASSWORD"] = config.password
128
+
129
+ try:
130
+ # pg_dump | gzip > file
131
+ with open(filepath, "wb") as f:
132
+ dump_proc = subprocess.Popen(
133
+ cmd,
134
+ stdout=subprocess.PIPE,
135
+ stderr=subprocess.PIPE,
136
+ env=env
137
+ )
138
+ gzip_proc = subprocess.Popen(
139
+ ["gzip"],
140
+ stdin=dump_proc.stdout,
141
+ stdout=f,
142
+ stderr=subprocess.PIPE
143
+ )
144
+
145
+ dump_proc.stdout.close()
146
+ gzip_proc.communicate()
147
+ dump_proc.wait()
148
+
149
+ if dump_proc.returncode != 0:
150
+ stderr = dump_proc.stderr.read().decode() if dump_proc.stderr else "Unknown error"
151
+ filepath.unlink(missing_ok=True)
152
+ return False, f"pg_dump failed: {stderr}", None
153
+
154
+ size = filepath.stat().st_size
155
+ size_human = f"{size / 1024 / 1024:.2f} MB" if size > 1024 * 1024 else f"{size / 1024:.1f} KB"
156
+ return True, f"Backup created: {filename} ({size_human})", filepath
157
+
158
+ except FileNotFoundError:
159
+ return False, f"pg_dump not found at '{pg_dump}'. Install PostgreSQL client tools or check Postgres.app installation.", None
160
+ except Exception as e:
161
+ filepath.unlink(missing_ok=True)
162
+ return False, f"Backup failed: {str(e)}", None
163
+
164
+
165
+ def list_backups() -> list[dict]:
166
+ """List all available backups"""
167
+ if not BACKUP_DIR.exists():
168
+ return []
169
+
170
+ backups = []
171
+ for f in sorted(BACKUP_DIR.glob("*.sql.gz"), reverse=True):
172
+ stat = f.stat()
173
+ backups.append({
174
+ "filename": f.name,
175
+ "size_bytes": stat.st_size,
176
+ "size_human": f"{stat.st_size / 1024 / 1024:.2f} MB" if stat.st_size > 1024 * 1024 else f"{stat.st_size / 1024:.1f} KB",
177
+ "created": datetime.fromtimestamp(stat.st_mtime).isoformat(),
178
+ })
179
+
180
+ return backups
181
+
182
+
183
+ def get_backup_path(filename: str) -> Path | None:
184
+ """Get full path to a backup file (for download)"""
185
+ filepath = BACKUP_DIR / filename
186
+ if filepath.exists() and filepath.is_file():
187
+ return filepath
188
+ return None
189
+
190
+
191
+ def restore_backup(config: ConnectionConfig, filename: str) -> tuple[bool, str]:
192
+ """Restore a database from backup
193
+
194
+ WARNING: This will overwrite the target database!
195
+ """
196
+ filepath = get_backup_path(filename)
197
+ if not filepath:
198
+ return False, f"Backup file not found: {filename}"
199
+
200
+ psql = get_psql_path()
201
+
202
+ cmd = [
203
+ psql,
204
+ "-h", config.host or "localhost",
205
+ "-p", str(config.port),
206
+ "-U", config.user or "postgres",
207
+ "-d", config.database or "postgres",
208
+ ]
209
+
210
+ env = os.environ.copy()
211
+ if config.password:
212
+ env["PGPASSWORD"] = config.password
213
+
214
+ try:
215
+ # gunzip < file | psql
216
+ with open(filepath, "rb") as f:
217
+ gunzip_proc = subprocess.Popen(
218
+ ["gunzip", "-c"],
219
+ stdin=f,
220
+ stdout=subprocess.PIPE,
221
+ stderr=subprocess.PIPE
222
+ )
223
+ psql_proc = subprocess.Popen(
224
+ cmd,
225
+ stdin=gunzip_proc.stdout,
226
+ stdout=subprocess.PIPE,
227
+ stderr=subprocess.PIPE,
228
+ env=env
229
+ )
230
+
231
+ gunzip_proc.stdout.close()
232
+ stdout, stderr = psql_proc.communicate()
233
+
234
+ if psql_proc.returncode != 0:
235
+ return False, f"Restore failed: {stderr.decode()}"
236
+
237
+ return True, f"Database restored from {filename}"
238
+
239
+ except FileNotFoundError:
240
+ return False, f"psql not found at '{psql}'. Install PostgreSQL client tools or check Postgres.app installation."
241
+ except Exception as e:
242
+ return False, f"Restore failed: {str(e)}"
243
+
244
+
245
+ def get_createdb_path() -> str:
246
+ """Get the path to createdb binary"""
247
+ return _find_pg_binary("createdb")
248
+
249
+
250
+ def create_database(config: ConnectionConfig, db_name: str, owner: str | None = None) -> tuple[bool, str]:
251
+ """Create a new database on the PostgreSQL server
252
+
253
+ Args:
254
+ config: Connection config (uses host, port, user, password)
255
+ db_name: Name for the new database
256
+ owner: Optional owner for the database
257
+ """
258
+ createdb = get_createdb_path()
259
+
260
+ cmd = [
261
+ createdb,
262
+ "-h", config.host or "localhost",
263
+ "-p", str(config.port),
264
+ "-U", config.user or "postgres",
265
+ ]
266
+
267
+ if owner:
268
+ cmd.extend(["-O", owner])
269
+
270
+ cmd.append(db_name)
271
+
272
+ env = os.environ.copy()
273
+ if config.password:
274
+ env["PGPASSWORD"] = config.password
275
+
276
+ try:
277
+ result = subprocess.run(
278
+ cmd,
279
+ capture_output=True,
280
+ text=True,
281
+ env=env
282
+ )
283
+
284
+ if result.returncode != 0:
285
+ return False, f"Create database failed: {result.stderr}"
286
+
287
+ return True, f"Database '{db_name}' created successfully"
288
+
289
+ except FileNotFoundError:
290
+ return False, f"createdb not found at '{createdb}'. Install PostgreSQL client tools."
291
+ except Exception as e:
292
+ return False, f"Create database failed: {str(e)}"
293
+
294
+
295
+ def create_database_from_backup(
296
+ config: ConnectionConfig,
297
+ filename: str,
298
+ new_db_name: str,
299
+ owner: str | None = None
300
+ ) -> tuple[bool, str]:
301
+ """Create a new database and restore from backup
302
+
303
+ Args:
304
+ config: Connection config for the PostgreSQL server
305
+ filename: Backup file to restore from
306
+ new_db_name: Name for the new database
307
+ owner: Optional owner for the new database
308
+ """
309
+ # First create the new database
310
+ success, message = create_database(config, new_db_name, owner)
311
+ if not success:
312
+ return False, message
313
+
314
+ # Create a modified config pointing to the new database
315
+ new_config = ConnectionConfig(
316
+ id=config.id,
317
+ name=config.name,
318
+ type=config.type,
319
+ host=config.host,
320
+ port=config.port,
321
+ user=config.user,
322
+ password=config.password,
323
+ database=new_db_name,
324
+ )
325
+
326
+ # Restore the backup to the new database
327
+ success, message = restore_backup(new_config, filename)
328
+ if not success:
329
+ return False, f"Database created but restore failed: {message}"
330
+
331
+ return True, f"Database '{new_db_name}' created and restored from {filename}"
@@ -0,0 +1,148 @@
1
+ """PostgreSQL extension management"""
2
+
3
+ import msgspec
4
+ from ..core.connection import ConnectionConfig
5
+ from ..engines.postgres import execute_query
6
+
7
+
8
+ class Extension(msgspec.Struct):
9
+ """PostgreSQL extension info"""
10
+ name: str
11
+ installed_version: str | None = None
12
+ default_version: str | None = None
13
+ description: str | None = None
14
+ is_installed: bool = False
15
+
16
+
17
+ async def get_extensions(config: ConnectionConfig) -> list[Extension]:
18
+ """Get all available extensions with their installation status"""
19
+
20
+ # Query to get all available extensions and their installation status
21
+ sql = """
22
+ SELECT
23
+ a.name,
24
+ i.extversion as installed_version,
25
+ a.default_version,
26
+ a.comment as description,
27
+ CASE WHEN i.extname IS NOT NULL THEN true ELSE false END as is_installed
28
+ FROM pg_available_extensions a
29
+ LEFT JOIN pg_extension i ON a.name = i.extname
30
+ ORDER BY
31
+ CASE WHEN i.extname IS NOT NULL THEN 0 ELSE 1 END,
32
+ a.name
33
+ """
34
+
35
+ result = await execute_query(config, sql)
36
+
37
+ extensions = []
38
+ for row in result.rows:
39
+ extensions.append(Extension(
40
+ name=row[0],
41
+ installed_version=row[1],
42
+ default_version=row[2],
43
+ description=row[3],
44
+ is_installed=row[4]
45
+ ))
46
+
47
+ return extensions
48
+
49
+
50
+ async def get_installed_extensions(config: ConnectionConfig) -> list[Extension]:
51
+ """Get only installed extensions"""
52
+
53
+ sql = """
54
+ SELECT
55
+ e.extname as name,
56
+ e.extversion as installed_version,
57
+ a.default_version,
58
+ a.comment as description,
59
+ true as is_installed
60
+ FROM pg_extension e
61
+ LEFT JOIN pg_available_extensions a ON e.extname = a.name
62
+ ORDER BY e.extname
63
+ """
64
+
65
+ result = await execute_query(config, sql)
66
+
67
+ extensions = []
68
+ for row in result.rows:
69
+ extensions.append(Extension(
70
+ name=row[0],
71
+ installed_version=row[1],
72
+ default_version=row[2],
73
+ description=row[3],
74
+ is_installed=True
75
+ ))
76
+
77
+ return extensions
78
+
79
+
80
+ async def install_extension(config: ConnectionConfig, name: str, schema: str | None = None) -> bool:
81
+ """Install an extension"""
82
+
83
+ # Validate extension name (prevent SQL injection)
84
+ if not name.replace("_", "").replace("-", "").isalnum():
85
+ raise ValueError(f"Invalid extension name: {name}")
86
+
87
+ sql = f'CREATE EXTENSION IF NOT EXISTS "{name}"'
88
+ if schema:
89
+ # Validate schema name
90
+ if not schema.replace("_", "").isalnum():
91
+ raise ValueError(f"Invalid schema name: {schema}")
92
+ sql += f' SCHEMA "{schema}"'
93
+
94
+ await execute_query(config, sql)
95
+ return True
96
+
97
+
98
+ async def uninstall_extension(config: ConnectionConfig, name: str, cascade: bool = False) -> bool:
99
+ """Uninstall an extension"""
100
+
101
+ # Validate extension name (prevent SQL injection)
102
+ if not name.replace("_", "").replace("-", "").isalnum():
103
+ raise ValueError(f"Invalid extension name: {name}")
104
+
105
+ sql = f'DROP EXTENSION IF EXISTS "{name}"'
106
+ if cascade:
107
+ sql += " CASCADE"
108
+
109
+ await execute_query(config, sql)
110
+ return True
111
+
112
+
113
+ async def get_extension_details(config: ConnectionConfig, name: str) -> dict:
114
+ """Get detailed information about an extension"""
115
+
116
+ # Validate extension name
117
+ if not name.replace("_", "").replace("-", "").isalnum():
118
+ raise ValueError(f"Invalid extension name: {name}")
119
+
120
+ sql = f"""
121
+ SELECT
122
+ a.name,
123
+ a.default_version,
124
+ a.comment as description,
125
+ e.extversion as installed_version,
126
+ e.extrelocatable as relocatable,
127
+ n.nspname as schema
128
+ FROM pg_available_extensions a
129
+ LEFT JOIN pg_extension e ON a.name = e.extname
130
+ LEFT JOIN pg_namespace n ON e.extnamespace = n.oid
131
+ WHERE a.name = '{name}'
132
+ """
133
+
134
+ result = await execute_query(config, sql)
135
+
136
+ if not result.rows:
137
+ return {"error": f"Extension '{name}' not found"}
138
+
139
+ row = result.rows[0]
140
+ return {
141
+ "name": row[0],
142
+ "default_version": row[1],
143
+ "description": row[2],
144
+ "installed_version": row[3],
145
+ "relocatable": row[4],
146
+ "schema": row[5],
147
+ "is_installed": row[3] is not None
148
+ }