X-Git-Url: http://git.veekun.com/zzz-pokedex.git/blobdiff_plain/7d2a18f84eedde0f6fe76e1886c5cf1974dc1909..16dd9d8ac11b990125eb836d4d3f7b62c4b1c712:/pokedex/db/load.py diff --git a/pokedex/db/load.py b/pokedex/db/load.py index 7f8824a..f0e4b6d 100644 --- a/pokedex/db/load.py +++ b/pokedex/db/load.py @@ -1,50 +1,101 @@ """CSV to database or vice versa.""" import csv -import pkg_resources +import fnmatch +import os.path import sys from sqlalchemy.orm.attributes import instrumentation_registry +import sqlalchemy.sql.util import sqlalchemy.types from pokedex.db import metadata import pokedex.db.tables as tables +from pokedex.defaults import get_default_csv_dir +def _get_table_names(metadata, patterns): + """Returns a list of table names from the given metadata. If `patterns` + exists, only tables matching one of the patterns will be returned. + """ + if patterns: + table_names = set() + for pattern in patterns: + if '.' in pattern or '/' in pattern: + # If it looks like a filename, pull out just the table name + _, filename = os.path.split(pattern) + table_name, _ = os.path.splitext(filename) + pattern = table_name + + table_names.update(fnmatch.filter(metadata.tables.keys(), pattern)) + else: + table_names = metadata.tables.keys() + + return list(table_names) + def _get_verbose_prints(verbose): - """If `verbose` is true, returns two functions: one for printing a starting - message, and the other for printing a success or failure message when - finished. + """If `verbose` is true, returns three functions: one for printing a + starting message, one for printing an interim status update, and one for + printing a success or failure message when finished. - If `verbose` is false, returns two no-op functions. + If `verbose` is false, returns no-op functions. """ - if verbose: - import sys - def print_start(thing): - # Truncate to 66 characters, leaving 10 characters for a success - # or failure message - truncated_thing = thing[0:66] + if not verbose: + # Return dummies + def dummy(*args, **kwargs): + pass + + return dummy, dummy, dummy - # Also, space-pad to keep the cursor in a known column - num_spaces = 66 - len(truncated_thing) + ### Okay, verbose == True; print stuff - print "%s...%s" % (truncated_thing, ' ' * num_spaces), + def print_start(thing): + # Truncate to 66 characters, leaving 10 characters for a success + # or failure message + truncated_thing = thing[0:66] + + # Also, space-pad to keep the cursor in a known column + num_spaces = 66 - len(truncated_thing) + + print "%s...%s" % (truncated_thing, ' ' * num_spaces), + sys.stdout.flush() + + if sys.stdout.isatty(): + # stdout is a terminal; stupid backspace tricks are OK. + # Don't use print, because it always adds magical spaces, which + # makes backspace accounting harder + + backspaces = [0] + def print_status(msg): + # Overwrite any status text with spaces before printing + sys.stdout.write('\b' * backspaces[0]) + sys.stdout.write(' ' * backspaces[0]) + sys.stdout.write('\b' * backspaces[0]) + sys.stdout.write(msg) sys.stdout.flush() + backspaces[0] = len(msg) def print_done(msg='ok'): - print msg + # Overwrite any status text with spaces before printing + sys.stdout.write('\b' * backspaces[0]) + sys.stdout.write(' ' * backspaces[0]) + sys.stdout.write('\b' * backspaces[0]) + sys.stdout.write(msg + "\n") sys.stdout.flush() + backspaces[0] = 0 - return print_start, print_done + else: + # stdout is a file (or something); don't bother with status at all + def print_status(msg): + pass - # Not verbose; return dummies - def dummy(*args, **kwargs): - pass + def print_done(msg='ok'): + print msg - return dummy, dummy + return print_start, print_status, print_done -def load(session, directory=None, drop_tables=False, verbose=False): +def load(session, tables=[], directory=None, drop_tables=False, verbose=False, safe=True): """Load data from CSV files into the given database session. Tables are created automatically. @@ -52,6 +103,9 @@ def load(session, directory=None, drop_tables=False, verbose=False): `session` SQLAlchemy session to use. + `tables` + List of tables to load. If omitted, all tables are loaded. + `directory` Directory the CSV files reside in. Defaults to the `pokedex` data directory. @@ -61,63 +115,89 @@ def load(session, directory=None, drop_tables=False, verbose=False): `verbose` If set to True, status messages will be printed to stdout. + + `safe` + If set to False, load can be faster, but can corrupt the database if + it crashes or is interrupted. """ # First take care of verbosity - print_start, print_done = _get_verbose_prints(verbose) + print_start, print_status, print_done = _get_verbose_prints(verbose) - if not directory: - directory = pkg_resources.resource_filename('pokedex', 'data/csv') + if directory is None: + directory = get_default_csv_dir() + + table_names = _get_table_names(metadata, tables) + table_objs = [metadata.tables[name] for name in table_names] + table_objs = sqlalchemy.sql.util.sort_tables(table_objs) + + # SQLite speed tweaks + if not safe and session.connection().dialect.name == 'sqlite': + session.connection().execute("PRAGMA synchronous=OFF") + session.connection().execute("PRAGMA journal_mode=OFF") # Drop all tables if requested if drop_tables: print_start('Dropping tables') - metadata.drop_all() + for n, table in enumerate(reversed(table_objs)): + table.drop(checkfirst=True) + print_status('%s/%s' % (n, len(table_objs))) print_done() - metadata.create_all() - - # SQLAlchemy is retarded and there is no way for me to get a list of ORM - # classes besides to inspect the module they all happen to live in for - # things that look right. - table_base = tables.TableBase - orm_classes = {} # table object => table class - - for name in dir(tables): - # dir() returns strings! How /convenient/. - thingy = getattr(tables, name) - - if not isinstance(thingy, type): - # Not a class; bail - continue - elif not issubclass(thingy, table_base): - # Not a declarative table; bail - continue - elif thingy == table_base: - # Declarative table base, so not a real table; bail - continue - - # thingy is definitely a table class! Hallelujah. - orm_classes[thingy.__table__] = thingy + print_start('Creating tables') + for n, table in enumerate(table_objs): + table.create() + print_status('%s/%s' % (n, len(table_objs))) + print_done() + connection = session.connection() # Okay, run through the tables and actually load the data now - for table_obj in metadata.sorted_tables: - table_class = orm_classes[table_obj] + for table_obj in table_objs: table_name = table_obj.name + insert_stmt = table_obj.insert() print_start(table_name) try: - csvfile = open("%s/%s.csv" % (directory, table_name), 'rb') + csvpath = "%s/%s.csv" % (directory, table_name) + csvfile = open(csvpath, 'rb') except IOError: # File doesn't exist; don't load anything! print_done('missing?') continue + csvsize = os.stat(csvpath).st_size + reader = csv.reader(csvfile, lineterminator='\n') column_names = [unicode(column) for column in reader.next()] + if not safe and session.connection().dialect.name == 'postgresql': + """ + Postgres' CSV dialect works with our data, if we mark the not-null + columns with FORCE NOT NULL. + COPY is only allowed for DB superusers. If you're not one, use safe + loading (pokedex load -S). + """ + session.commit() + not_null_cols = [c for c in column_names if not table_obj.c[c].nullable] + if not_null_cols: + force_not_null = 'FORCE NOT NULL ' + ','.join('"%s"' % c for c in not_null_cols) + else: + force_not_null = '' + command = "COPY %(table_name)s (%(columns)s) FROM '%(csvpath)s' CSV HEADER %(force_not_null)s" + session.connection().execute( + command % dict( + table_name=table_name, + csvpath=csvpath, + columns=','.join('"%s"' % c for c in column_names), + force_not_null=force_not_null, + ) + ) + session.commit() + print_done() + continue + # Self-referential tables may contain rows with foreign keys of other # rows in the same table that do not yet exist. Pull these out and add # them to the session last @@ -131,8 +211,19 @@ def load(session, directory=None, drop_tables=False, verbose=False): if any(_.references(table_obj) for _ in column.foreign_keys): self_ref_columns.append(column) + new_rows = [] + def insert_and_commit(): + if not new_rows: + return + session.connection().execute(insert_stmt, new_rows) + session.commit() + new_rows[:] = [] + + progress = "%d%%" % (100 * csvfile.tell() // csvsize) + print_status(progress) + for csvs in reader: - row = table_class() + row_data = {} for column_name, value in zip(column_names, csvs): column = table_obj.c[column_name] @@ -150,56 +241,72 @@ def load(session, directory=None, drop_tables=False, verbose=False): # Otherwise, unflatten from bytes value = value.decode('utf-8') - setattr(row, column_name, value) + # nb: Dictionaries flattened with ** have to have string keys + row_data[ str(column_name) ] = value # May need to stash this row and add it later if it refers to a # later row in this table if self_ref_columns: - foreign_ids = [getattr(row, _.name) for _ in self_ref_columns] + foreign_ids = [row_data[_.name] for _ in self_ref_columns] foreign_ids = [_ for _ in foreign_ids if _] # remove NULL ids if not foreign_ids: # NULL key. Remember this row and add as usual. - seen_ids[row.id] = 1 + seen_ids[row_data['id']] = 1 elif all(_ in seen_ids for _ in foreign_ids): # Non-NULL key we've already seen. Remember it and commit # so we know the old row exists when we add the new one - session.commit() - seen_ids[row.id] = 1 + insert_and_commit() + seen_ids[row_data['id']] = 1 else: # Non-NULL future id. Save this and insert it later! - deferred_rows.append((row, foreign_ids)) + deferred_rows.append((row_data, foreign_ids)) continue - session.add(row) + # Insert row! + new_rows.append(row_data) - session.commit() + # Remembering some zillion rows in the session consumes a lot of + # RAM. Let's not do that. Commit every 1000 rows + if len(new_rows) >= 1000: + insert_and_commit() + + insert_and_commit() # Attempt to add any spare rows we've collected - for row, foreign_ids in deferred_rows: + for row_data, foreign_ids in deferred_rows: if not all(_ in seen_ids for _ in foreign_ids): # Could happen if row A refers to B which refers to C. # This is ridiculous and doesn't happen in my data so far raise ValueError("Too many levels of self-reference! " - "Row was: " + str(row.__dict__)) + "Row was: " + str(row)) - session.add(row) - seen_ids[row.id] = 1 - session.commit() + session.connection().execute( + insert_stmt.values(**row_data) + ) + seen_ids[row_data['id']] = 1 + session.commit() print_done() + # SQLite check + if session.connection().dialect.name == 'sqlite': + session.connection().execute("PRAGMA integrity_check") + -def dump(session, directory=None, verbose=False): +def dump(session, tables=[], directory=None, verbose=False): """Dumps the contents of a database to a set of CSV files. Probably not useful to anyone besides a developer. `session` SQLAlchemy session to use. + `tables` + List of tables to dump. If omitted, all tables are dumped. + `directory` Directory the CSV files should be put in. Defaults to the `pokedex` data directory. @@ -209,13 +316,17 @@ def dump(session, directory=None, verbose=False): """ # First take care of verbosity - print_start, print_done = _get_verbose_prints(verbose) + print_start, print_status, print_done = _get_verbose_prints(verbose) if not directory: - directory = pkg_resources.resource_filename('pokedex', 'data/csv') + directory = get_default_csv_dir() + + table_names = _get_table_names(metadata, tables) + table_names.sort() + - for table_name in sorted(metadata.tables.keys()): + for table_name in table_names: print_start(table_name) table = metadata.tables[table_name]