├── .flake8 ├── .pylintrc ├── LICENSE ├── README.md ├── pyproject.toml ├── setup.cfg └── sqint ├── __init__.py ├── sqint.css └── sqint.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=100 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Collin Delker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sqint 2 | 3 | ### SQlite IN Terminal 4 | 5 | Sqint is a texutal-based terminal application for viewing, querying, and modifying SQLite databases. 6 | 7 | Install with: 8 | 9 | ``` 10 | pip install sqint 11 | ``` 12 | 13 | And run from the command line: 14 | 15 | ``` 16 | sqint [database.db] 17 | ``` 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = sqint 3 | version = attr: sqint.__version__ 4 | author = Collin J. Delker 5 | author_email = code@collindelker.com 6 | url = https://github.com/cdelker/sqint 7 | description = Terminal SQLite Viewer and Editor 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | keywords = sqlite, database, tui 11 | license = License :: OSI Approved :: MIT License 12 | classifiers = 13 | Development Status :: 3 - Alpha 14 | Programming Language :: Python 15 | Topic :: Database 16 | Topic :: Database :: Front-Ends 17 | License :: OSI Approved :: MIT License 18 | 19 | [options] 20 | packages = find: 21 | zip_safe = True 22 | include_package_data = True 23 | install_requires = 24 | textual>=0.26 25 | 26 | [options.package_data] 27 | * = 28 | *.css 29 | 30 | [options.entry_points] 31 | console_scripts = 32 | sqint = sqint.sqint:main 33 | -------------------------------------------------------------------------------- /sqint/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1a1' -------------------------------------------------------------------------------- /sqint/sqint.css: -------------------------------------------------------------------------------- 1 | Screen { 2 | align: center top; 3 | overflow-x: auto; 4 | } 5 | 6 | ModalScreen { 7 | align: center middle; 8 | } 9 | 10 | #dialog { 11 | padding: 0 1; 12 | width: 60; 13 | height: 11; 14 | border: thick $background 80%; 15 | background: $surface; 16 | } 17 | 18 | #insertdialog { 19 | padding: 0 1; 20 | width: 60; 21 | height: 40; 22 | border: thick $background 80%; 23 | background: $surface; 24 | } 25 | 26 | TabbedContent ContentSwitcher { 27 | overflow-y: hidden; 28 | height: 1fr; 29 | } 30 | 31 | Button { 32 | margin-right: 1; 33 | margin-left: 1; 34 | } 35 | 36 | #dbtree { 37 | dock: left; 38 | width: 25; 39 | padding: 1; 40 | } 41 | 42 | DataTable { 43 | padding-left: 1; 44 | padding-right: 1; 45 | height: 1fr; 46 | } 47 | 48 | #opentree { 49 | height: 80%; 50 | margin-top: 2; 51 | margin-left: 2; 52 | } 53 | 54 | #openlabel { 55 | margin-left: 3; 56 | margin-top: 2; 57 | } 58 | 59 | RowEdit { 60 | height: 3; 61 | width: 80%; 62 | } 63 | #roweditlabel { 64 | margin-left: 2; 65 | margin-top: 1; 66 | } 67 | #roweditvalue { 68 | height: 3; 69 | margin-left: 2; 70 | } -------------------------------------------------------------------------------- /sqint/sqint.py: -------------------------------------------------------------------------------- 1 | ''' Textual viewer/editor for SQLite databases ''' 2 | 3 | import os 4 | import sys 5 | import sqlite3 6 | import asyncio 7 | from collections import namedtuple 8 | from pathlib import Path 9 | from typing import Optional, Iterable, Sequence 10 | 11 | from rich.markup import escape 12 | from textual import on 13 | from textual.css.query import NoMatches 14 | from textual.app import App, ComposeResult 15 | from textual.binding import Binding 16 | from textual.widgets import (Button, 17 | ContentSwitcher, 18 | DirectoryTree, 19 | DataTable, 20 | Footer, 21 | Header, 22 | Input, 23 | Label, 24 | TabbedContent, 25 | TabPane, 26 | Tree, 27 | Static) 28 | from textual.screen import Screen, ModalScreen 29 | from textual.containers import Container, Horizontal, Vertical 30 | from textual.message import Message 31 | from textual.coordinate import Coordinate 32 | 33 | 34 | SQLITE_EXTENSIONS = ['.db', '.sqlite', '.sqlite3', '.db3', '.s3db', '.sl3'] 35 | TableEditInfo = namedtuple('TableEditInfo', 'value column tablename conditions coordinate') 36 | 37 | 38 | def sanitize_table(rows: Iterable[Iterable[str]], limit: int = 40) -> Iterable[Iterable[str]]: 39 | ''' Limit table rows to a certain length and escape any markup. ''' 40 | rows = [[col[:limit-3]+'...' if len(col) > limit else col for col in row] for row in rows] 41 | rows = [[escape(col) for col in row] for row in rows] 42 | return rows 43 | 44 | 45 | def escape_identifier(name: str): 46 | ''' SQLite can have quotes in table names. This escapes them. ''' 47 | return "\"" + name.replace("\"", "\"\"") + "\"" 48 | 49 | 50 | class Database: 51 | ''' The Sqlite Database ''' 52 | 53 | def load(self, path: Path) -> bool: 54 | ''' Load database from path. Return True if successful. ''' 55 | try: 56 | self.connection = sqlite3.connect(path) 57 | except sqlite3.DatabaseError: 58 | return False 59 | 60 | self.path = path 61 | self.name = os.path.split(self.path)[1] 62 | _, tables = self.query( 63 | "SELECT name FROM sqlite_schema WHERE type='table'") 64 | self.tables = [t[0] for t in tables] 65 | _, views = self.query( 66 | "SELECT name FROM sqlite_schema WHERE type='view'") 67 | self.views = [v[0] for v in views] 68 | return True 69 | 70 | def query(self, query: str, args: Sequence[str] = None) -> tuple[list[str], list[list[str]]]: 71 | ''' Query the database ''' 72 | args = () if args is None else args 73 | columns: list[str] = [] 74 | rows: list[list[str]] = [[]] 75 | try: 76 | cursor = self.connection.execute(query, args) 77 | except AttributeError: 78 | pass 79 | else: 80 | if cursor.description: 81 | columns = [col[0] for col in cursor.description] 82 | rows = [[str(col) for col in row] for row in cursor.fetchall()] 83 | return columns, rows 84 | 85 | def query_single(self, tablename: str, column: str, conditions: dict = None): 86 | ''' Get single field from a table ''' 87 | args = None 88 | query = f'SELECT {escape_identifier(column)} from {escape_identifier(tablename)} ' 89 | if conditions: 90 | query += 'where ' + ' and '.join(f'{key}=?' for key in conditions.keys()) 91 | args = tuple(conditions.values()) 92 | _, rows = self.query(query, args) 93 | return rows[0][0] 94 | 95 | def table_info(self, name: str) -> tuple[list[str], list[list[str]]]: 96 | ''' Get table info ''' 97 | columns, info = self.query(f'PRAGMA table_info({escape_identifier(name)});') 98 | return columns, info 99 | 100 | def table_data(self, name: str) -> tuple[list[str], list[list[str]]]: 101 | ''' Get column names and row data from table ''' 102 | primary_keys = self.primary_keys(name) 103 | if name not in self.views and primary_keys[0] == 'rowid': 104 | columns, rows = self.query(f'SELECT rowid, * FROM {escape_identifier(name)}') 105 | else: 106 | columns, rows = self.query(f'SELECT * FROM {escape_identifier(name)}') 107 | return columns, rows 108 | 109 | def primary_keys(self, name: str) -> list[str]: 110 | ''' Get primary key columns for a table ''' 111 | _, rows = self.query( 112 | f'SELECT l.name FROM pragma_table_info({escape_identifier(name)}) as l WHERE l.pk = 1;') 113 | rowstrs = [r[0] for r in rows] 114 | if not rowstrs: 115 | rowstrs = ['rowid'] 116 | return rowstrs 117 | 118 | def update(self, tablename: str, colunmname: str, value: str, where: dict = None) -> None: 119 | ''' Update a single field in the database ''' 120 | args = [value] 121 | sql = f'UPDATE {escape_identifier(tablename)} SET {escape_identifier(colunmname)}=? ' 122 | if where: 123 | searchstrs = ' and '.join(f'{pk}=?' for pk in where.keys()) 124 | sql += ' WHERE ' + searchstrs 125 | args += where.values() 126 | self.connection.execute(sql, args) 127 | self.connection.commit() 128 | 129 | def insert(self, tablename: str, values: dict[str, str]) -> None: 130 | ''' Insert a new row into the table ''' 131 | colstr = ','.join(escape_identifier(v) for v in values.keys()) 132 | qs = ','.join('?'*len(values)) 133 | sql = (f'INSERT INTO {escape_identifier(tablename)} ' 134 | f'({colstr}) VALUES({qs})') 135 | self.connection.execute(sql, list(values.values())) 136 | self.connection.commit() 137 | 138 | 139 | class SqliteDirectoryTree(DirectoryTree): 140 | ''' Textual DirectoryTree with sqlite file filter ''' 141 | def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: 142 | return [path for path in paths if path.is_dir() or path.suffix.lower() in SQLITE_EXTENSIONS] 143 | 144 | 145 | class OpenDb(Screen): 146 | ''' Screen for selecting a database file ''' 147 | BINDINGS = [Binding("escape", "app.pop_screen", "Pop screen")] 148 | 149 | class Fileopen(Message): 150 | ''' Message to notify that a database should be loaded ''' 151 | def __init__(self, path: Path) -> None: 152 | self.path = path 153 | super().__init__() 154 | 155 | def compose(self) -> ComposeResult: 156 | yield Label('Select Database to Open', id='openlabel') 157 | yield SqliteDirectoryTree(os.path.expanduser('~'), id='opentree') 158 | yield Button('Open', id='openbutton') 159 | 160 | @on(Button.Pressed, '#openbutton') 161 | def openbutton(self, event: Button.Pressed) -> None: 162 | ''' The Open button was pressed ''' 163 | tree = self.query_one('#opentree', DirectoryTree) 164 | if tree.cursor_node and tree.cursor_node.data: 165 | self.post_message(self.Fileopen(tree.cursor_node.data.path)) 166 | event.stop() 167 | 168 | 169 | class DbTreeWidget(Tree): 170 | ''' Tree widget for showing DB tables ''' 171 | def load_db(self, database: Database): 172 | ''' Load database data into the tree ''' 173 | self.database = database 174 | self.clear() 175 | self.root.label = os.path.basename(database.path) 176 | self.root.expand() 177 | tree_tables = self.root.add("Tables", expand=True) 178 | for table in self.database.tables: 179 | tree_tables.add_leaf(table) 180 | tree_views = self.root.add("Views", expand=True) 181 | for view in self.database.views: 182 | tree_views.add_leaf(view) 183 | 184 | 185 | class DbTableEdit(DataTable): 186 | ''' DataTable for showing and editing an SQLite table ''' 187 | BINDINGS = [Binding('enter', 'edit_field', 'Edit Field'), 188 | Binding('i', 'insert_row', 'Insert Row')] 189 | 190 | @property 191 | def column_names(self) -> list[str]: 192 | ''' Get list of column names ''' 193 | return [str(c.label) for c in self.ordered_columns] 194 | 195 | @property 196 | def current_column(self) -> str: 197 | ''' Get label for selected column ''' 198 | return self.column_names[self.cursor_column] 199 | 200 | @property 201 | def current_value(self) -> str: 202 | ''' Get value of selected cell ''' 203 | return self.get_cell_at(self.cursor_coordinate) 204 | 205 | def current_row_values(self, *columns: str) -> dict: 206 | ''' Get values of columns for selected row ''' 207 | column_labels = tuple(self.column_names) 208 | if len(columns) == 0: 209 | columns = column_labels 210 | colids = [column_labels.index(c) for c in columns] 211 | col_values = [self.get_cell_at(Coordinate(self.cursor_row, i)) for i in colids] 212 | return dict(zip(columns, col_values)) 213 | 214 | 215 | class FieldEditor(ModalScreen): 216 | ''' Popup Widget for editing a single field in a table ''' 217 | BINDINGS = [Binding("escape", "app.pop_screen()", "Close")] 218 | 219 | class ChangeField(Message): 220 | ''' Message to notify that the field should be changed ''' 221 | def __init__(self, newvalue: str, changeinfo: TableEditInfo): 222 | self.newvalue = newvalue 223 | self.changeinfo = changeinfo 224 | super().__init__() 225 | 226 | def compose(self) -> ComposeResult: 227 | with Vertical(id='dialog'): 228 | yield Label('Value', id='fieldname') 229 | yield Input(id='fieldinput') 230 | with Horizontal(): 231 | yield Button('Commit', id='commit') 232 | yield Button('Cancel', id='cancel') 233 | 234 | async def startedit(self, editinfo: TableEditInfo) -> None: 235 | ''' Start editing the field ''' 236 | self.editinfo = editinfo 237 | fieldname = self.query_one('#fieldname', Label) 238 | fieldname.update(editinfo.column) 239 | value = self.query_one('#fieldinput', Input) 240 | value.action_end() 241 | value.action_delete_left_all() 242 | value.insert_text_at_cursor(editinfo.value) 243 | value.focus() 244 | 245 | def on_field_editor_start_edit(self, message): 246 | self.editinfo = message.editinfo 247 | fieldname = self.query_one('#fieldname', Label) 248 | fieldname.update(self.editinfo.column) 249 | value = self.query_one('#fieldinput', Input) 250 | value.action_end() 251 | value.action_delete_left_all() 252 | value.insert_text_at_cursor(self.editinfo.value) 253 | value.focus() 254 | 255 | def on_input_submitted(self, event: Input.Submitted) -> None: 256 | ''' Enter was pressed in the Input. Commit the change. ''' 257 | self.app.pop_screen() 258 | self.post_message(self.ChangeField(event.value, self.editinfo)) 259 | event.stop() 260 | 261 | @on(Button.Pressed, '#cancel') 262 | def cancel(self, event: Button.Pressed) -> None: 263 | ''' Cancel the dialog ''' 264 | event.stop() 265 | self.app.pop_screen() 266 | 267 | @on(Button.Pressed, '#commit') 268 | def commit(self) -> None: 269 | ''' Commit the change ''' 270 | value = self.query_one('#fieldinput', Input).value 271 | self.post_message(self.ChangeField(value, self.editinfo)) 272 | self.app.pop_screen() 273 | 274 | 275 | class InsertEditor(ModalScreen): 276 | ''' Screen for inserting an entire row into a table ''' 277 | BINDINGS = [Binding("escape", "app.pop_screen", "Pop screen")] 278 | 279 | class InsertRow(Message): 280 | ''' Message to notify that a row should be inserted ''' 281 | def __init__(self, tablename: str, values: dict[str, str]): 282 | self.values = values 283 | self.tablename = tablename 284 | super().__init__() 285 | 286 | class RowEdit(Static): 287 | ''' Label and Input Widgets ''' 288 | def __init__(self, label: str, value: str): 289 | super().__init__() 290 | self.label = label 291 | self.initialvalue = value 292 | 293 | def compose(self) -> ComposeResult: 294 | with Horizontal(): 295 | yield Label(self.label, id='roweditlabel') 296 | yield Input(self.initialvalue, id='roweditvalue') 297 | 298 | @property 299 | def value(self): 300 | ''' Get entered value as a string ''' 301 | inpt = self.query_one('#value', Input) 302 | return str(inpt.value) 303 | 304 | def compose(self) -> ComposeResult: 305 | with Vertical(id='insertdialog'): 306 | yield Label(id='tablename') 307 | yield Container(id='widgetcontainer') 308 | with Horizontal(): 309 | yield Button('Commit', id='commit') 310 | yield Button('Cancel', id='cancel') 311 | 312 | def clear(self) -> None: 313 | ''' Clear the widgets ''' 314 | widgets = self.query(self.RowEdit) 315 | if widgets: 316 | widgets.remove() 317 | 318 | async def startedit(self, tablename: str, columnnames: Iterable[str]) -> None: 319 | ''' Add widgets for entering a database row ''' 320 | self.query_one('#tablename', Label).update(tablename) 321 | self.clear() 322 | for column in columnnames: 323 | widget = self.RowEdit(column, '') 324 | self.query_one('#widgetcontainer').mount(widget) 325 | 326 | @on(Button.Pressed, '#cancel') 327 | def cancel(self): 328 | ''' Cancel the dialog. ''' 329 | self.app.pop_screen() 330 | 331 | @on(Button.Pressed, '#commit') 332 | def accept(self): 333 | ''' Accept the new row ''' 334 | tablename = str(self.query_one('#tablename', Label).renderable) 335 | values = {} 336 | for rowedit in self.query(self.RowEdit): 337 | key = str(rowedit.query_one('#roweditlabel', Label).renderable) 338 | value = str(rowedit.query_one('#roweditvalue', Input).value) 339 | if value: 340 | values[key] = value 341 | self.post_message(self.InsertRow(tablename, values)) 342 | self.app.pop_screen() 343 | 344 | 345 | class Sqint(App): 346 | ''' Main SQLite Viewer App ''' 347 | CSS_PATH = 'sqint.css' 348 | SCREENS = {'opendb': OpenDb(), 349 | 'editfield': FieldEditor(), 350 | 'insertrow': InsertEditor()} 351 | BINDINGS = [Binding("o", "push_screen('opendb')", "Open Database"), 352 | Binding("d", "toggle_dark", "Toggle dark mode")] 353 | 354 | def __init__(self, dbpath: str = None): 355 | super().__init__() 356 | self.database = Database() 357 | self.dbpath = dbpath 358 | self.currenttable: Optional[str] = None 359 | 360 | def on_mount(self) -> None: 361 | ''' Load the database when mounted ''' 362 | if self.dbpath: 363 | self.load_database(Path(self.dbpath)) 364 | else: 365 | self.push_screen('opendb') 366 | 367 | def load_database(self, path: Path) -> bool: 368 | ''' Load database info into widgets. Return True on success ''' 369 | loaded = self.database.load(path) 370 | if loaded: 371 | self.query_one('#dbtree', DbTreeWidget).load_db(self.database) 372 | return loaded 373 | 374 | def compose(self) -> ComposeResult: 375 | yield Header() 376 | yield DbTreeWidget('database', id='dbtree') 377 | with TabbedContent(): 378 | with TabPane('Contents', id='tab_contents'): 379 | yield DbTableEdit(id='dbtable') 380 | with TabPane('Table Info', id='tab_info'): 381 | yield DataTable(id='infotable') 382 | with TabPane('Query', id='tab_query'): 383 | yield Input(placeholder='SELECT * FROM ?', id='queryinput') 384 | yield DataTable(id='queryoutput') 385 | yield Footer() 386 | 387 | def on_tree_node_selected(self, message: Tree.NodeSelected) -> None: 388 | ''' Something was selected in the Database Tree ''' 389 | if not message.node.allow_expand: 390 | self.currenttable = str(message.node.label) 391 | columns, rows = self.database.table_data(self.currenttable) 392 | table = self.query_one('#dbtable', DbTableEdit) 393 | table.clear(columns=True) 394 | table.add_columns(*columns) 395 | table.add_rows(sanitize_table(rows)) 396 | 397 | infotable = self.query_one('#infotable', DataTable) 398 | infotable.clear(columns=True) 399 | columns, info = self.database.table_info(str(message.node.label)) 400 | infotable.add_columns(*columns) 401 | infotable.add_rows(sanitize_table(info)) 402 | 403 | contentswitcher = self.query_one(ContentSwitcher) 404 | if contentswitcher.current == 'query': 405 | contentswitcher.current = 'dbtable' 406 | 407 | async def action_edit_field(self) -> None: 408 | ''' Edit of the field was requested. Show edit popup. ''' 409 | if self.currenttable and self.currenttable not in self.database.views: 410 | table = self.query_one('#dbtable', DbTableEdit) 411 | primary_keys = self.database.primary_keys(self.currenttable) 412 | conditions = table.current_row_values(*primary_keys) 413 | current_value = self.database.query_single( 414 | self.currenttable, table.current_column, conditions) 415 | tableinfo = TableEditInfo(current_value, 416 | table.current_column, 417 | self.currenttable, 418 | conditions, 419 | table.cursor_coordinate) 420 | self.push_screen('editfield') 421 | screen = self.SCREENS['editfield'] 422 | await asyncio.create_task(screen.startedit(tableinfo)) # type: ignore 423 | 424 | async def action_insert_row(self) -> None: 425 | ''' Show the Insert Row screen ''' 426 | if self.currenttable and self.currenttable not in self.database.views: 427 | table = self.query_one('#dbtable', DbTableEdit) 428 | self.push_screen('insertrow') 429 | screen = self.SCREENS['insertrow'] 430 | await asyncio.create_task( 431 | screen.startedit(self.currenttable, table.column_names)) # type: ignore 432 | 433 | def on_field_editor_change_field(self, message: FieldEditor.ChangeField) -> None: 434 | ''' Field editor is done editing ''' 435 | where = message.changeinfo.conditions 436 | table_name = message.changeinfo.tablename 437 | column_name = message.changeinfo.column 438 | coordinate = message.changeinfo.coordinate 439 | new_value = message.newvalue 440 | table = self.query_one('#dbtable', DbTableEdit) 441 | try: 442 | self.database.update(table_name, column_name, new_value, where) 443 | except sqlite3.Error: 444 | pass # TODO: show error message 445 | else: 446 | table.update_cell_at(coordinate, new_value, update_width=True) 447 | table.focus() 448 | 449 | def on_insert_editor_insert_row(self, message: InsertEditor.InsertRow) -> None: 450 | ''' Insert Row editor has a row to insert ''' 451 | try: 452 | self.database.insert(message.tablename, message.values) 453 | except sqlite3.Error: 454 | pass # TODO: show error message 455 | else: 456 | if self.currenttable: 457 | columns, rows = self.database.table_data(self.currenttable) 458 | table = self.query_one('#dbtable', DbTableEdit) 459 | table.clear(columns=True) 460 | table.add_columns(*columns) 461 | table.add_rows(sanitize_table(rows)) 462 | table.focus() 463 | 464 | def on_input_submitted(self, event: Input.Submitted) -> None: 465 | ''' The SQL query was submitted ''' 466 | try: 467 | table = self.query_one('#queryoutput', DataTable) 468 | except NoMatches: 469 | return 470 | 471 | query = event.value 472 | table.clear(columns=True) 473 | try: 474 | columns, result = self.database.query(query) 475 | except sqlite3.OperationalError as err: 476 | columns = ['Error',] 477 | result = [[str(err)]] 478 | 479 | table.add_columns(*columns) 480 | table.add_rows(sanitize_table(result)) 481 | 482 | def action_toggle_dark(self) -> None: 483 | ''' Dark mode ''' 484 | self.dark = not self.dark 485 | 486 | def on_open_db_fileopen(self, message: OpenDb.Fileopen) -> None: 487 | ''' OpenDb wants to open a database to load ''' 488 | self.pop_screen() 489 | if not self.load_database(message.path): 490 | self.push_screen('opendb') 491 | 492 | 493 | def main(): 494 | if len(sys.argv) <= 1: 495 | dbpath = None 496 | else: 497 | dbpath = sys.argv[1] 498 | 499 | app = Sqint(dbpath) 500 | app.run() 501 | 502 | 503 | if __name__ == "__main__": 504 | main() 505 | --------------------------------------------------------------------------------