Merge branch 'main' into fix-transform-keyword-literal-defaults

This commit is contained in:
Simon Willison 2026-07-05 22:26:33 -07:00
commit 750686c6d1
44 changed files with 3440 additions and 376 deletions

View file

@ -14,6 +14,7 @@ from sqlite_utils.db import (
DEFAULT,
DescIndex,
NoTable,
NoView,
quote_identifier,
)
from sqlite_utils.plugins import ensure_plugins_loaded, pm, get_plugins
@ -33,6 +34,7 @@ from .utils import (
OperationalError,
_compile_code,
chunks,
dedupe_keys,
file_progress,
find_spatialite,
flatten as _flatten,
@ -701,14 +703,14 @@ def enable_fts(
_register_db_for_cleanup(db)
_load_extensions(db, load_extension)
try:
db[table].enable_fts(
db.table(table).enable_fts(
column,
fts_version=fts_version,
tokenize=tokenize,
create_triggers=create_triggers,
replace=replace,
)
except OperationalError as ex:
except (NoTable, OperationalError) as ex:
raise click.ClickException(str(ex))
@ -939,12 +941,6 @@ def insert_upsert_options(*, require_pk=False):
type=(str, str),
help="Default value that should be set for a column",
),
click.option(
"-d",
"--detect-types",
is_flag=True,
help="Detect types for columns in CSV/TSV data (default)",
),
click.option(
"--no-detect-types",
is_flag=True,
@ -999,7 +995,6 @@ def insert_upsert_implementation(
truncate=False,
not_null=None,
default=None,
detect_types=None,
no_detect_types=False,
analyze=False,
load_extension=None,
@ -1066,7 +1061,7 @@ def insert_upsert_implementation(
)
else:
docs = (dict(zip(headers, row)) for row in reader)
# detect_types is now the default, unless --no-detect-types is passed
# Type detection is the default, unless --no-detect-types is passed
if not no_detect_types:
tracker = TypeTracker()
docs = tracker.wrap(docs)
@ -1146,7 +1141,7 @@ def insert_upsert_implementation(
else:
doc_chunks = [docs]
for doc_chunk in doc_chunks:
with db.conn:
with db.atomic():
db.conn.cursor().executemany(bulk_sql, doc_chunk)
return
@ -1154,6 +1149,8 @@ def insert_upsert_implementation(
db.table(table).insert_all(
docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs
)
except NoTable as e:
raise click.ClickException(str(e))
except Exception as e:
if (
isinstance(e, OperationalError)
@ -1238,7 +1235,6 @@ def insert(
batch_size,
stop_after,
alter,
detect_types,
no_detect_types,
analyze,
load_extension,
@ -1321,7 +1317,6 @@ def insert(
ignore=ignore,
replace=replace,
truncate=truncate,
detect_types=detect_types,
no_detect_types=no_detect_types,
analyze=analyze,
load_extension=load_extension,
@ -1360,7 +1355,6 @@ def upsert(
alter,
not_null,
default,
detect_types,
no_detect_types,
analyze,
load_extension,
@ -1406,7 +1400,6 @@ def upsert(
upsert=True,
not_null=not_null,
default=default,
detect_types=detect_types,
no_detect_types=no_detect_types,
analyze=analyze,
load_extension=load_extension,
@ -1494,7 +1487,6 @@ def bulk(
upsert=False,
not_null=set(),
default={},
detect_types=False,
no_detect_types=True,
load_extension=load_extension,
silent=False,
@ -1725,7 +1717,13 @@ def drop_table(path, table, ignore, load_extension):
_register_db_for_cleanup(db)
_load_extensions(db, load_extension)
try:
db[table].drop(ignore=ignore)
db.table(table).drop(ignore=ignore)
except NoTable:
# A view exists with this name
if not ignore:
raise click.ClickException(
'"{}" is a view, not a table - use drop-view to drop it'.format(table)
)
except OperationalError:
raise click.ClickException('Table "{}" does not exist'.format(table))
@ -1797,8 +1795,14 @@ def drop_view(path, view, ignore, load_extension):
_register_db_for_cleanup(db)
_load_extensions(db, load_extension)
try:
db[view].drop(ignore=ignore)
except OperationalError:
db.view(view).drop(ignore=ignore)
except NoView:
if ignore:
return
if view in db.table_names():
raise click.ClickException(
'"{}" is a table, not a view - use drop-table to drop it'.format(view)
)
raise click.ClickException('View "{}" does not exist'.format(view))
@ -3374,24 +3378,57 @@ def migrate(db_path, migrations, stop_before, list_, verbose):
if not migration_sets:
raise click.ClickException("No migrations.py files found")
db = sqlite_utils.Database(db_path)
_register_db_for_cleanup(db)
if list_:
if pathlib.Path(db_path).exists():
db = sqlite_utils.Database(db_path)
else:
# Listing is read-only - don't create the database file
db = sqlite_utils.Database(memory=True)
_register_db_for_cleanup(db)
_display_migration_list(db, migration_sets)
return
db = sqlite_utils.Database(db_path)
_register_db_for_cleanup(db)
prev_schema = db.schema
if verbose:
click.echo("Migrating {}".format(db_path))
click.echo("\nSchema before:\n")
click.echo(textwrap.indent(prev_schema, " ") or " (empty)")
click.echo()
if stop_before:
# Every --stop-before value must match at least one known migration
known_names = set()
for migration_set in migration_sets:
names = {m.name for m in migration_set.pending(db)}
names.update(m.name for m in migration_set.applied(db))
known_names.update(names)
known_names.update(
"{}:{}".format(migration_set.name, name) for name in names
)
unknown = [value for value in stop_before if value not in known_names]
if unknown:
raise click.ClickException(
"--stop-before did not match any migrations: {}".format(
", ".join(unknown)
)
)
for migration_set in migration_sets:
migration_set.apply(
db,
stop_before=_stop_before_for_migration_set(stop_before, migration_set.name),
)
matches = _stop_before_for_migration_set(stop_before, migration_set.name)
if isinstance(migration_set, sqlite_utils.Migrations):
migration_set.apply(db, stop_before=matches)
else:
# Legacy sqlite-migrate Migrations objects take a single string
# for stop_before, not a list
distinct = list(dict.fromkeys(matches))
if len(distinct) > 1:
raise click.ClickException(
"Migration set '{}' uses the older sqlite-migrate class, "
"which only supports a single --stop-before value - "
"got: {}".format(migration_set.name, ", ".join(distinct))
)
migration_set.apply(db, stop_before=distinct[0] if distinct else None)
if verbose:
click.echo("Schema after:\n")
post_schema = db.schema
@ -3457,6 +3494,10 @@ FILE_COLUMNS = {
def output_rows(iterator, headers, nl, arrays, json_cols):
# Duplicate column names would collide as dictionary keys, so rename
# later occurrences id, id -> id, id_2 - CSV and table output keep
# the original duplicate headers since they never build dictionaries
headers = dedupe_keys(headers)
# We have to iterate two-at-a-time so we can know if we
# should output a trailing comma or if we have reached
# the last row.

File diff suppressed because it is too large Load diff

View file

@ -14,11 +14,14 @@ class Migrations:
class _Migration:
name: str
fn: Callable
transactional: bool = True
@dataclass
class _AppliedMigration:
name: str
applied_at: datetime.datetime
# A string timestamp such as "2026-07-04 12:00:00.000000+00:00" -
# stored as TEXT in the _sqlite_migrations table
applied_at: str
def __init__(self, name: str):
"""
@ -27,15 +30,29 @@ class Migrations:
self.name = name
self._migrations: list[Migrations._Migration] = []
def __call__(self, *, name: str | None = None) -> Callable:
def __call__(
self, *, name: str | None = None, transactional: bool = True
) -> Callable:
"""
:param name: The name to use for this migration - if not provided,
the name of the function will be used.
:param transactional: If ``True`` (the default) the migration and the
record of it having been applied are wrapped in a transaction, which
will be rolled back if the migration raises an exception. Pass
``False`` for migrations that cannot run inside a transaction, for
example those that execute ``VACUUM``.
"""
def inner(func: Callable) -> Callable:
migration_name = name or getattr(func, "__name__")
if any(m.name == migration_name for m in self._migrations):
raise ValueError(
"Migration '{}' is already registered in set '{}'".format(
migration_name, self.name
)
)
self._migrations.append(
self._Migration(name or getattr(func, "__name__"), func)
self._Migration(migration_name, func, transactional)
)
return func
@ -44,14 +61,10 @@ class Migrations:
def pending(self, db: "Database") -> list["Migrations._Migration"]:
"""
Return a list of pending migrations.
This is a read-only operation - it does not write to the database.
"""
self.ensure_migrations_table(db)
already_applied = {
r["name"]
for r in db[self.migrations_table].rows_where(
"migration_set = ?", [self.name]
)
}
already_applied = {migration.name for migration in self.applied(db)}
return [
migration
for migration in self._migrations
@ -60,19 +73,29 @@ class Migrations:
def applied(self, db: "Database") -> list["Migrations._AppliedMigration"]:
"""
Return a list of applied migrations.
Return a list of applied migrations, in the order they were applied.
This is a read-only operation - it does not write to the database.
"""
self.ensure_migrations_table(db)
table = _table(db, self.migrations_table)
if not table.exists():
return []
return [
self._AppliedMigration(name=row["name"], applied_at=row["applied_at"])
for row in db[self.migrations_table].rows_where(
"migration_set = ?", [self.name]
for row in table.rows_where(
"migration_set = ?", [self.name], order_by="rowid"
)
]
def apply(self, db: "Database", *, stop_before: str | Iterable[str] | None = None):
"""
Apply any pending migrations to the database.
Each migration runs inside a transaction, together with the record of
it having been applied - if the migration raises an exception its
changes are rolled back, no record is written and the migration stays
pending. Migrations registered with ``transactional=False`` run
outside of a transaction.
"""
self.ensure_migrations_table(db)
if stop_before is None:
@ -85,14 +108,22 @@ class Migrations:
name = migration.name
if name in stop_before_names:
return
migration.fn(db)
_table(db, self.migrations_table).insert(
{
"migration_set": self.name,
"name": name,
"applied_at": str(datetime.datetime.now(datetime.timezone.utc)),
}
)
if migration.transactional:
with db.atomic():
migration.fn(db)
self._record_applied(db, name)
else:
migration.fn(db)
self._record_applied(db, name)
def _record_applied(self, db: "Database", name: str):
_table(db, self.migrations_table).insert(
{
"migration_set": self.name,
"name": name,
"applied_at": str(datetime.datetime.now(datetime.timezone.utc)),
}
)
def ensure_migrations_table(self, db: "Database"):
"""

View file

@ -43,15 +43,10 @@ else:
dbapi2 = importlib.import_module("pysqlite3.dbapi2")
OperationalError = dbapi2.OperationalError
except ImportError:
try:
sqlite3 = importlib.import_module("sqlean")
dbapi2 = importlib.import_module("sqlean.dbapi2")
OperationalError = dbapi2.OperationalError
except ImportError:
import sqlite3 # noqa: F401
from sqlite3 import dbapi2 # noqa: F401
import sqlite3 # noqa: F401
from sqlite3 import dbapi2 # noqa: F401
OperationalError = dbapi2.OperationalError
OperationalError = dbapi2.OperationalError
SPATIALITE_PATHS = (
@ -618,6 +613,37 @@ def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) ->
).hexdigest()
def dedupe_keys(keys: Iterable[str]) -> List[str]:
"""
Rename duplicates in a list of column names so every name is unique,
by appending ``_2``, ``_3``... to later occurrences - skipping any
suffix that would collide with another column in the list.
Used when converting SQL query rows to dictionaries, where duplicate
column names would otherwise silently overwrite each other.
:param keys: List of column names, possibly containing duplicates
"""
keys = list(keys)
taken = set(keys)
if len(taken) == len(keys):
# No duplicates - the common case
return keys
seen: set = set()
result = []
for key in keys:
if key in seen:
new_key = key
suffix = 2
while new_key in seen or new_key in taken:
new_key = "{}_{}".format(key, suffix)
suffix += 1
key = new_key
seen.add(key)
result.append(key)
return result
def _flatten(d: Dict[str, Any]) -> Generator[Tuple[str, Any], None, None]:
for key, value in d.items():
if isinstance(value, dict):