From 566b5bfcf7d28a9ee194cb1684d32ab60c6498bb Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Mon, 3 Aug 2026 02:14:02 +0000 Subject: [PATCH 1/5] fix: accept migration template configuration keys The migration_config allowlist rejected the three keys the template renderer reads, so any customized migration template failed at config construction. - Declare templates, default_format, and title on MigrationConfig, with MigrationTemplates, SQLTemplateOverride, and PythonTemplateOverride describing the override shape. - Validate keys nested under templates.sql and templates.py, reporting the dotted path and the closest valid key, and reject non-mapping overrides with a clear message rather than a render-time TypeError. - Correct the version_table_name docstring: the default is ddl_migrations. --- sqlspec/config.py | 138 ++++++++++++++++-- .../test_migration_config_validation.py | 111 ++++++++++++++ 2 files changed, 237 insertions(+), 12 deletions(-) diff --git a/sqlspec/config.py b/sqlspec/config.py index 688c271f1..30e4b3c9f 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -48,6 +48,7 @@ __all__ = ( "MIGRATION_CONFIG_KEYS", + "MIGRATION_TEMPLATES_KEYS", "ADKConfig", "AsyncConfigT", "AsyncDatabaseConfig", @@ -62,11 +63,14 @@ "LifecycleConfig", "LitestarConfig", "MigrationConfig", + "MigrationTemplates", "NoPoolAsyncConfig", "NoPoolSyncConfig", "OpenTelemetryConfig", "PoolT", "PrometheusConfig", + "PythonTemplateOverride", + "SQLTemplateOverride", "SanicConfig", "StarletteConfig", "SyncConfigT", @@ -114,6 +118,55 @@ class LifecycleConfig(TypedDict): on_error: NotRequired[list[Callable[[Exception, str, dict[str, Any]], None]]] +class SQLTemplateOverride(TypedDict): + """Overrides for the SQL migration template.""" + + header: NotRequired[str] + """First line of the generated file. Supports the migration template placeholders.""" + + metadata: NotRequired["list[str]"] + """Comment lines rendered beneath the header.""" + + body: NotRequired[str] + """Migration body containing the up and down named statements.""" + + description_key: NotRequired["str | list[str]"] + """Metadata label(s) the description is read back from. Defaults to 'Description'.""" + + +class PythonTemplateOverride(TypedDict): + """Overrides for the Python migration template.""" + + docstring: NotRequired[str] + """Module docstring contents. Supports the migration template placeholders.""" + + body: NotRequired[str] + """Module body defining the up and down functions.""" + + imports: NotRequired["list[str]"] + """Import lines rendered between the docstring and the body.""" + + description_key: NotRequired["str | list[str]"] + """Docstring label(s) the description is read back from. Defaults to 'Description'.""" + + +class MigrationTemplates(TypedDict): + """Template overrides applied when generating migration files. + + Placeholders available to every fragment: ``title``, ``version``, ``message``, + ``description``, ``created_at``, ``author``, ``adapter``, and ``project_slug``. + """ + + sql: NotRequired[SQLTemplateOverride] + """Overrides for generated ``.sql`` migrations.""" + + py: NotRequired[PythonTemplateOverride] + """Overrides for generated ``.py`` migrations.""" + + title: NotRequired[str] + """Title used when ``MigrationConfig.title`` is omitted.""" + + class MigrationConfig(TypedDict): """Configuration options for database migrations. @@ -124,7 +177,7 @@ class MigrationConfig(TypedDict): """Path to the migrations directory. Accepts string or Path object. Defaults to 'migrations'.""" version_table_name: NotRequired[str] - """Name of the table used to track applied migrations. Defaults to 'sqlspec_migrations'.""" + """Name of the table used to track applied migrations. Defaults to 'ddl_migrations'.""" default_schema: NotRequired[str] """Schema applied to migration sessions before user migration SQL runs, when supported by the adapter.""" @@ -198,30 +251,91 @@ class MigrationConfig(TypedDict): Defaults to False. """ + default_format: NotRequired["Literal['sql', 'py']"] + """File format used by ``create-migration`` when none is requested. Defaults to 'sql'.""" + + title: NotRequired[str] + """Title rendered into generated migration files. Defaults to 'SQLSpec Migration'.""" + + templates: NotRequired[MigrationTemplates] + """Template fragment overrides applied when generating migration files.""" + MIGRATION_CONFIG_KEYS: "frozenset[str]" = MigrationConfig.__required_keys__ | MigrationConfig.__optional_keys__ +MIGRATION_TEMPLATES_KEYS: "frozenset[str]" = MigrationTemplates.__required_keys__ | MigrationTemplates.__optional_keys__ +_TEMPLATE_FRAGMENT_KEYS: "dict[str, frozenset[str]]" = { + "sql": SQLTemplateOverride.__required_keys__ | SQLTemplateOverride.__optional_keys__, + "py": PythonTemplateOverride.__required_keys__ | PythonTemplateOverride.__optional_keys__, +} -def validate_migration_config_keys(migration_config: "Mapping[str, Any]") -> None: - """Reject migration configuration keys that SQLSpec does not read. +def _report_unknown_keys( + mapping: "Mapping[str, Any]", valid_keys: "frozenset[str]", prefix: str, scope: str +) -> "list[str]": + """Describe keys a configuration scope does not declare. Args: - migration_config: Migration configuration mapping to check. + mapping: Mapping to check. + valid_keys: Keys the scope accepts. + prefix: Dotted path prepended to each reported key. + scope: Scope name used in the valid-key summary, empty for the top level. - Raises: - ImproperConfigurationError: If the mapping contains an unrecognized key. + Returns: + Report lines, empty when every key is recognized. """ - unknown = sorted(key for key in migration_config if key not in MIGRATION_CONFIG_KEYS) + unknown = sorted(key for key in mapping if key not in valid_keys) if not unknown: - return + return [] lines = [] for key in unknown: - suggestions = get_close_matches(key, MIGRATION_CONFIG_KEYS, n=1, cutoff=0.6) + suggestions = get_close_matches(key, valid_keys, n=1, cutoff=0.6) hint = f" Did you mean {suggestions[0]!r}?" if suggestions else "" - lines.append(f"Unknown migration_config key {key!r}.{hint}") - lines.append(f"Valid keys: {', '.join(sorted(MIGRATION_CONFIG_KEYS))}.") - raise ImproperConfigurationError(" ".join(lines)) + lines.append(f"Unknown migration_config key {f'{prefix}{key}'!r}.{hint}") + lines.append(f"Valid {scope}keys: {', '.join(sorted(valid_keys))}.") + return lines + + +def _report_template_keys(templates: Any) -> "list[str]": + """Describe unrecognized keys nested under ``templates``. + + Args: + templates: Value configured for the ``templates`` key. + + Returns: + Report lines, empty when the overrides are recognized. + """ + if not isinstance(templates, Mapping): + return [f"migration_config key 'templates' must be a mapping, got {type(templates).__name__}."] + + lines = _report_unknown_keys(templates, MIGRATION_TEMPLATES_KEYS, "templates.", "'templates' ") + for section, fragment_keys in _TEMPLATE_FRAGMENT_KEYS.items(): + overrides = templates.get(section) + if overrides is None: + continue + path = f"templates.{section}" + if not isinstance(overrides, Mapping): + lines.append(f"migration_config key '{path}' must be a mapping, got {type(overrides).__name__}.") + continue + lines.extend(_report_unknown_keys(overrides, fragment_keys, f"{path}.", f"'{path}' ")) + return lines + + +def validate_migration_config_keys(migration_config: "Mapping[str, Any]") -> None: + """Reject migration configuration keys that SQLSpec does not read. + + Args: + migration_config: Migration configuration mapping to check. + + Raises: + ImproperConfigurationError: If the mapping contains an unrecognized key. + """ + lines = _report_unknown_keys(migration_config, MIGRATION_CONFIG_KEYS, "", "") + templates = migration_config.get("templates") + if templates is not None: + lines.extend(_report_template_keys(templates)) + if lines: + raise ImproperConfigurationError(" ".join(lines)) class FlaskConfig(TypedDict): diff --git a/tests/unit/config/test_migration_config_validation.py b/tests/unit/config/test_migration_config_validation.py index 5014afbdd..98bd95002 100644 --- a/tests/unit/config/test_migration_config_validation.py +++ b/tests/unit/config/test_migration_config_validation.py @@ -5,11 +5,17 @@ These tests pin the validate-and-raise behavior and the suggestion text. """ +from typing import TYPE_CHECKING + import pytest from sqlspec.adapters.sqlite import SqliteConfig from sqlspec.config import MIGRATION_CONFIG_KEYS, MigrationConfig, validate_migration_config_keys from sqlspec.exceptions import ImproperConfigurationError +from sqlspec.migrations.utils import create_migration_file + +if TYPE_CHECKING: + from pathlib import Path def test_known_keys_cover_every_typed_dict_field() -> None: @@ -78,3 +84,108 @@ def test_valid_config_is_unchanged() -> None: ) assert config.migration_config["version_table_name"] == "_schema_versions" + + +@pytest.mark.parametrize( + ("key", "value"), + [("templates", {"sql": {"header": "-- {title} [ACME]"}}), ("title", "Acme Migration"), ("default_format", "py")], +) +def test_template_keys_are_accepted(key: str, value: object) -> None: + """Keys read by build_template_settings must survive validation.""" + config = SqliteConfig(connection_config={"database": ":memory:"}, migration_config={key: value}) + + assert config.migration_config[key] == value + + +def test_template_keys_are_declared() -> None: + """The template settings reader consumes these keys, so they must be declared.""" + assert {"templates", "title", "default_format"} <= MIGRATION_CONFIG_KEYS + + +def test_unknown_template_section_key_is_reported() -> None: + """A typo directly under templates names the dotted path and the intended key.""" + with pytest.raises(ImproperConfigurationError) as exc_info: + validate_migration_config_keys({"templates": {"sqll": {}}}) + + message = str(exc_info.value) + assert "Unknown migration_config key 'templates.sqll'" in message + assert "Did you mean 'sql'?" in message + + +@pytest.mark.parametrize( + ("section", "bad_key", "suggestion"), + [("sql", "headers", "header"), ("sql", "bodyy", "body"), ("py", "docstrings", "docstring")], +) +def test_unknown_template_fragment_key_is_reported(section: str, bad_key: str, suggestion: str) -> None: + """A typo inside a template fragment is caught at construction, not at render time.""" + with pytest.raises(ImproperConfigurationError) as exc_info: + validate_migration_config_keys({"templates": {section: {bad_key: "x"}}}) + + message = str(exc_info.value) + assert f"Unknown migration_config key 'templates.{section}.{bad_key}'" in message + assert f"Did you mean {suggestion!r}?" in message + + +def test_description_key_is_the_accepted_fragment_spelling() -> None: + """The singular description_key is declared; the resolved plural form is not a config key.""" + validate_migration_config_keys({"templates": {"sql": {"description_key": "Summary"}}}) + + with pytest.raises(ImproperConfigurationError, match="Did you mean 'description_key'"): + validate_migration_config_keys({"templates": {"sql": {"description_keys": "Summary"}}}) + + +@pytest.mark.parametrize("path", ["templates", "templates.sql"]) +def test_non_mapping_template_value_reports_the_type(path: str) -> None: + """A non-mapping override raises a clear message instead of a TypeError at render time.""" + payload: dict[str, object] = ( + {"templates": ["not", "a", "mapping"]} if path == "templates" else {"templates": {"sql": "not a mapping"}} + ) + + with pytest.raises(ImproperConfigurationError) as exc_info: + validate_migration_config_keys(payload) + + assert f"'{path}' must be a mapping" in str(exc_info.value) + + +def test_nested_typo_fails_at_config_construction() -> None: + """Nested validation runs through the real config setter.""" + with pytest.raises(ImproperConfigurationError, match=r"templates\.sql\.headerr"): + SqliteConfig( + connection_config={"database": ":memory:"}, migration_config={"templates": {"sql": {"headerr": "-- x"}}} + ) + + +def test_valid_nested_template_config_is_accepted() -> None: + """A fully populated template override passes validation.""" + validate_migration_config_keys({ + "title": "Acme", + "default_format": "py", + "templates": { + "title": "Acme Fallback", + "sql": {"header": "-- {title}", "metadata": ["-- {author}"], "body": "", "description_key": "Desc"}, + "py": {"docstring": "{title}", "body": "", "imports": [], "description_key": ["Desc"]}, + }, + }) + + +def test_template_overrides_reach_the_rendered_migration(tmp_path: "Path") -> None: + """A customized template configured on a real config renders through to disk.""" + migrations_dir = tmp_path / "migrations" + migrations_dir.mkdir() + config = SqliteConfig( + connection_config={"database": ":memory:"}, + migration_config={ + "author": "Acme Ops", + "title": "Acme Migration", + "default_format": "py", + "templates": {"sql": {"header": "-- {title} [ACME]", "metadata": ["-- Owner: {author}"]}}, + }, + ) + + sql_path = create_migration_file(migrations_dir, "0001", "custom", "sql", config=config) + default_path = create_migration_file(migrations_dir, "0002", "defaulted", None, config=config) + + content = sql_path.read_text() + assert "-- Acme Migration [ACME]" in content + assert "-- Owner: Acme Ops" in content + assert default_path.suffix == ".py" From fd4e7652399a1ad3ecb73416d881f926e445d7a1 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Mon, 3 Aug 2026 02:16:08 +0000 Subject: [PATCH 2/5] test: guard that every migration_config key read is declared MIGRATION_CONFIG_KEYS rejects any key absent from the MigrationConfig declaration, so the declaration has to stay a complete inventory of what SQLSpec reads. Scan the package for key reads and assert the allowlist covers them. The scanner resolves one level of local aliasing, since a search keyed on the literal name misses the rebinding the template reader uses. Reads through a lookup result are deliberately not tracked, so nested keys are not mistaken for top-level ones. Fixture cases prove each read shape is detected. --- .../test_migration_config_key_inventory.py | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tests/unit/config/test_migration_config_key_inventory.py diff --git a/tests/unit/config/test_migration_config_key_inventory.py b/tests/unit/config/test_migration_config_key_inventory.py new file mode 100644 index 000000000..7bf40ec4d --- /dev/null +++ b/tests/unit/config/test_migration_config_key_inventory.py @@ -0,0 +1,188 @@ +"""Guard test keeping ``MIGRATION_CONFIG_KEYS`` a complete inventory of consumers. + +``MIGRATION_CONFIG_KEYS`` is a closed allowlist derived from the ``MigrationConfig`` +``TypedDict``: a key absent from the declaration is rejected at config construction. +That is only correct while the declaration covers every key SQLSpec reads, and a +hand-maintained restatement drifts. This module scans the package for reads and +asserts the allowlist covers them. + +The scanner resolves one level of local aliasing because that is what a name-based +search misses: ``build_template_settings`` rebinds ``config = migration_config or {}`` +before reading, which is how three template keys were consumed without being declared. +""" + +import ast +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +import sqlspec +from sqlspec.config import MIGRATION_CONFIG_KEYS + +if TYPE_CHECKING: + from collections.abc import Iterator + +CONFIG_NAME = "migration_config" +PACKAGE_ROOT = Path(sqlspec.__file__).parent + + +def _unwrap(node: ast.expr) -> ast.expr: + """Strip ``or`` fallbacks and ``cast()`` wrappers from an expression.""" + while True: + if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or) and node.values: + node = node.values[0] + elif isinstance(node, ast.Call) and _is_cast(node.func) and node.args: + node = node.args[-1] + else: + return node + + +def _is_cast(func: ast.expr) -> bool: + """Check whether a call target is ``cast`` or ``typing.cast``.""" + if isinstance(func, ast.Name): + return func.id == "cast" + return isinstance(func, ast.Attribute) and func.attr == "cast" + + +def _is_config_expr(node: ast.expr, tracked: "set[str]") -> bool: + """Check whether an expression evaluates to a migration configuration mapping.""" + root = _unwrap(node) + if isinstance(root, ast.Name): + return root.id in tracked + return isinstance(root, ast.Attribute) and root.attr == CONFIG_NAME + + +def _tracked_names(scope: ast.AST) -> "set[str]": + """Collect local names bound to a migration configuration within one scope. + + Only direct rebindings are tracked. A name bound to the *result* of a lookup, + such as ``templates_config = config.get("templates")``, is deliberately not + tracked: its keys belong to a nested scope, not to ``migration_config``. + """ + tracked = {CONFIG_NAME} + for _ in range(3): + before = len(tracked) + for node in ast.walk(scope): + targets: list[ast.expr] = [] + if isinstance(node, ast.Assign): + targets = list(node.targets) + elif isinstance(node, ast.AnnAssign) and node.value is not None: + targets = [node.target] + else: + continue + value = node.value + if value is None or not _is_config_expr(value, tracked): + continue + tracked.update(target.id for target in targets if isinstance(target, ast.Name)) + if len(tracked) == before: + break + return tracked + + +def _literal(node: ast.expr) -> "str | None": + """Return the value of a string literal node, or None.""" + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def _reads_in_scope(scope: ast.AST, tracked: "set[str]") -> "Iterator[str]": + """Yield string-literal keys read from a tracked configuration mapping.""" + for node in ast.walk(scope): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args: + if node.func.attr in {"get", "pop"} and _is_config_expr(node.func.value, tracked): + key = _literal(node.args[0]) + if key is not None: + yield key + elif isinstance(node, ast.Subscript) and _is_config_expr(node.value, tracked): + key = _literal(node.slice) + if key is not None: + yield key + elif isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.In): + if _is_config_expr(node.comparators[0], tracked): + key = _literal(node.left) + if key is not None: + yield key + + +def collect_consumed_keys(source: str) -> "set[str]": + """Collect every migration_config key a module reads by string literal.""" + tree = ast.parse(source) + scopes: list[ast.AST] = [tree] + scopes.extend( + node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)) + ) + consumed: set[str] = set() + for scope in scopes: + consumed.update(_reads_in_scope(scope, _tracked_names(scope))) + return consumed + + +def _package_sources() -> "Iterator[tuple[Path, str]]": + """Yield every Python source file shipped in the package.""" + for path in sorted(PACKAGE_ROOT.rglob("*.py")): + yield path, path.read_text(encoding="utf-8") + + +def test_every_consumed_key_is_declared() -> None: + """No module may read a migration_config key the allowlist would reject.""" + undeclared: dict[str, set[str]] = {} + for path, source in _package_sources(): + consumed = collect_consumed_keys(source) + missing = consumed - MIGRATION_CONFIG_KEYS + if missing: + undeclared[str(path.relative_to(PACKAGE_ROOT))] = missing + + assert not undeclared, ( + f"migration_config keys read but not declared on MigrationConfig: {undeclared}. " + "Declare them on the TypedDict; MIGRATION_CONFIG_KEYS rejects everything else at construction." + ) + + +def test_scanner_finds_keys_read_through_a_local_alias() -> None: + """The scanner resolves the rebinding pattern that hid three keys during #670.""" + source = """ +def build_template_settings(migration_config): + config = migration_config or {} + return config.get("templates"), config.get("default_format"), config.get("title") +""" + + assert collect_consumed_keys(source) == {"templates", "default_format", "title"} + + +@pytest.mark.parametrize( + "source", + [ + pytest.param('def f(migration_config):\n return migration_config.get("undeclared")', id="direct-get"), + pytest.param('def f(migration_config):\n return migration_config["undeclared"]', id="subscript"), + pytest.param('def f(migration_config):\n return "undeclared" in migration_config', id="containment"), + pytest.param( + 'def f(config):\n mc = cast("dict[str, Any]", config.migration_config) or {}\n' + ' return mc.get("undeclared")', + id="cast-attribute-alias", + ), + ], +) +def test_scanner_detects_an_undeclared_key(source: str) -> None: + """A guard that cannot fail is worse than no guard, so prove each read shape is caught.""" + consumed = collect_consumed_keys(source) + + assert "undeclared" in consumed + assert consumed - MIGRATION_CONFIG_KEYS == {"undeclared"} + + +def test_scanner_ignores_keys_of_nested_mappings() -> None: + """Keys of a mapping returned by a lookup belong to a nested scope, not the top level.""" + source = """ +def build_template_settings(migration_config): + templates_config = migration_config.get("templates") or {} + return templates_config.get("sql") +""" + + assert collect_consumed_keys(source) == {"templates"} + + +def test_scanner_reads_the_real_template_module() -> None: + """The template reader is scanned as a live consumer, not just as a fixture.""" + source = (PACKAGE_ROOT / "migrations" / "templates.py").read_text(encoding="utf-8") + + assert {"templates", "default_format", "title"} <= collect_consumed_keys(source) From b28d441b023f7e2193ddaaad3108fb2e63e0e107 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Mon, 3 Aug 2026 02:18:09 +0000 Subject: [PATCH 3/5] docs: document migration template customization - Convert the template tests from a stub config to a real adapter config so they exercise the validation path users hit, and cover the Python template override alongside the SQL one. - Add a migrations guide section for default_format, title, and templates, listing the placeholders each fragment can use. - Correct the documented version_table_name default. - Add the template override types to the configuration reference. - Record v0.58.1 in the changelog. --- docs/changelog.rst | 28 +++++++++ docs/reference/config.rst | 12 ++++ docs/usage/migrations.rst | 61 ++++++++++++++++++- .../test_migration_config_validation.py | 8 ++- tests/unit/migrations/test_utils.py | 55 +++++++++++------ 5 files changed, 142 insertions(+), 22 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fb2e1431a..5795fe1a8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,34 @@ important operational fixes. Recent Updates ============== +v0.58.1 - Migration template configuration +------------------------------------------------------------------------------ + +**Fixed:** + +* ``migration_config`` accepts the three keys that customize generated + migration files: ``templates``, ``default_format``, and ``title``. The + key validation added in v0.58.0 did not recognize them, so any configuration + using a customized migration template raised + :class:`~sqlspec.exceptions.ImproperConfigurationError` at construction. + ``default_format`` was additionally reported with a suggestion to use + ``default_schema``, an unrelated setting. +* Template overrides are validated when the configuration is built rather than + when a migration is generated. A misspelling inside ``templates.sql`` or + ``templates.py`` reports its full path and the closest valid key, and an + override that is not a mapping is named along with the type supplied. +* :class:`~sqlspec.config.MigrationConfig` documents the real default for + ``version_table_name``, which is ``ddl_migrations``. + +**Added:** + +* :class:`~sqlspec.config.MigrationTemplates`, + :class:`~sqlspec.config.SQLTemplateOverride`, and + :class:`~sqlspec.config.PythonTemplateOverride` describe the template + override shape, so type checkers now cover it. +* The migrations guide documents template customization, including the + placeholders available to each fragment. + v0.58.0 - Configuration and storage correctness ------------------------------------------------------------------------------ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 5c786d961..f5a590def 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -53,6 +53,18 @@ Extension Configuration Types :members: :show-inheritance: +.. autoclass:: MigrationTemplates + :members: + :show-inheritance: + +.. autoclass:: SQLTemplateOverride + :members: + :show-inheritance: + +.. autoclass:: PythonTemplateOverride + :members: + :show-inheritance: + .. autoclass:: EventsConfig :members: :show-inheritance: diff --git a/docs/usage/migrations.rst b/docs/usage/migrations.rst index 1497afe20..f2efef6c0 100644 --- a/docs/usage/migrations.rst +++ b/docs/usage/migrations.rst @@ -135,7 +135,7 @@ Common keys * - ``script_location`` - Migrations directory. Defaults to ``migrations``. * - ``version_table_name`` - - Tracking table name. Defaults to ``sqlspec_migrations``. + - Tracking table name. Defaults to ``ddl_migrations``. * - ``enabled`` - Set ``False`` to exclude this configuration from CLI operations. * - ``strict_ordering`` @@ -305,6 +305,65 @@ configuration is built does not re-run discovery. it compiles its own modules, the migration sources must remain on disk, since Python migrations are read and compiled at runtime. +Migration File Templates +------------------------ + +``create-migration`` renders new files from a built-in template. Three +``migration_config`` keys adjust what it writes: + +.. list-table:: + :header-rows: 1 + :widths: 24 76 + + * - Key + - Purpose + * - ``default_format`` + - Format used when the command is run without ``--file-type``. Either + ``sql`` or ``py``. Defaults to ``sql``. + * - ``title`` + - Title rendered into generated files. Defaults to ``SQLSpec Migration``. + * - ``templates`` + - Fragment overrides for the ``sql`` and ``py`` templates. + +Overrides replace individual fragments; anything omitted keeps its default: + +.. code-block:: python + + config = DuckDBConfig( + connection_config={"database": "/tmp/analytics.db"}, + migration_config={ + "title": "Acme Migration", + "default_format": "py", + "templates": { + "sql": { + "header": "-- {title} [{adapter}]", + "metadata": ["-- Version: {version}", "-- Owner: {author}"], + } + }, + }, + ) + +Every fragment is rendered with ``str.format``, so these placeholders are +available: ``title``, ``version``, ``message``, ``description``, ``created_at``, +``author``, ``adapter``, ``project_slug``, and ``slug`` (the filename-safe form +of the message). An unknown placeholder raises +:class:`~sqlspec.migrations.templates.TemplateValidationError` when the file is +generated. + +The SQL template accepts ``header``, ``metadata``, ``body``, and +``description_key``; the Python template accepts ``docstring``, ``imports``, +``body``, and ``description_key``. ``description_key`` names the label the +description is read back from, and takes a string or a list of strings. + +.. note:: + + A body override owns the whole migration body, including the + ``-- name: migrate-{version}-up`` and ``-- name: migrate-{version}-down`` + markers for SQL, or the ``up``/``down`` functions for Python. SQLSpec does + not merge fragments into a replaced body. + +See :class:`~sqlspec.config.MigrationTemplates` for the full override shape. + Output and Logging ------------------ diff --git a/tests/unit/config/test_migration_config_validation.py b/tests/unit/config/test_migration_config_validation.py index 98bd95002..d1d759a71 100644 --- a/tests/unit/config/test_migration_config_validation.py +++ b/tests/unit/config/test_migration_config_validation.py @@ -147,11 +147,15 @@ def test_non_mapping_template_value_reports_the_type(path: str) -> None: assert f"'{path}' must be a mapping" in str(exc_info.value) +MISSPELLED_FRAGMENT_KEY = "headerr" # codespell:ignore headerr + + def test_nested_typo_fails_at_config_construction() -> None: """Nested validation runs through the real config setter.""" - with pytest.raises(ImproperConfigurationError, match=r"templates\.sql\.headerr"): + with pytest.raises(ImproperConfigurationError, match=rf"templates\.sql\.{MISSPELLED_FRAGMENT_KEY}"): SqliteConfig( - connection_config={"database": ":memory:"}, migration_config={"templates": {"sql": {"headerr": "-- x"}}} + connection_config={"database": ":memory:"}, + migration_config={"templates": {"sql": {MISSPELLED_FRAGMENT_KEY: "-- x"}}}, ) diff --git a/tests/unit/migrations/test_utils.py b/tests/unit/migrations/test_utils.py index d5433de37..e5a702175 100644 --- a/tests/unit/migrations/test_utils.py +++ b/tests/unit/migrations/test_utils.py @@ -10,12 +10,12 @@ import os import subprocess from pathlib import Path -from typing import Any, cast +from typing import Any from unittest.mock import Mock, patch import pytest -from sqlspec.config import DatabaseConfigProtocol +from sqlspec.adapters.sqlite import SqliteConfig from sqlspec.migrations.templates import TemplateValidationError from sqlspec.migrations.utils import ( _get_git_config, @@ -255,16 +255,12 @@ def test_create_migration_file_slugifies_message(tmp_path: Path) -> None: def test_create_migration_file_respects_default_format(tmp_path: Path) -> None: migrations_dir = tmp_path / "migrations" migrations_dir.mkdir() - - class DummyConfig: - migration_config = {"default_format": "py", "author": "Static"} - bind_key: str | None = None - driver_type: type | None = None - - file_path = create_migration_file( - migrations_dir, "0001", "custom", None, config=cast(DatabaseConfigProtocol[Any, Any, Any], DummyConfig()) + config = SqliteConfig( + connection_config={"database": ":memory:"}, migration_config={"default_format": "py", "author": "Static"} ) + file_path = create_migration_file(migrations_dir, "0001", "custom", None, config=config) + assert file_path.suffix == ".py" @@ -291,27 +287,48 @@ def test_quote_identifier_escapes_embedded_quotes() -> None: def test_create_migration_file_uses_custom_sql_template(tmp_path: Path) -> None: migrations_dir = tmp_path / "migrations" migrations_dir.mkdir() - - class DummyConfig: - migration_config = { + config = SqliteConfig( + connection_config={"database": ":memory:"}, + migration_config={ "author": "Acme Ops", "title": "Acme Migration", "templates": { "sql": {"header": "-- {title} [ACME]", "metadata": ["-- Owner: {author}"], "body": "-- custom body"} }, - } - bind_key: str | None = None - driver_type: type | None = None - - file_path = create_migration_file( - migrations_dir, "0001", "custom", "sql", config=cast(DatabaseConfigProtocol[Any, Any, Any], DummyConfig()) + }, ) + + file_path = create_migration_file(migrations_dir, "0001", "custom", "sql", config=config) content = file_path.read_text() assert "-- Acme Migration [ACME]" in content assert "-- Owner: Acme Ops" in content +def test_create_migration_file_uses_custom_python_template(tmp_path: Path) -> None: + migrations_dir = tmp_path / "migrations" + migrations_dir.mkdir() + config = SqliteConfig( + connection_config={"database": ":memory:"}, + migration_config={ + "author": "Acme Ops", + "templates": { + "py": { + "docstring": "{title} :: {message}", + "imports": ["from typing import Iterable"], + "body": "def up() -> str:\n return 'SELECT 1'", + } + }, + }, + ) + + file_path = create_migration_file(migrations_dir, "0001", "custom", "py", config=config) + content = file_path.read_text() + + assert '"""SQLSpec Migration :: custom"""' in content + assert "def up() -> str:" in content + + def test_python_template_includes_down_and_context(tmp_path: Path) -> None: migrations_dir = tmp_path / "migrations" migrations_dir.mkdir() From c52c8e3a23840a85ea23dd33cf6a8253b2e0a559 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Mon, 3 Aug 2026 13:12:22 +0000 Subject: [PATCH 4/5] chore(release): bump SQLSpec to v0.58.1 ## Summary - bump SQLSpec to `0.58.1` - refresh prompt-toolkit to 3.0.53 in the lock file --- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28720cc03..4dff8ba0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }] name = "sqlspec" readme = "README.md" requires-python = ">=3.10, <4.0" -version = "0.58.0" +version = "0.58.1" [project.urls] Discord = "https://discord.gg/litestar" @@ -331,7 +331,7 @@ opt_level = "3" # Maximum optimization (0-3) allow_dirty = true commit = false commit_args = "--no-verify" -current_version = "0.58.0" +current_version = "0.58.1" ignore_missing_files = false ignore_missing_version = false message = "chore(release): bump to v{new_version}" diff --git a/uv.lock b/uv.lock index c06bceba6..e137895b7 100644 --- a/uv.lock +++ b/uv.lock @@ -4498,14 +4498,14 @@ wheels = [ [[package]] name = "prompt-toolkit" -version = "3.0.52" +version = "3.0.53" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] [[package]] @@ -6628,7 +6628,7 @@ wheels = [ [[package]] name = "sqlspec" -version = "0.58.0" +version = "0.58.1" source = { editable = "." } dependencies = [ { name = "mypy-extensions" }, From bc538f66ac4a84d82448a53e92ace38efabcd1d5 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Mon, 3 Aug 2026 14:53:23 +0000 Subject: [PATCH 5/5] test: index migration_config through a plain dict in the key test Subscripting the MigrationConfig TypedDict with a parametrized variable is not a literal key, which mypy rejects. --- tests/unit/config/test_migration_config_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config/test_migration_config_validation.py b/tests/unit/config/test_migration_config_validation.py index d1d759a71..827a7a8ad 100644 --- a/tests/unit/config/test_migration_config_validation.py +++ b/tests/unit/config/test_migration_config_validation.py @@ -94,7 +94,7 @@ def test_template_keys_are_accepted(key: str, value: object) -> None: """Keys read by build_template_settings must survive validation.""" config = SqliteConfig(connection_config={"database": ":memory:"}, migration_config={key: value}) - assert config.migration_config[key] == value + assert dict(config.migration_config)[key] == value def test_template_keys_are_declared() -> None: