databased.dbshell

  1from datetime import datetime
  2
  3import argshell
  4from griddle import griddy
  5from noiftimer import time_it
  6from pathier import Pathier, Pathish
  7
  8from databased import Databased, __version__, dbparsers
  9from databased.create_shell import create_shell
 10
 11
 12class DBShell(argshell.ArgShell):
 13    _dbpath: Pathier = None  # type: ignore
 14    connection_timeout: float = 10
 15    detect_types: bool = True
 16    enforce_foreign_keys: bool = True
 17    intro = f"Starting dbshell v{__version__} (enter help or ? for arg info)...\n"
 18    prompt = f"based>"
 19
 20    @property
 21    def dbpath(self) -> Pathier:
 22        return self._dbpath
 23
 24    @dbpath.setter
 25    def dbpath(self, path: Pathish):
 26        self._dbpath = Pathier(path)
 27        self.prompt = f"{self._dbpath.name}>"
 28
 29    def _DB(self) -> Databased:
 30        return Databased(
 31            self.dbpath,
 32            self.connection_timeout,
 33            self.detect_types,
 34            self.enforce_foreign_keys,
 35        )
 36
 37    @time_it()
 38    def default(self, line: str):
 39        line = line.strip("_")
 40        with self._DB() as db:
 41            self.display(db.query(line))
 42
 43    def display(self, data: list[dict]):
 44        """Print row data to terminal in a grid."""
 45        try:
 46            print(griddy(data, "keys"))
 47        except Exception as e:
 48            print("Could not fit data into grid :(")
 49            print(e)
 50
 51    # Seat
 52
 53    def _show_tables(self, args: argshell.Namespace):
 54        with self._DB() as db:
 55            if args.tables:
 56                tables = [table for table in args.tables if table in db.tables]
 57            else:
 58                tables = db.tables
 59            if tables:
 60                print("Getting database tables...")
 61                info = [
 62                    {
 63                        "Table Name": table,
 64                        "Columns": ", ".join(db.get_columns(table)),
 65                        "Number of Rows": db.count(table) if args.rowcount else "n/a",
 66                    }
 67                    for table in tables
 68                ]
 69                self.display(info)
 70
 71    def _show_views(self, args: argshell.Namespace):
 72        with self._DB() as db:
 73            if args.tables:
 74                views = [view for view in args.tables if view in db.views]
 75            else:
 76                views = db.views
 77            if views:
 78                print("Getting database views...")
 79                info = [
 80                    {
 81                        "View Name": view,
 82                        "Columns": ", ".join(db.get_columns(view)),
 83                        "Number of Rows": db.count(view) if args.rowcount else "n/a",
 84                    }
 85                    for view in views
 86                ]
 87                self.display(info)
 88
 89    @argshell.with_parser(dbparsers.get_add_column_parser)
 90    def do_add_column(self, args: argshell.Namespace):
 91        """Add a new column to the specified tables."""
 92        with self._DB() as db:
 93            db.add_column(args.table, args.column_def)
 94
 95    @argshell.with_parser(dbparsers.get_add_table_parser)
 96    def do_add_table(self, args: argshell.Namespace):
 97        """Add a new table to the database."""
 98        with self._DB() as db:
 99            db.create_table(args.table, *args.columns)
100
101    @argshell.with_parser(dbparsers.get_backup_parser)
102    @time_it()
103    def do_backup(self, args: argshell.Namespace):
104        """Create a backup of the current db file."""
105        print(f"Creating a back up for {self.dbpath}...")
106        backup_path = self.dbpath.backup(args.timestamp)
107        print("Creating backup is complete.")
108        print(f"Backup path: {backup_path}")
109
110    @argshell.with_parser(dbparsers.get_count_parser)
111    @time_it()
112    def do_count(self, args: argshell.Namespace):
113        """Count the number of matching records."""
114        with self._DB() as db:
115            count = db.count(args.table, args.column, args.where, args.distinct)
116            self.display(
117                [
118                    {
119                        "Table": args.table,
120                        "Column": args.column,
121                        "Distinct": args.distinct,
122                        "Where": args.where,
123                        "Count": count,
124                    }
125                ]
126            )
127
128    def do_customize(self, name: str):
129        """Generate a template file in the current working directory for creating a custom DBShell class.
130        Expects one argument: the name of the custom dbshell.
131        This will be used to name the generated file as well as several components in the file content.
132        """
133        try:
134            create_shell(name)
135        except Exception as e:
136            print(f"{type(e).__name__}: {e}")
137
138    def do_dbpath(self, _: str):
139        """Print the .db file in use."""
140        print(self.dbpath)
141
142    @argshell.with_parser(dbparsers.get_delete_parser)
143    @time_it()
144    def do_delete(self, args: argshell.Namespace):
145        """Delete rows from the database.
146
147        Syntax:
148        >>> delete {table} {where}
149        >>> based>delete users "username LIKE '%chungus%"
150
151        ^will delete all rows in the 'users' table whose username contains 'chungus'^"""
152        print("Deleting records...")
153        with self._DB() as db:
154            num_rows = db.delete(args.table, args.where)
155            print(f"Deleted {num_rows} rows from {args.table} table.")
156
157    def do_describe(self, tables: str):
158        """Describe each given table or view. If no list is given, all tables and views will be described."""
159        with self._DB() as db:
160            table_list = tables.split() or (db.tables + db.views)
161            for table in table_list:
162                print(f"<{table}>")
163                print(db.to_grid(db.describe(table)))
164
165    @argshell.with_parser(dbparsers.get_drop_column_parser)
166    def do_drop_column(self, args: argshell.Namespace):
167        """Drop the specified column from the specified table."""
168        with self._DB() as db:
169            db.drop_column(args.table, args.column)
170
171    def do_drop_table(self, table: str):
172        """Drop the specified table."""
173        with self._DB() as db:
174            db.drop_table(table)
175
176    @argshell.with_parser(dbparsers.get_dump_parser)
177    @time_it()
178    def do_dump(self, args: argshell.Namespace):
179        """Create `.sql` dump files for the current database."""
180        date = datetime.now().strftime("%m_%d_%Y_%H_%M_%S")
181        if not args.data_only:
182            print("Dumping schema...")
183            with self._DB() as db:
184                db.dump_schema(
185                    Pathier.cwd() / f"{db.name}_schema_{date}.sql", args.tables
186                )
187        if not args.schema_only:
188            print("Dumping data...")
189            with self._DB() as db:
190                db.dump_data(Pathier.cwd() / f"{db.name}_data_{date}.sql", args.tables)
191
192    def do_flush_log(self, _: str):
193        """Clear the log file for this database."""
194        log_path = self.dbpath.with_name(self.dbpath.name.replace(".", "") + ".log")
195        if not log_path.exists():
196            print(f"No log file at path {log_path}")
197        else:
198            print(f"Flushing log...")
199            log_path.write_text("")
200
201    def do_help(self, args: str):
202        """Display help messages."""
203        super().do_help(args)
204        if args == "":
205            print("Unrecognized commands will be executed as queries.")
206            print(
207                "Use the `query` command explicitly if you don't want to capitalize your key words."
208            )
209            print("All transactions initiated by commands are committed immediately.")
210        print()
211
212    def do_new_db(self, dbname: str):
213        """Create a new, empty database with the given name."""
214        dbpath = Pathier(dbname)
215        self.dbpath = dbpath
216        self.prompt = f"{self.dbpath.name}>"
217
218    def do_properties(self, _: str):
219        """See current database property settings."""
220        for property_ in ["connection_timeout", "detect_types", "enforce_foreign_keys"]:
221            print(f"{property_}: {getattr(self, property_)}")
222
223    @time_it()
224    def do_query(self, query: str):
225        """Execute a query against the current database."""
226        print(f"Executing {query}")
227        with self._DB() as db:
228            results = db.query(query)
229        self.display(results)
230        print(f"{db.cursor.rowcount} affected rows")
231
232    @argshell.with_parser(dbparsers.get_rename_column_parser)
233    def do_rename_column(self, args: argshell.Namespace):
234        """Rename a column."""
235        with self._DB() as db:
236            db.rename_column(args.table, args.column, args.new_name)
237
238    @argshell.with_parser(dbparsers.get_rename_table_parser)
239    def do_rename_table(self, args: argshell.Namespace):
240        """Rename a table."""
241        with self._DB() as db:
242            db.rename_table(args.table, args.new_name)
243
244    def do_restore(self, file: str):
245        """Replace the current db file with the given db backup file."""
246        backup = Pathier(file.strip('"'))
247        if not backup.exists():
248            print(f"{backup} does not exist.")
249        else:
250            print(f"Restoring from {file}...")
251            self.dbpath.write_bytes(backup.read_bytes())
252            print("Restore complete.")
253
254    @argshell.with_parser(dbparsers.get_scan_dbs_parser)
255    def do_scan(self, args: argshell.Namespace):
256        """Scan the current working directory for database files."""
257        dbs = self._scan(args.extensions, args.recursive)
258        for db in dbs:
259            print(db.separate(Pathier.cwd().stem))
260
261    @argshell.with_parser(dbparsers.get_schema_parser)
262    @time_it()
263    def do_schema(self, args: argshell.Namespace):
264        """Print out the names of the database tables and views, their columns, and, optionally, the number of rows."""
265        self._show_tables(args)
266        self._show_views(args)
267
268    @time_it()
269    def do_script(self, path: str):
270        """Execute the given SQL script."""
271        with self._DB() as db:
272            self.display(db.execute_script(path))
273
274    @argshell.with_parser(dbparsers.get_select_parser, [dbparsers.select_post_parser])
275    @time_it()
276    def do_select(self, args: argshell.Namespace):
277        """Execute a SELECT query with the given args."""
278        print(f"Querying {args.table}... ")
279        with self._DB() as db:
280            rows = db.select(
281                table=args.table,
282                columns=args.columns,
283                joins=args.joins,
284                where=args.where,
285                group_by=args.group_by,
286                having=args.Having,
287                order_by=args.order_by,
288                limit=args.limit,
289            )
290            print(f"Found {len(rows)} rows:")
291            self.display(rows)
292            print(f"{len(rows)} rows from {args.table}")
293
294    def do_set_connection_timeout(self, seconds: str):
295        """Set database connection timeout to this number of seconds."""
296        self.connection_timeout = float(seconds)
297
298    def do_set_detect_types(self, should_detect: str):
299        """Pass a `1` to turn on and a `0` to turn off."""
300        self.detect_types = bool(int(should_detect))
301
302    def do_set_enforce_foreign_keys(self, should_enforce: str):
303        """Pass a `1` to turn on and a `0` to turn off."""
304        self.enforce_foreign_keys = bool(int(should_enforce))
305
306    def do_size(self, _: str):
307        """Display the size of the the current db file."""
308        print(f"{self.dbpath.name} is {self.dbpath.formatted_size}.")
309
310    @argshell.with_parser(dbparsers.get_schema_parser)
311    @time_it()
312    def do_tables(self, args: argshell.Namespace):
313        """Print out the names of the database tables, their columns, and, optionally, the number of rows."""
314        self._show_tables(args)
315
316    @argshell.with_parser(dbparsers.get_update_parser)
317    @time_it()
318    def do_update(self, args: argshell.Namespace):
319        """Update a column to a new value.
320
321        Syntax:
322        >>> update {table} {column} {value} {where}
323        >>> based>update users username big_chungus "username = lil_chungus"
324
325        ^will update the username in the users 'table' to 'big_chungus' where the username is currently 'lil_chungus'^
326        """
327        print("Updating rows...")
328        with self._DB() as db:
329            num_updates = db.update(args.table, args.column, args.new_value, args.where)
330            print(f"Updated {num_updates} rows in table {args.table}.")
331
332    def do_use(self, dbname: str):
333        """Set which database file to use."""
334        dbpath = Pathier(dbname)
335        if not dbpath.exists():
336            print(f"{dbpath} does not exist.")
337            print(f"Still using {self.dbpath}")
338        elif not dbpath.is_file():
339            print(f"{dbpath} is not a file.")
340            print(f"Still using {self.dbpath}")
341        else:
342            self.dbpath = dbpath
343            self.prompt = f"{self.dbpath.name}>"
344
345    @time_it()
346    def do_vacuum(self, _: str):
347        """Reduce database disk memory."""
348        print(f"Database size before vacuuming: {self.dbpath.formatted_size}")
349        print("Vacuuming database...")
350        with self._DB() as db:
351            freedspace = db.vacuum()
352        print(f"Database size after vacuuming: {self.dbpath.formatted_size}")
353        print(f"Freed up {Pathier.format_bytes(freedspace)} of disk space.")
354
355    @argshell.with_parser(dbparsers.get_schema_parser)
356    @time_it()
357    def do_views(self, args: argshell.Namespace):
358        """Print out the names of the database views, their columns, and, optionally, the number of rows."""
359        self._show_views(args)
360
361    # Seat
362
363    def _choose_db(self, options: list[Pathier]) -> Pathier:
364        """Prompt the user to select from a list of files."""
365        cwd = Pathier.cwd()
366        paths = [path.separate(cwd.stem) for path in options]
367        while True:
368            print(
369                f"DB options:\n{' '.join([f'({i}) {path}' for i, path in enumerate(paths, 1)])}"
370            )
371            choice = input("Enter the number of the option to use: ")
372            try:
373                index = int(choice)
374                if not 1 <= index <= len(options):
375                    print("Choice out of range.")
376                    continue
377                return options[index - 1]
378            except Exception as e:
379                print(f"{choice} is not a valid option.")
380
381    def _scan(
382        self, extensions: list[str] = [".sqlite3", ".db"], recursive: bool = False
383    ) -> list[Pathier]:
384        cwd = Pathier.cwd()
385        dbs = []
386        globber = cwd.glob
387        if recursive:
388            globber = cwd.rglob
389        for extension in extensions:
390            dbs.extend(list(globber(f"*{extension}")))
391        return dbs
392
393    def preloop(self):
394        """Scan the current directory for a .db file to use.
395        If not found, prompt the user for one or to try again recursively."""
396        if self.dbpath:
397            self.dbpath = Pathier(self.dbpath)
398            print(f"Defaulting to database {self.dbpath}")
399        else:
400            print("Searching for database...")
401            cwd = Pathier.cwd()
402            dbs = self._scan()
403            if len(dbs) == 1:
404                self.dbpath = dbs[0]
405                print(f"Using database {self.dbpath}.")
406            elif dbs:
407                self.dbpath = self._choose_db(dbs)
408            else:
409                print(f"Could not find a .db file in {cwd}.")
410                path = input(
411                    "Enter path to .db file to use or press enter to search again recursively: "
412                )
413                if path:
414                    self.dbpath = Pathier(path)
415                elif not path:
416                    print("Searching recursively...")
417                    dbs = self._scan(recursive=True)
418                    if len(dbs) == 1:
419                        self.dbpath = dbs[0]
420                        print(f"Using database {self.dbpath}.")
421                    elif dbs:
422                        self.dbpath = self._choose_db(dbs)
423                    else:
424                        print("Could not find a .db file.")
425                        self.dbpath = Pathier(input("Enter path to a .db file: "))
426        if not self.dbpath.exists():
427            raise FileNotFoundError(f"{self.dbpath} does not exist.")
428        if not self.dbpath.is_file():
429            raise ValueError(f"{self.dbpath} is not a file.")
430
431
432def main():
433    DBShell().cmdloop()
class DBShell(argshell.argshell.ArgShell):
 13class DBShell(argshell.ArgShell):
 14    _dbpath: Pathier = None  # type: ignore
 15    connection_timeout: float = 10
 16    detect_types: bool = True
 17    enforce_foreign_keys: bool = True
 18    intro = f"Starting dbshell v{__version__} (enter help or ? for arg info)...\n"
 19    prompt = f"based>"
 20
 21    @property
 22    def dbpath(self) -> Pathier:
 23        return self._dbpath
 24
 25    @dbpath.setter
 26    def dbpath(self, path: Pathish):
 27        self._dbpath = Pathier(path)
 28        self.prompt = f"{self._dbpath.name}>"
 29
 30    def _DB(self) -> Databased:
 31        return Databased(
 32            self.dbpath,
 33            self.connection_timeout,
 34            self.detect_types,
 35            self.enforce_foreign_keys,
 36        )
 37
 38    @time_it()
 39    def default(self, line: str):
 40        line = line.strip("_")
 41        with self._DB() as db:
 42            self.display(db.query(line))
 43
 44    def display(self, data: list[dict]):
 45        """Print row data to terminal in a grid."""
 46        try:
 47            print(griddy(data, "keys"))
 48        except Exception as e:
 49            print("Could not fit data into grid :(")
 50            print(e)
 51
 52    # Seat
 53
 54    def _show_tables(self, args: argshell.Namespace):
 55        with self._DB() as db:
 56            if args.tables:
 57                tables = [table for table in args.tables if table in db.tables]
 58            else:
 59                tables = db.tables
 60            if tables:
 61                print("Getting database tables...")
 62                info = [
 63                    {
 64                        "Table Name": table,
 65                        "Columns": ", ".join(db.get_columns(table)),
 66                        "Number of Rows": db.count(table) if args.rowcount else "n/a",
 67                    }
 68                    for table in tables
 69                ]
 70                self.display(info)
 71
 72    def _show_views(self, args: argshell.Namespace):
 73        with self._DB() as db:
 74            if args.tables:
 75                views = [view for view in args.tables if view in db.views]
 76            else:
 77                views = db.views
 78            if views:
 79                print("Getting database views...")
 80                info = [
 81                    {
 82                        "View Name": view,
 83                        "Columns": ", ".join(db.get_columns(view)),
 84                        "Number of Rows": db.count(view) if args.rowcount else "n/a",
 85                    }
 86                    for view in views
 87                ]
 88                self.display(info)
 89
 90    @argshell.with_parser(dbparsers.get_add_column_parser)
 91    def do_add_column(self, args: argshell.Namespace):
 92        """Add a new column to the specified tables."""
 93        with self._DB() as db:
 94            db.add_column(args.table, args.column_def)
 95
 96    @argshell.with_parser(dbparsers.get_add_table_parser)
 97    def do_add_table(self, args: argshell.Namespace):
 98        """Add a new table to the database."""
 99        with self._DB() as db:
100            db.create_table(args.table, *args.columns)
101
102    @argshell.with_parser(dbparsers.get_backup_parser)
103    @time_it()
104    def do_backup(self, args: argshell.Namespace):
105        """Create a backup of the current db file."""
106        print(f"Creating a back up for {self.dbpath}...")
107        backup_path = self.dbpath.backup(args.timestamp)
108        print("Creating backup is complete.")
109        print(f"Backup path: {backup_path}")
110
111    @argshell.with_parser(dbparsers.get_count_parser)
112    @time_it()
113    def do_count(self, args: argshell.Namespace):
114        """Count the number of matching records."""
115        with self._DB() as db:
116            count = db.count(args.table, args.column, args.where, args.distinct)
117            self.display(
118                [
119                    {
120                        "Table": args.table,
121                        "Column": args.column,
122                        "Distinct": args.distinct,
123                        "Where": args.where,
124                        "Count": count,
125                    }
126                ]
127            )
128
129    def do_customize(self, name: str):
130        """Generate a template file in the current working directory for creating a custom DBShell class.
131        Expects one argument: the name of the custom dbshell.
132        This will be used to name the generated file as well as several components in the file content.
133        """
134        try:
135            create_shell(name)
136        except Exception as e:
137            print(f"{type(e).__name__}: {e}")
138
139    def do_dbpath(self, _: str):
140        """Print the .db file in use."""
141        print(self.dbpath)
142
143    @argshell.with_parser(dbparsers.get_delete_parser)
144    @time_it()
145    def do_delete(self, args: argshell.Namespace):
146        """Delete rows from the database.
147
148        Syntax:
149        >>> delete {table} {where}
150        >>> based>delete users "username LIKE '%chungus%"
151
152        ^will delete all rows in the 'users' table whose username contains 'chungus'^"""
153        print("Deleting records...")
154        with self._DB() as db:
155            num_rows = db.delete(args.table, args.where)
156            print(f"Deleted {num_rows} rows from {args.table} table.")
157
158    def do_describe(self, tables: str):
159        """Describe each given table or view. If no list is given, all tables and views will be described."""
160        with self._DB() as db:
161            table_list = tables.split() or (db.tables + db.views)
162            for table in table_list:
163                print(f"<{table}>")
164                print(db.to_grid(db.describe(table)))
165
166    @argshell.with_parser(dbparsers.get_drop_column_parser)
167    def do_drop_column(self, args: argshell.Namespace):
168        """Drop the specified column from the specified table."""
169        with self._DB() as db:
170            db.drop_column(args.table, args.column)
171
172    def do_drop_table(self, table: str):
173        """Drop the specified table."""
174        with self._DB() as db:
175            db.drop_table(table)
176
177    @argshell.with_parser(dbparsers.get_dump_parser)
178    @time_it()
179    def do_dump(self, args: argshell.Namespace):
180        """Create `.sql` dump files for the current database."""
181        date = datetime.now().strftime("%m_%d_%Y_%H_%M_%S")
182        if not args.data_only:
183            print("Dumping schema...")
184            with self._DB() as db:
185                db.dump_schema(
186                    Pathier.cwd() / f"{db.name}_schema_{date}.sql", args.tables
187                )
188        if not args.schema_only:
189            print("Dumping data...")
190            with self._DB() as db:
191                db.dump_data(Pathier.cwd() / f"{db.name}_data_{date}.sql", args.tables)
192
193    def do_flush_log(self, _: str):
194        """Clear the log file for this database."""
195        log_path = self.dbpath.with_name(self.dbpath.name.replace(".", "") + ".log")
196        if not log_path.exists():
197            print(f"No log file at path {log_path}")
198        else:
199            print(f"Flushing log...")
200            log_path.write_text("")
201
202    def do_help(self, args: str):
203        """Display help messages."""
204        super().do_help(args)
205        if args == "":
206            print("Unrecognized commands will be executed as queries.")
207            print(
208                "Use the `query` command explicitly if you don't want to capitalize your key words."
209            )
210            print("All transactions initiated by commands are committed immediately.")
211        print()
212
213    def do_new_db(self, dbname: str):
214        """Create a new, empty database with the given name."""
215        dbpath = Pathier(dbname)
216        self.dbpath = dbpath
217        self.prompt = f"{self.dbpath.name}>"
218
219    def do_properties(self, _: str):
220        """See current database property settings."""
221        for property_ in ["connection_timeout", "detect_types", "enforce_foreign_keys"]:
222            print(f"{property_}: {getattr(self, property_)}")
223
224    @time_it()
225    def do_query(self, query: str):
226        """Execute a query against the current database."""
227        print(f"Executing {query}")
228        with self._DB() as db:
229            results = db.query(query)
230        self.display(results)
231        print(f"{db.cursor.rowcount} affected rows")
232
233    @argshell.with_parser(dbparsers.get_rename_column_parser)
234    def do_rename_column(self, args: argshell.Namespace):
235        """Rename a column."""
236        with self._DB() as db:
237            db.rename_column(args.table, args.column, args.new_name)
238
239    @argshell.with_parser(dbparsers.get_rename_table_parser)
240    def do_rename_table(self, args: argshell.Namespace):
241        """Rename a table."""
242        with self._DB() as db:
243            db.rename_table(args.table, args.new_name)
244
245    def do_restore(self, file: str):
246        """Replace the current db file with the given db backup file."""
247        backup = Pathier(file.strip('"'))
248        if not backup.exists():
249            print(f"{backup} does not exist.")
250        else:
251            print(f"Restoring from {file}...")
252            self.dbpath.write_bytes(backup.read_bytes())
253            print("Restore complete.")
254
255    @argshell.with_parser(dbparsers.get_scan_dbs_parser)
256    def do_scan(self, args: argshell.Namespace):
257        """Scan the current working directory for database files."""
258        dbs = self._scan(args.extensions, args.recursive)
259        for db in dbs:
260            print(db.separate(Pathier.cwd().stem))
261
262    @argshell.with_parser(dbparsers.get_schema_parser)
263    @time_it()
264    def do_schema(self, args: argshell.Namespace):
265        """Print out the names of the database tables and views, their columns, and, optionally, the number of rows."""
266        self._show_tables(args)
267        self._show_views(args)
268
269    @time_it()
270    def do_script(self, path: str):
271        """Execute the given SQL script."""
272        with self._DB() as db:
273            self.display(db.execute_script(path))
274
275    @argshell.with_parser(dbparsers.get_select_parser, [dbparsers.select_post_parser])
276    @time_it()
277    def do_select(self, args: argshell.Namespace):
278        """Execute a SELECT query with the given args."""
279        print(f"Querying {args.table}... ")
280        with self._DB() as db:
281            rows = db.select(
282                table=args.table,
283                columns=args.columns,
284                joins=args.joins,
285                where=args.where,
286                group_by=args.group_by,
287                having=args.Having,
288                order_by=args.order_by,
289                limit=args.limit,
290            )
291            print(f"Found {len(rows)} rows:")
292            self.display(rows)
293            print(f"{len(rows)} rows from {args.table}")
294
295    def do_set_connection_timeout(self, seconds: str):
296        """Set database connection timeout to this number of seconds."""
297        self.connection_timeout = float(seconds)
298
299    def do_set_detect_types(self, should_detect: str):
300        """Pass a `1` to turn on and a `0` to turn off."""
301        self.detect_types = bool(int(should_detect))
302
303    def do_set_enforce_foreign_keys(self, should_enforce: str):
304        """Pass a `1` to turn on and a `0` to turn off."""
305        self.enforce_foreign_keys = bool(int(should_enforce))
306
307    def do_size(self, _: str):
308        """Display the size of the the current db file."""
309        print(f"{self.dbpath.name} is {self.dbpath.formatted_size}.")
310
311    @argshell.with_parser(dbparsers.get_schema_parser)
312    @time_it()
313    def do_tables(self, args: argshell.Namespace):
314        """Print out the names of the database tables, their columns, and, optionally, the number of rows."""
315        self._show_tables(args)
316
317    @argshell.with_parser(dbparsers.get_update_parser)
318    @time_it()
319    def do_update(self, args: argshell.Namespace):
320        """Update a column to a new value.
321
322        Syntax:
323        >>> update {table} {column} {value} {where}
324        >>> based>update users username big_chungus "username = lil_chungus"
325
326        ^will update the username in the users 'table' to 'big_chungus' where the username is currently 'lil_chungus'^
327        """
328        print("Updating rows...")
329        with self._DB() as db:
330            num_updates = db.update(args.table, args.column, args.new_value, args.where)
331            print(f"Updated {num_updates} rows in table {args.table}.")
332
333    def do_use(self, dbname: str):
334        """Set which database file to use."""
335        dbpath = Pathier(dbname)
336        if not dbpath.exists():
337            print(f"{dbpath} does not exist.")
338            print(f"Still using {self.dbpath}")
339        elif not dbpath.is_file():
340            print(f"{dbpath} is not a file.")
341            print(f"Still using {self.dbpath}")
342        else:
343            self.dbpath = dbpath
344            self.prompt = f"{self.dbpath.name}>"
345
346    @time_it()
347    def do_vacuum(self, _: str):
348        """Reduce database disk memory."""
349        print(f"Database size before vacuuming: {self.dbpath.formatted_size}")
350        print("Vacuuming database...")
351        with self._DB() as db:
352            freedspace = db.vacuum()
353        print(f"Database size after vacuuming: {self.dbpath.formatted_size}")
354        print(f"Freed up {Pathier.format_bytes(freedspace)} of disk space.")
355
356    @argshell.with_parser(dbparsers.get_schema_parser)
357    @time_it()
358    def do_views(self, args: argshell.Namespace):
359        """Print out the names of the database views, their columns, and, optionally, the number of rows."""
360        self._show_views(args)
361
362    # Seat
363
364    def _choose_db(self, options: list[Pathier]) -> Pathier:
365        """Prompt the user to select from a list of files."""
366        cwd = Pathier.cwd()
367        paths = [path.separate(cwd.stem) for path in options]
368        while True:
369            print(
370                f"DB options:\n{' '.join([f'({i}) {path}' for i, path in enumerate(paths, 1)])}"
371            )
372            choice = input("Enter the number of the option to use: ")
373            try:
374                index = int(choice)
375                if not 1 <= index <= len(options):
376                    print("Choice out of range.")
377                    continue
378                return options[index - 1]
379            except Exception as e:
380                print(f"{choice} is not a valid option.")
381
382    def _scan(
383        self, extensions: list[str] = [".sqlite3", ".db"], recursive: bool = False
384    ) -> list[Pathier]:
385        cwd = Pathier.cwd()
386        dbs = []
387        globber = cwd.glob
388        if recursive:
389            globber = cwd.rglob
390        for extension in extensions:
391            dbs.extend(list(globber(f"*{extension}")))
392        return dbs
393
394    def preloop(self):
395        """Scan the current directory for a .db file to use.
396        If not found, prompt the user for one or to try again recursively."""
397        if self.dbpath:
398            self.dbpath = Pathier(self.dbpath)
399            print(f"Defaulting to database {self.dbpath}")
400        else:
401            print("Searching for database...")
402            cwd = Pathier.cwd()
403            dbs = self._scan()
404            if len(dbs) == 1:
405                self.dbpath = dbs[0]
406                print(f"Using database {self.dbpath}.")
407            elif dbs:
408                self.dbpath = self._choose_db(dbs)
409            else:
410                print(f"Could not find a .db file in {cwd}.")
411                path = input(
412                    "Enter path to .db file to use or press enter to search again recursively: "
413                )
414                if path:
415                    self.dbpath = Pathier(path)
416                elif not path:
417                    print("Searching recursively...")
418                    dbs = self._scan(recursive=True)
419                    if len(dbs) == 1:
420                        self.dbpath = dbs[0]
421                        print(f"Using database {self.dbpath}.")
422                    elif dbs:
423                        self.dbpath = self._choose_db(dbs)
424                    else:
425                        print("Could not find a .db file.")
426                        self.dbpath = Pathier(input("Enter path to a .db file: "))
427        if not self.dbpath.exists():
428            raise FileNotFoundError(f"{self.dbpath} does not exist.")
429        if not self.dbpath.is_file():
430            raise ValueError(f"{self.dbpath} is not a file.")

Subclass this to create custom ArgShells.

@time_it()
def default(self, line: str):
38    @time_it()
39    def default(self, line: str):
40        line = line.strip("_")
41        with self._DB() as db:
42            self.display(db.query(line))

Called on an input line when the command prefix is not recognized.

If this method is not overridden, it prints an error message and returns.

def display(self, data: list[dict]):
44    def display(self, data: list[dict]):
45        """Print row data to terminal in a grid."""
46        try:
47            print(griddy(data, "keys"))
48        except Exception as e:
49            print("Could not fit data into grid :(")
50            print(e)

Print row data to terminal in a grid.

@argshell.with_parser(dbparsers.get_add_column_parser)
def do_add_column(self, args: argshell.argshell.Namespace):
90    @argshell.with_parser(dbparsers.get_add_column_parser)
91    def do_add_column(self, args: argshell.Namespace):
92        """Add a new column to the specified tables."""
93        with self._DB() as db:
94            db.add_column(args.table, args.column_def)

Add a new column to the specified tables.

@argshell.with_parser(dbparsers.get_add_table_parser)
def do_add_table(self, args: argshell.argshell.Namespace):
 96    @argshell.with_parser(dbparsers.get_add_table_parser)
 97    def do_add_table(self, args: argshell.Namespace):
 98        """Add a new table to the database."""
 99        with self._DB() as db:
100            db.create_table(args.table, *args.columns)

Add a new table to the database.

@argshell.with_parser(dbparsers.get_backup_parser)
@time_it()
def do_backup(self, args: argshell.argshell.Namespace):
102    @argshell.with_parser(dbparsers.get_backup_parser)
103    @time_it()
104    def do_backup(self, args: argshell.Namespace):
105        """Create a backup of the current db file."""
106        print(f"Creating a back up for {self.dbpath}...")
107        backup_path = self.dbpath.backup(args.timestamp)
108        print("Creating backup is complete.")
109        print(f"Backup path: {backup_path}")

Create a backup of the current db file.

@argshell.with_parser(dbparsers.get_count_parser)
@time_it()
def do_count(self, args: argshell.argshell.Namespace):
111    @argshell.with_parser(dbparsers.get_count_parser)
112    @time_it()
113    def do_count(self, args: argshell.Namespace):
114        """Count the number of matching records."""
115        with self._DB() as db:
116            count = db.count(args.table, args.column, args.where, args.distinct)
117            self.display(
118                [
119                    {
120                        "Table": args.table,
121                        "Column": args.column,
122                        "Distinct": args.distinct,
123                        "Where": args.where,
124                        "Count": count,
125                    }
126                ]
127            )

Count the number of matching records.

def do_customize(self, name: str):
129    def do_customize(self, name: str):
130        """Generate a template file in the current working directory for creating a custom DBShell class.
131        Expects one argument: the name of the custom dbshell.
132        This will be used to name the generated file as well as several components in the file content.
133        """
134        try:
135            create_shell(name)
136        except Exception as e:
137            print(f"{type(e).__name__}: {e}")

Generate a template file in the current working directory for creating a custom DBShell class. Expects one argument: the name of the custom dbshell. This will be used to name the generated file as well as several components in the file content.

def do_dbpath(self, _: str):
139    def do_dbpath(self, _: str):
140        """Print the .db file in use."""
141        print(self.dbpath)

Print the .db file in use.

@argshell.with_parser(dbparsers.get_delete_parser)
@time_it()
def do_delete(self, args: argshell.argshell.Namespace):
143    @argshell.with_parser(dbparsers.get_delete_parser)
144    @time_it()
145    def do_delete(self, args: argshell.Namespace):
146        """Delete rows from the database.
147
148        Syntax:
149        >>> delete {table} {where}
150        >>> based>delete users "username LIKE '%chungus%"
151
152        ^will delete all rows in the 'users' table whose username contains 'chungus'^"""
153        print("Deleting records...")
154        with self._DB() as db:
155            num_rows = db.delete(args.table, args.where)
156            print(f"Deleted {num_rows} rows from {args.table} table.")

Delete rows from the database.

Syntax:

>>> delete {table} {where}
>>> based>delete users "username LIKE '%chungus%"

^will delete all rows in the 'users' table whose username contains 'chungus'^

def do_describe(self, tables: str):
158    def do_describe(self, tables: str):
159        """Describe each given table or view. If no list is given, all tables and views will be described."""
160        with self._DB() as db:
161            table_list = tables.split() or (db.tables + db.views)
162            for table in table_list:
163                print(f"<{table}>")
164                print(db.to_grid(db.describe(table)))

Describe each given table or view. If no list is given, all tables and views will be described.

@argshell.with_parser(dbparsers.get_drop_column_parser)
def do_drop_column(self, args: argshell.argshell.Namespace):
166    @argshell.with_parser(dbparsers.get_drop_column_parser)
167    def do_drop_column(self, args: argshell.Namespace):
168        """Drop the specified column from the specified table."""
169        with self._DB() as db:
170            db.drop_column(args.table, args.column)

Drop the specified column from the specified table.

def do_drop_table(self, table: str):
172    def do_drop_table(self, table: str):
173        """Drop the specified table."""
174        with self._DB() as db:
175            db.drop_table(table)

Drop the specified table.

@argshell.with_parser(dbparsers.get_dump_parser)
@time_it()
def do_dump(self, args: argshell.argshell.Namespace):
177    @argshell.with_parser(dbparsers.get_dump_parser)
178    @time_it()
179    def do_dump(self, args: argshell.Namespace):
180        """Create `.sql` dump files for the current database."""
181        date = datetime.now().strftime("%m_%d_%Y_%H_%M_%S")
182        if not args.data_only:
183            print("Dumping schema...")
184            with self._DB() as db:
185                db.dump_schema(
186                    Pathier.cwd() / f"{db.name}_schema_{date}.sql", args.tables
187                )
188        if not args.schema_only:
189            print("Dumping data...")
190            with self._DB() as db:
191                db.dump_data(Pathier.cwd() / f"{db.name}_data_{date}.sql", args.tables)

Create .sql dump files for the current database.

def do_flush_log(self, _: str):
193    def do_flush_log(self, _: str):
194        """Clear the log file for this database."""
195        log_path = self.dbpath.with_name(self.dbpath.name.replace(".", "") + ".log")
196        if not log_path.exists():
197            print(f"No log file at path {log_path}")
198        else:
199            print(f"Flushing log...")
200            log_path.write_text("")

Clear the log file for this database.

def do_help(self, args: str):
202    def do_help(self, args: str):
203        """Display help messages."""
204        super().do_help(args)
205        if args == "":
206            print("Unrecognized commands will be executed as queries.")
207            print(
208                "Use the `query` command explicitly if you don't want to capitalize your key words."
209            )
210            print("All transactions initiated by commands are committed immediately.")
211        print()

Display help messages.

def do_new_db(self, dbname: str):
213    def do_new_db(self, dbname: str):
214        """Create a new, empty database with the given name."""
215        dbpath = Pathier(dbname)
216        self.dbpath = dbpath
217        self.prompt = f"{self.dbpath.name}>"

Create a new, empty database with the given name.

def do_properties(self, _: str):
219    def do_properties(self, _: str):
220        """See current database property settings."""
221        for property_ in ["connection_timeout", "detect_types", "enforce_foreign_keys"]:
222            print(f"{property_}: {getattr(self, property_)}")

See current database property settings.

@time_it()
def do_query(self, query: str):
224    @time_it()
225    def do_query(self, query: str):
226        """Execute a query against the current database."""
227        print(f"Executing {query}")
228        with self._DB() as db:
229            results = db.query(query)
230        self.display(results)
231        print(f"{db.cursor.rowcount} affected rows")

Execute a query against the current database.

@argshell.with_parser(dbparsers.get_rename_column_parser)
def do_rename_column(self, args: argshell.argshell.Namespace):
233    @argshell.with_parser(dbparsers.get_rename_column_parser)
234    def do_rename_column(self, args: argshell.Namespace):
235        """Rename a column."""
236        with self._DB() as db:
237            db.rename_column(args.table, args.column, args.new_name)

Rename a column.

@argshell.with_parser(dbparsers.get_rename_table_parser)
def do_rename_table(self, args: argshell.argshell.Namespace):
239    @argshell.with_parser(dbparsers.get_rename_table_parser)
240    def do_rename_table(self, args: argshell.Namespace):
241        """Rename a table."""
242        with self._DB() as db:
243            db.rename_table(args.table, args.new_name)

Rename a table.

def do_restore(self, file: str):
245    def do_restore(self, file: str):
246        """Replace the current db file with the given db backup file."""
247        backup = Pathier(file.strip('"'))
248        if not backup.exists():
249            print(f"{backup} does not exist.")
250        else:
251            print(f"Restoring from {file}...")
252            self.dbpath.write_bytes(backup.read_bytes())
253            print("Restore complete.")

Replace the current db file with the given db backup file.

@argshell.with_parser(dbparsers.get_scan_dbs_parser)
def do_scan(self, args: argshell.argshell.Namespace):
255    @argshell.with_parser(dbparsers.get_scan_dbs_parser)
256    def do_scan(self, args: argshell.Namespace):
257        """Scan the current working directory for database files."""
258        dbs = self._scan(args.extensions, args.recursive)
259        for db in dbs:
260            print(db.separate(Pathier.cwd().stem))

Scan the current working directory for database files.

@argshell.with_parser(dbparsers.get_schema_parser)
@time_it()
def do_schema(self, args: argshell.argshell.Namespace):
262    @argshell.with_parser(dbparsers.get_schema_parser)
263    @time_it()
264    def do_schema(self, args: argshell.Namespace):
265        """Print out the names of the database tables and views, their columns, and, optionally, the number of rows."""
266        self._show_tables(args)
267        self._show_views(args)

Print out the names of the database tables and views, their columns, and, optionally, the number of rows.

@time_it()
def do_script(self, path: str):
269    @time_it()
270    def do_script(self, path: str):
271        """Execute the given SQL script."""
272        with self._DB() as db:
273            self.display(db.execute_script(path))

Execute the given SQL script.

@argshell.with_parser(dbparsers.get_select_parser, [dbparsers.select_post_parser])
@time_it()
def do_select(self, args: argshell.argshell.Namespace):
275    @argshell.with_parser(dbparsers.get_select_parser, [dbparsers.select_post_parser])
276    @time_it()
277    def do_select(self, args: argshell.Namespace):
278        """Execute a SELECT query with the given args."""
279        print(f"Querying {args.table}... ")
280        with self._DB() as db:
281            rows = db.select(
282                table=args.table,
283                columns=args.columns,
284                joins=args.joins,
285                where=args.where,
286                group_by=args.group_by,
287                having=args.Having,
288                order_by=args.order_by,
289                limit=args.limit,
290            )
291            print(f"Found {len(rows)} rows:")
292            self.display(rows)
293            print(f"{len(rows)} rows from {args.table}")

Execute a SELECT query with the given args.

def do_set_connection_timeout(self, seconds: str):
295    def do_set_connection_timeout(self, seconds: str):
296        """Set database connection timeout to this number of seconds."""
297        self.connection_timeout = float(seconds)

Set database connection timeout to this number of seconds.

def do_set_detect_types(self, should_detect: str):
299    def do_set_detect_types(self, should_detect: str):
300        """Pass a `1` to turn on and a `0` to turn off."""
301        self.detect_types = bool(int(should_detect))

Pass a 1 to turn on and a 0 to turn off.

def do_set_enforce_foreign_keys(self, should_enforce: str):
303    def do_set_enforce_foreign_keys(self, should_enforce: str):
304        """Pass a `1` to turn on and a `0` to turn off."""
305        self.enforce_foreign_keys = bool(int(should_enforce))

Pass a 1 to turn on and a 0 to turn off.

def do_size(self, _: str):
307    def do_size(self, _: str):
308        """Display the size of the the current db file."""
309        print(f"{self.dbpath.name} is {self.dbpath.formatted_size}.")

Display the size of the the current db file.

@argshell.with_parser(dbparsers.get_schema_parser)
@time_it()
def do_tables(self, args: argshell.argshell.Namespace):
311    @argshell.with_parser(dbparsers.get_schema_parser)
312    @time_it()
313    def do_tables(self, args: argshell.Namespace):
314        """Print out the names of the database tables, their columns, and, optionally, the number of rows."""
315        self._show_tables(args)

Print out the names of the database tables, their columns, and, optionally, the number of rows.

@argshell.with_parser(dbparsers.get_update_parser)
@time_it()
def do_update(self, args: argshell.argshell.Namespace):
317    @argshell.with_parser(dbparsers.get_update_parser)
318    @time_it()
319    def do_update(self, args: argshell.Namespace):
320        """Update a column to a new value.
321
322        Syntax:
323        >>> update {table} {column} {value} {where}
324        >>> based>update users username big_chungus "username = lil_chungus"
325
326        ^will update the username in the users 'table' to 'big_chungus' where the username is currently 'lil_chungus'^
327        """
328        print("Updating rows...")
329        with self._DB() as db:
330            num_updates = db.update(args.table, args.column, args.new_value, args.where)
331            print(f"Updated {num_updates} rows in table {args.table}.")

Update a column to a new value.

Syntax:

>>> update {table} {column} {value} {where}
>>> based>update users username big_chungus "username = lil_chungus"

^will update the username in the users 'table' to 'big_chungus' where the username is currently 'lil_chungus'^

def do_use(self, dbname: str):
333    def do_use(self, dbname: str):
334        """Set which database file to use."""
335        dbpath = Pathier(dbname)
336        if not dbpath.exists():
337            print(f"{dbpath} does not exist.")
338            print(f"Still using {self.dbpath}")
339        elif not dbpath.is_file():
340            print(f"{dbpath} is not a file.")
341            print(f"Still using {self.dbpath}")
342        else:
343            self.dbpath = dbpath
344            self.prompt = f"{self.dbpath.name}>"

Set which database file to use.

@time_it()
def do_vacuum(self, _: str):
346    @time_it()
347    def do_vacuum(self, _: str):
348        """Reduce database disk memory."""
349        print(f"Database size before vacuuming: {self.dbpath.formatted_size}")
350        print("Vacuuming database...")
351        with self._DB() as db:
352            freedspace = db.vacuum()
353        print(f"Database size after vacuuming: {self.dbpath.formatted_size}")
354        print(f"Freed up {Pathier.format_bytes(freedspace)} of disk space.")

Reduce database disk memory.

@argshell.with_parser(dbparsers.get_schema_parser)
@time_it()
def do_views(self, args: argshell.argshell.Namespace):
356    @argshell.with_parser(dbparsers.get_schema_parser)
357    @time_it()
358    def do_views(self, args: argshell.Namespace):
359        """Print out the names of the database views, their columns, and, optionally, the number of rows."""
360        self._show_views(args)

Print out the names of the database views, their columns, and, optionally, the number of rows.

def preloop(self):
394    def preloop(self):
395        """Scan the current directory for a .db file to use.
396        If not found, prompt the user for one or to try again recursively."""
397        if self.dbpath:
398            self.dbpath = Pathier(self.dbpath)
399            print(f"Defaulting to database {self.dbpath}")
400        else:
401            print("Searching for database...")
402            cwd = Pathier.cwd()
403            dbs = self._scan()
404            if len(dbs) == 1:
405                self.dbpath = dbs[0]
406                print(f"Using database {self.dbpath}.")
407            elif dbs:
408                self.dbpath = self._choose_db(dbs)
409            else:
410                print(f"Could not find a .db file in {cwd}.")
411                path = input(
412                    "Enter path to .db file to use or press enter to search again recursively: "
413                )
414                if path:
415                    self.dbpath = Pathier(path)
416                elif not path:
417                    print("Searching recursively...")
418                    dbs = self._scan(recursive=True)
419                    if len(dbs) == 1:
420                        self.dbpath = dbs[0]
421                        print(f"Using database {self.dbpath}.")
422                    elif dbs:
423                        self.dbpath = self._choose_db(dbs)
424                    else:
425                        print("Could not find a .db file.")
426                        self.dbpath = Pathier(input("Enter path to a .db file: "))
427        if not self.dbpath.exists():
428            raise FileNotFoundError(f"{self.dbpath} does not exist.")
429        if not self.dbpath.is_file():
430            raise ValueError(f"{self.dbpath} is not a file.")

Scan the current directory for a .db file to use. If not found, prompt the user for one or to try again recursively.

Inherited Members
cmd.Cmd
Cmd
precmd
postcmd
postloop
parseline
onecmd
completedefault
completenames
complete
get_names
complete_help
print_topics
columnize
argshell.argshell.ArgShell
do_quit
do_sys
cmdloop
emptyline
def main():
433def main():
434    DBShell().cmdloop()