Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 82 additions & 14 deletions alembic/ddl/impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import re
from typing import Any
from typing import Callable
from typing import ClassVar
from typing import NamedTuple
from typing import TYPE_CHECKING

Expand All @@ -21,6 +22,8 @@
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy import text
from sqlalchemy.exc import NoSuchModuleError
from sqlalchemy.util import PluginLoader

from . import _autogen
from . import base
Expand Down Expand Up @@ -68,23 +71,77 @@
log = logging.getLogger(__name__)


class ImplMeta(type):
def __init__(
cls,
classname: str,
bases: tuple[type[DefaultImpl]],
dict_: dict[str, Any],
):
newtype = type.__init__(cls, classname, bases, dict_)
if "__dialect__" in dict_:
_impls[dict_["__dialect__"]] = cls # type: ignore[assignment]
return newtype
# PluginLoader only caches on hits, not misses.
_entrypoints_checked: set[str] = set()


registry = PluginLoader("alembic.dialects")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

Not blocking: importlib.metadata could be used directly here

alembic/runtime/plugins.py reads its entry points with importlib.metadata.entry_points(group="alembic.plugins"). Doing the same for alembic.dialects would:

  • remove the dependency on sqlalchemy.util.PluginLoader, which is an internal SQLAlchemy utility
  • remove the need for the separate _entrypoints_checked cache, since hits and misses could be kept in one dictionary
  • let the tests patch something inside Alembic instead of sqlalchemy.util.compat.importlib_metadata_get
  • avoid adding the public module-level name registry to alembic.ddl.impl, which is easy to confuse with sqlalchemy.dialects.registry

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove the dep on PluginLoader. Maybe adding it in the first place was too cute and creates too much of a burden on sqlalchemy to keep it. I don't think it adds anything either way as its purely internal.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally missed that this used PluginLoader (or it just seemed fine to me) and that we didn't use it for the plugin plugin thing. Since we didnt use it for the plugins.py thing, then yes we should stay consistent here.



_impls: dict[str, type[DefaultImpl]] = {}
class RegisterImpl:
_impls: ClassVar[dict[str, type[RegisterImpl]]] = {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

The module-level alembic.ddl.impl._impls is used by third party code

Moving the registry onto RegisterImpl removes the module attribute alembic.ddl.impl._impls. During review of the pull request the maintainer advised against keeping a backwards compatible name, on the assumption that nothing outside Alembic accesses it; the maintainer has confirmed that advice was wrong. The name is private, but it is used in practice, because assigning into it is a way to point a dialect name at an existing impl class without subclassing. A GitHub code search finds, among others:

  • application env.py files doing impl._impls["duckdb"] = PostgresqlImpl, and the same for other PostgreSQL or SQLite compatible databases
  • the leonardovida/duckdb-sqlalchemy repository (_alembic_impl._impls.get("duckdb"))
  • the test suite of cubrid-lab/sqlalchemy-cubrid (from alembic.ddl.impl import _impls)
from alembic.ddl import impl
from alembic.ddl.postgresql import PostgresqlImpl

impl._impls["duckdb"] = PostgresqlImpl
print("ok")

On main this prints ok. On this patchset it raises AttributeError: module 'alembic.ddl.impl' has no attribute '_impls', which for those users happens while env.py is being loaded.

Keeping a module-level name bound to the same dictionary avoids this at no cost, for example _impls = RegisterImpl._impls after the class.

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two options:

  1. Friendly: the suggested one of _impls = RegisterImpl._impls. Silent and keeps people on the pattern indefinitely.
  2. Draconian: if you want to force people off this, you can module level def __getattr__(name): ..., warn on name match, return RegisterImpl._impls.

Really depends on where you land on the friendly to draconian perspective. I err on side of friendly but 2 is an option.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i'd just keep it indefinitely for now. if we want to deprecate it later, that can be..later

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if we can turn _impls into a dynamic plugin registry itself. so when someone assigns into _impls it actually replaces an entrypoint that would have been there.

__dialect__: ClassVar[str]

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
cls._register()

@classmethod
def _register(cls) -> None:
dialect: str | None = getattr(cls, "__dialect__", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

getattr() makes subclasses that do not declare __dialect__ register themselves

This was changed from the "__dialect__" in cls.__dict__ check during review of the pull request, at the maintainer's suggestion. The maintainer has confirmed that the suggestion was wrong and that the original check should come back. The open question at the time was why the original looked only at the exact class, and the reason is this: DefaultImpl itself has __dialect__ = "default", so every subclass inherits a non-empty value and getattr(cls, "__dialect__", None) is never None. The __dict__ check was what kept helper and intermediate base classes out of the registry.

from sqlalchemy.dialects import postgresql
from sqlalchemy.engine import default

from alembic.ddl.impl import DefaultImpl
from alembic.ddl.postgresql import PostgresqlImpl


class SharedBase(DefaultImpl):
    """helper base for several impls; declares no __dialect__"""

    transactional_ddl = False


class PGFamilyBase(PostgresqlImpl):
    """helper base for PostgreSQL compatible databases; no __dialect__"""


class CockroachLikeImpl(PGFamilyBase):
    __dialect__ = "cockroachlike"


print(DefaultImpl.get_by_dialect(default.DefaultDialect()).__name__)
print(DefaultImpl.get_by_dialect(postgresql.dialect()).__name__)

On main this prints DefaultImpl and PostgresqlImpl. On this patchset it prints SharedBase and PGFamilyBase: a base class that never asked to be registered has replaced the "default" impl, and another has replaced the PostgreSQL impl for the whole process. Both replacements are silent, because the new class is a subclass of the existing one.

The "default" impl is the one alembic.testing.fixtures.op_fixture() uses unless told otherwise, so the test suites of third party dialects are affected too.

Restoring the "__dialect__" in cls.__dict__ check avoids this. The pull request description still describes that check, so the two would match again.

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure I'll add that back.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this is just, I need to spend a lot of hours focusing on a single issue to really see everything happening. that is very difficult to do these days. im not sure if this is because "everyone is submitting bugs and PRs because they have robots so there's more work to do" or "understanding problems is less fun because the robot can do it better" or what. will take us all some years to figure these things out

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that was why i did a deep ai review on this one, anyway. changes that involve bootstrapping behavior always have lots of surprises that I've never been good at seeing

if dialect:
existing_cls = cls._impls.get(dialect)
if existing_cls is None:
cls._impls[dialect] = cls
return

# By default, the latest registrant always wins, but a warning
# occurs when the latest registrant is not a subclass of the
# existing one. Newcomer is asked first.
newcomer_cls = cls
chosen_cls = newcomer_cls.resolve_registration_conflict(
new=newcomer_cls, existing=existing_cls
)
if chosen_cls is None:
chosen_cls = existing_cls.resolve_registration_conflict(
new=newcomer_cls, existing=existing_cls
)
if chosen_cls is None:
msg = (
"Overwriting existing registered implementation "
"%r for dialect %s with new implementation %r."
" %r will be used."
% (existing_cls, dialect, newcomer_cls, newcomer_cls)
)
if issubclass(newcomer_cls, existing_cls):
# Assumed to be intentional if newcomer subclasses
# existing; log.debug() for telemetry.
log.debug(msg)
else:
util.warn(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

Conflict handling at class creation changes behavior for existing users

The maintainer's direction for this change is that conflict resolution belongs only to third party dialects that ship an impl through the new alembic.dialects entry point. Until a dialect does that, no conflict resolution should happen at all, and declaring an impl class behaves as it does on main. A dialect that ships an entry point impl decides how conflicts with it are resolved. A conflict that nobody has resolved produces a warning, and a user can resolve it in favor of their own impl, after which the warning goes away.

On this patchset the conflict logic runs every time a class with a __dialect__ is created, whether or not an entry point is involved, so it already affects existing users. env.py is executed again for every command, so the class statement in it produces a new class object each time. From the second command on, the new copy is not a subclass of the copy registered by the previous command, and this warning is emitted.

import warnings

warnings.simplefilter("error")

env_py = """
from alembic.ddl.postgresql import PostgresqlImpl

class MyPostgresqlImpl(PostgresqlImpl):
    __dialect__ = "postgresql"
"""

# what ScriptDirectory.run_env() does once per command
for command in (1, 2):
    exec(compile(env_py, "env.py", "exec"), {"__name__": "env_py"})
    print("command", command, "ok")

On main this prints command 1 ok and command 2 ok. On this patchset the second iteration raises:

UserWarning: Overwriting existing registered implementation <class 'env_py.MyPostgresqlImpl'> for dialect postgresql with new implementation <class 'env_py.MyPostgresqlImpl'>. <class 'env_py.MyPostgresqlImpl'> will be used. To make this intentional, subclass the other implementation, or remove one of them.

Without warnings.simplefilter("error") it is a warning rather than an exception, but projects that run their tests with warnings as errors will see env.py fail to load. The advice in the message cannot be followed, since "the other implementation" is the same class from the previous command.

This applies to any process that runs more than one command, such as a test suite or code calling alembic.command functions, and to any env.py that declares an impl, including the class SnowflakeImpl(DefaultImpl) pattern that snowflake-sqlalchemy currently asks its users to add.

Under the direction above, _register() only needs to do what ImplMeta.__init__ did on main: store the class under its __dialect__, replacing whatever was there, with no warning and no hook call. Conflict resolution would then happen where the entry point is loaded, in get_by_dialect().

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't matter for CLI commands, it matters for calls within Python subproc command.upgrade(). And it's true. I assumed some sort of importlib thing was happening that would prevent this but I do see now that importlib.util.module_from_spec() is used and not importlib.import_module(). Good to know.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes env.py is not treated as a module, it's treated as a python script

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i told claude to propose a test suite for this issue as well, it's somewhere down below

f"{msg} To make this intentional,"
" subclass the other implementation, or remove one of"
" them."
)
chosen_cls = newcomer_cls
cls._impls[dialect] = chosen_cls

@classmethod
def resolve_registration_conflict(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

The hook should run at lookup time, on the entry point impl first and then on the user's impl

The maintainer's direction for this change is that conflict resolution belongs only to third party dialects that ship an impl through the new alembic.dialects entry point. Until a dialect does that, no conflict resolution should happen at all, and declaring an impl class behaves as it does on main. A dialect that ships an entry point impl decides how conflicts with it are resolved. A conflict that nobody has resolved produces a warning, and a user can resolve it in favor of their own impl, after which the warning goes away.

On this patchset the hook is called at class creation, on the new class and then on the existing one. Because the hook is inherited, a user's subclass of a third party impl runs the third party hook with cls set to the subclass, and a hook written in the obvious way gives the wrong answer:

from sqlalchemy.engine import default

from alembic.ddl.impl import DefaultImpl


class NewDbDialect(default.DefaultDialect):
    name = "newdb"


class LibraryImpl(DefaultImpl):
    __dialect__ = "newdb"

    @classmethod
    def resolve_registration_conflict(cls, new, existing):
        # "always keep the other implementation", written the same way as
        # the hook in test_entry_point_hook_keeps_env_py_impl
        return existing if new is cls else new


class UserImpl(LibraryImpl):
    __dialect__ = "newdb"


print(DefaultImpl.get_by_dialect(NewDbDialect()).__name__)

On this patchset this prints LibraryImpl. The user subclassed the library impl, which is the recommended way to override it, and the subclass was silently discarded. (On main it prints UserImpl, since the hook does not exist there.)

Calling the hook from get_by_dialect(), only when an entry point impl and a different, unrelated impl are both present for the dialect name, removes that problem and gives each side one clear role:

  • a subclass of the entry point impl never reaches the hook, because subclassing it is the explicit override; that is the first way a user resolves a conflict in their own favor
  • otherwise the hook is called on the class the entry point returned, so cls is always the third party dialect's own class and its author decides
  • if that returns None, the hook is called on the user's declared impl; a user who wants to keep an unrelated impl of their own defines the hook there and returns cls, which is the second way to resolve it, and no warning is emitted
  • if both return None the conflict is unresolved and a warning is emitted

With only two fixed roles the "new" and "existing" arguments are no longer needed, and the signature could be resolve_registration_conflict(cls, other), returning the class to use or None.

Which impl is used while a conflict is unresolved still needs to be stated. Keeping the impl the user declared would match what the maintainer wrote in the discussion ("make a loud warning, then use that user-custom extension as given for that run"), and is the maintainer's call.

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Let me get back to this one.)

cls, new: type[RegisterImpl], existing: type[RegisterImpl]
) -> type[RegisterImpl] | None:
"""Decide which implementation to register when ``new`` declares a
``__dialect__`` that is already registered to ``existing``.

Both the new and existing may enforce registration conflict behavior.
``None`` essentially means 'no opinion'. The newcomer is asked for
its opinion first; then the existing impl. If neither has an
opinion, the newcomer wins.
"""
return None


class DefaultImpl(metaclass=ImplMeta):
class DefaultImpl(RegisterImpl):
"""Provide the entrypoint for major migration operations,
including database-specific behavioral variances.

Expand Down Expand Up @@ -137,7 +194,18 @@ def __init__(

@classmethod
def get_by_dialect(cls, dialect: Dialect) -> type[DefaultImpl]:
return _impls[dialect.name]
if dialect.name not in _entrypoints_checked:
_entrypoints_checked.add(dialect.name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

A failing entry point is reported only on the first lookup

The dialect name is added to _entrypoints_checked before registry.load() runs, so if loading the entry point raises, later lookups skip the entry point entirely.

from types import SimpleNamespace
from unittest import mock

from sqlalchemy.dialects import postgresql
from sqlalchemy.engine import default

from alembic.ddl.impl import DefaultImpl


class NewDbDialect(default.DefaultDialect):
    name = "newdb"


def load():
    raise ImportError("No module named 'somedialect.alembic_impl'")


entry_points = [
    SimpleNamespace(name="newdb", load=load),
    SimpleNamespace(name="postgresql", load=load),
]

with mock.patch(
    "sqlalchemy.util.compat.importlib_metadata_get",
    side_effect=lambda group: (
        entry_points if group == "alembic.dialects" else []
    ),
):
    for dialect in (NewDbDialect(), postgresql.dialect()):
        for attempt in (1, 2):
            try:
                result = DefaultImpl.get_by_dialect(dialect).__name__
            except Exception as err:
                result = f"{type(err).__name__}: {err}"
            print(dialect.name, "attempt", attempt, "->", result)

Output on this patchset:

newdb attempt 1 -> ImportError: No module named 'somedialect.alembic_impl'
newdb attempt 2 -> NoSuchDialectError: Implementation for dialect 'newdb' not found
postgresql attempt 1 -> ImportError: No module named 'somedialect.alembic_impl'
postgresql attempt 2 -> PostgresqlImpl

For a new dialect the second attempt hides the real error behind NoSuchDialectError. For a built-in dialect name the second attempt silently uses the built-in impl, so the broken third party impl is ignored for the rest of the process. In a test suite that means one failing test followed by tests that pass against the wrong impl.

Adding the name to _entrypoints_checked only after the load has finished would report the error every time.

Related: the value returned by registry.load() is discarded, and the lookup relies on the import having registered a class under the same name as the entry point. If the entry point is named snow but the class declares __dialect__ = "snowflake", the result is NoSuchDialectError: Implementation for dialect 'snow' not found with no hint that an entry point was found and loaded. Using the returned class would make that mistake visible, and is also what resolving conflicts at lookup time needs.

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failing entry point is reported only on the first lookup

I did this deliberately. I can change it and I think the argument of resurfacing the same error again is sound and I think I prefer that. That said, the scenarios where it matters are quite contrived; e.g. you'd have to try:except an entrypoint in one case (so that the program doesn't terminate) and then hit it again in a second case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right but it mentions a test suite that might be doing things multiple times. I can see a lot of ways this kind of thing can go wrong and it also produces very difficult to debug situations where the same input changes what it does on successive runs. basically the method should do the same thing every time its called, so if it raises, it raises every time.

try:
registry.load(dialect.name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

Entry points should not be consulted for dialects that Alembic implements itself

On this patchset an installed package can declare an alembic.dialects entry point named after a built-in dialect, and its impl replaces the built-in one for every user of that dialect in the environment. If it subclasses the built-in impl, nothing is reported:

from types import SimpleNamespace
from unittest import mock

from sqlalchemy.dialects import postgresql

from alembic.ddl.impl import DefaultImpl


def load():
    # stands in for the module an installed package's entry point refers to
    from alembic.ddl.postgresql import PostgresqlImpl

    class SomePackageImpl(PostgresqlImpl):
        __dialect__ = "postgresql"

    return SomePackageImpl


entry_points = [SimpleNamespace(name="postgresql", load=load)]

with mock.patch(
    "sqlalchemy.util.compat.importlib_metadata_get",
    side_effect=lambda group: (
        entry_points if group == "alembic.dialects" else []
    ),
):
    print(DefaultImpl.get_by_dialect(postgresql.dialect()).__name__)

On this patchset, with warnings turned into errors, this prints SomePackageImpl. Installing a package is enough; nothing has to import it. test_entry_point_for_builtin_subclass_replaces_quietly asserts this behavior.

SQLAlchemy does not allow the equivalent. Its sqlalchemy.dialects registry resolves names registered in the running process first, then its built-in dialects, and only then entry points. Checked against SQLAlchemy main with an installed package declaring these entry points:

URL entry point declared resolves to
postgresql:// postgresql the built-in dialect; the entry point is ignored
postgresql+psycopg2:// postgresql.psycopg2 the built-in dialect; the entry point is ignored
postgresql+newdriver:// postgresql.newdriver the entry point's class

The maintainer has decided that Alembic should behave the same way: for the dialect names Alembic implements itself (default, mssql, mysql, mariadb, oracle, postgresql, sqlite), the entry point group is not consulted.

Nothing that works today is lost. A project that wants to customize a built-in impl still declares class MyImpl(PostgresqlImpl): __dialect__ = "postgresql" in its env.py, which is the counterpart of SQLAlchemy's explicit registry.register(). It also avoids a problem specific to Alembic: impls are keyed on dialect.name alone, so a third party driver such as postgresql+newdriver still has the name postgresql, and an entry point for it could not be limited to that driver.

The test named above would be inverted to assert that the built-in impl is still used.

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

@dwreeves dwreeves Oct 14, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was actually a little worried about this one myself.

I thought about it and figured, well why not, plus I didn't consider it a security concern because anything in a Python venv is fair game, if it's there it's there. But that was the extent of my thinking.

I actually really like the note about the parallel to SQLAlchemy as I think it makes a ton of intuitive sense to mimic how SQLA does this to the extent possible. I agree with the order of operations 100%, in process -> built-in -> entry-points makes sense. In Alembic, I think # 2 comes for free with # 1 if the implementations are eagerly loaded into __init__.py.

Fixing it could be just a matter of only checking entry-points on key miss on _impls, but that depends on whether e.g. an impl in envs.py get respected over context.configure().

^ I thought I had this behavior at first? I forget what decision was made that made us check entry-points always.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes when it found this I asked it well what does SQLAlchemy do, so that's what drove my decision that the built-in impls should not have an override path. since SQLA has been that way for 20 years and nobody has complained :)

except NoSuchModuleError:
pass
try:
return cls._impls[dialect.name] # type: ignore[return-value]
except KeyError as ke:
raise util.NoSuchDialectError(
f"Implementation for dialect {dialect.name!r} not found"
) from ke

def static_output(self, text: str) -> None:
assert self.output_buffer is not None
Expand Down
1 change: 1 addition & 0 deletions alembic/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .exc import AutogenerateDiffsDetected as AutogenerateDiffsDetected
from .exc import CommandError as CommandError
from .exc import DatabaseNotAtHead as DatabaseNotAtHead
from .exc import NoSuchDialectError as NoSuchDialectError
from .langhelpers import _with_legacy_names as _with_legacy_names
from .langhelpers import asbool as asbool
from .langhelpers import dedupe_tuple as dedupe_tuple
Expand Down
8 changes: 8 additions & 0 deletions alembic/util/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,11 @@ def __init__(
super().__init__(message)
self.revision_context = revision_context
self.diffs = diffs


class NoSuchDialectError(CommandError):
"""Raised when an implementation for a dialect is not in the registry.

.. versionadded:: 1.21.0

"""
44 changes: 44 additions & 0 deletions docs/build/api/ddl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,50 @@ and :ref:`sqlalchemy.ext.compiler_toplevel` systems.
For programmatic usage of Alembic's migration directives, the easiest
route is to use the higher level functions given by :ref:`alembic.operations.toplevel`.

.. _alembic.ddl.third_party:

Implementations for Third Party Dialects
========================================

Alembic selects a :class:`.DefaultImpl` based on the name of the SQLAlchemy
dialect being used. If an implementation does not exist for a dialect,
Alembic will raise :class:`.NoSuchDialectError`. Third-party SQLAlchemy
libraries are therefore encouraged to implement their own
:class:`.DefaultImpl`.

Declaring an implementation
---------------------------

Subclass :class:`.DefaultImpl` and set ``__dialect__``::

from alembic.ddl.impl import DefaultImpl

class NewDbImpl(DefaultImpl):
__dialect__ = "newdb"

The ``RegisterImpl.__init_subclass__`` hook will register the implementation
to map to the ``__dialect__``.

Registering via entry point
---------------------------

.. versionadded:: 1.21.0

Third-party implementations can be registered via entry-points.
The name of the entry-point should match the name of the SQLAlchemy dialect:

.. code-block:: toml

[project.entry-points."alembic.dialects"]
newdb = "newdb_sqlalchemy.alembic:NewDbImpl"

When the ``context.configure()`` in ``env.py`` builds a
:class:`.MigrationContext`, Alembic will scan for entry-points matching to
the dialect required for the migration.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

The documentation does not cover conflicts, the hook, or eager registration

This section explains how to register through an entry point but not what happens when an impl for the same dialect name already exists. That is the situation any existing third party dialect adopting the entry point will put its users in, since their env.py may already declare an impl for that name.

The changelog entry links to RegisterImpl.resolve_registration_conflict, but nothing here describes it. For authors of third party dialects it would help to state:

  • that a conflict only arises for an impl loaded through the entry point, and that declaring an impl class otherwise behaves as before
  • how to write resolve_registration_conflict, with an example, and what happens when it is not overridden
  • that the class the entry point refers to must declare a __dialect__ equal to the entry point name
  • that entry points are not consulted for the dialects Alembic implements itself
  • that a dialect which ships an entry point impl should not also import that impl when the dialect module is loaded. If it does, the impl registers itself as a side effect of creating the engine, after env.py has declared the user's impl and before Alembic looks the impl up. The user's impl has then already been replaced by the time the entry point is consulted, so no conflict is seen and the hook does not run. This is how eagerly registered impls behave on main today, so it is not a regression, but the hook is only dependable for a dialect that leaves loading to the entry point

For users it would help to state the two ways to keep their own impl without a warning: subclass the dialect's impl, or define resolve_registration_conflict on their own impl.

Separately, "The RegisterImpl.__init_subclass__ hook will register the implementation" describes an internal detail; "the class is registered when it is defined" says the same thing without naming internals.

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048

@dwreeves dwreeves Oct 14, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AI has given some good feedback so far but this is the first one I'll really push back on.

My 2 cents: I have an opinion that if this behavior needs to be documented that we've failed in a sense. 99.99% of Alembic users are not writing their own Impls and 90% of people writing their own Impls shouldn't need to worry about conflict resolution. This is therefore useful to 0.001% of people, and they're probably using AI anyway. This is so incredibly in the weeds and irrelevant to almost everyone that it's embarrassing to write about it.

If I were to expand the docs I'd rather write more on just the entry-point stuff in general; I'm sure there is more to say, but maybe not so much about conflict resolution if it is designed right.

@zzzeek zzzeek Oct 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this is what happens when you select "high" effort on your model....

Obviously the method itself for "resolve_registration_conflict" should have a docstring (it does). it also needs a "..versionadded:: 1.21.0" note (this will be in 1.21.0 on current schedules).

it looks like you have a short example there, so that covers "that the class the entry point refers to must declare a __dialect__ equal to the entry point name" more or less.

I think mentioning the resolve_registration_conflict() method's existence is a good idea, like "for 3rd party dialects where the userbase may have been using a recipe to handle that dialect's functionality previously, the resolve_registration_conflict hook should be considered...". I think when this hook is mentioned, or maybe in the docstring for the hook, there should be a .. note:: the conflict resolution hook will not run if the entrypoint dialect also imports itself into _impl.... . I think we need to mention that.

that is I think the resolve_registration_conflict() method's own docstring gives us a good place to note these things, and only people writing their own impl are going to find this method.


Built-In Implementation Reference
=================================

.. automodule:: alembic.ddl
:members:
:undoc-members:
Expand Down
3 changes: 3 additions & 0 deletions docs/build/api/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ detect changes to them.
operations and implementations that would be suitable for packaging
as a plugin

:ref:`alembic.ddl.third_party` - Plugins are not used to register
implementations for third party dialects; see this section instead

Installing and Using Plugins
============================

Expand Down
23 changes: 23 additions & 0 deletions docs/build/unreleased/1872.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.. change::
:tags: feature, api
:tickets: 1872

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Robot 1 (ai-robot-1) wrote:

Ticket number

1872 is the discussion. The issue this change fixes is 1874, so this should be :tickets: 1874 and the file should be named 1874.rst.

View this in Gerrit at https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/7048


Added the ``alembic.dialects`` entry point group, which allows third party
dialects to register a :class:`.DefaultImpl` subclass for their dialect
without requiring it to be eagerly loaded.

When a :class:`.DefaultImpl` subclass declares a ``__dialect__`` that is
already registered and it is not a subclass of the existing
implementation, a warning is now presented. The new
:meth:`.RegisterImpl.resolve_registration_conflict` hook is used to resolve
conflicts; by default, the latest registered subclass wins, emulating
the existing behavior.

An unknown dialect name now raises :class:`.NoSuchDialectError`, which
is a subclass of :class:`.CommandError`. Registration now uses a
``RegisterImpl`` base class, and the ``ImplMeta`` metaclass has been
removed. Pull request courtesy Daniel Reeves.

.. seealso::

:ref:`alembic.ddl.third_party`
Loading
Loading