.. changelog::
:version: 1.1.0b1
+ .. change::
+ :tags: feature, engine
+ :tickets: 3536
+
+ Added a new entrypoint system to the engine to allow "plugins" to
+ be stated in the query string for a URL. Custom plugins can
+ be written which will be given the chance up front to alter and/or
+ consume the engine's URL and keyword arguments, and then at engine
+ create time will be given the engine itself to allow additional
+ modifications or event registration. Plugins are written as a
+ subclass of :class:`.CreateEnginePlugin`; see that class for
+ details.
+
.. change::
:tags: feature, mysql
:tickets: 3547
.. autoclass:: Connectable
:members:
+.. autoclass:: CreateEnginePlugin
+ :members:
+
.. autoclass:: Engine
:members:
return None
registry = util.PluginLoader("sqlalchemy.dialects", auto_fn=_auto_fn)
+
+plugins = util.PluginLoader("sqlalchemy.plugins")
\ No newline at end of file
from .interfaces import (
Connectable,
+ CreateEnginePlugin,
Dialect,
ExecutionContext,
ExceptionContext,
"""Create a new Engine instance using a configuration dictionary.
The dictionary is typically produced from a config file.
-
+
The keys of interest to ``engine_from_config()`` should be prefixed, e.g.
``sqlalchemy.url``, ``sqlalchemy.echo``, etc. The 'prefix' argument
indicates the prefix to be searched for. Each matching key (after the
pass
+class CreateEnginePlugin(object):
+ """A set of hooks intended to augment the construction of an
+ :class:`.Engine` object based on entrypoint names in a URL.
+
+ The purpose of :class:`.CreateEnginePlugin` is to allow third-party
+ systems to apply engine, pool and dialect level event listeners without
+ the need for the target application to be modified; instead, the plugin
+ names can be added to the database URL. Target applications for
+ :class:`.CreateEnginePlugin` include:
+
+ * connection and SQL performance tools, e.g. which use events to track
+ number of checkouts and/or time spent with statements
+
+ * connectivity plugins such as proxies
+
+ Plugins are registered using entry points in a similar way as that
+ of dialects::
+
+ entry_points={
+ 'sqlalchemy.plugins': [
+ 'myplugin = myapp.plugins:MyPlugin'
+ ]
+
+ A plugin that uses the above names would be invoked from a database
+ URL as in::
+
+ from sqlalchemy import create_engine
+
+ engine = create_engine(
+ "mysql+pymysql://scott:tiger@localhost/test?plugin=myplugin")
+
+ The ``plugin`` argument supports multiple instances, so that a URL
+ may specify multiple plugins; they are loaded in the order stated
+ in the URL::
+
+ engine = create_engine(
+ "mysql+pymysql://scott:tiger@localhost/"
+ "test?plugin=plugin_one&plugin=plugin_twp&plugin=plugin_three")
+
+ A plugin can receive additional arguments from the URL string as
+ well as from the keyword arguments passed to :func:`.create_engine`.
+ The :class:`.URL` object and the keyword dictionary are passed to the
+ constructor so that these arguments can be extracted from the url's
+ :attr:`.URL.query` collection as well as from the dictionary::
+
+ class MyPlugin(CreateEnginePlugin):
+ def __init__(self, url, kwargs):
+ self.my_argument_one = url.query.pop('my_argument_one')
+ self.my_argument_two = url.query.pop('my_argument_two')
+ self.my_argument_three = kwargs.pop('my_argument_three', None)
+
+ Arguments like those illustrated above would be consumed from the
+ following::
+
+ from sqlalchemy import create_engine
+
+ engine = create_engine(
+ "mysql+pymysql://scott:tiger@localhost/"
+ "test?plugin=myplugin&my_argument_one=foo&my_argument_two=bar",
+ my_argument_three='bat')
+
+ The URL and dictionary are used for subsequent setup of the engine
+ as they are, so the plugin can modify their arguments in-place.
+ Arguments that are only understood by the plugin should be popped
+ or otherwise removed so that they aren't interpreted as erroneous
+ arguments afterwards.
+
+ When the engine creation process completes and produces the
+ :class:`.Engine` object, it is again passed to the plugin via the
+ :meth:`.CreateEnginePlugin.engine_created` hook. In this hook, additional
+ changes can be made to the engine, most typically involving setup of
+ events (e.g. those defined in :ref:`core_event_toplevel`).
+
+ .. versionadded:: 1.1
+
+ """
+ def __init__(self, url, kwargs):
+ """Contruct a new :class:`.CreateEnginePlugin`.
+
+ The plugin object is instantiated individually for each call
+ to :func:`.create_engine`. A single :class:`.Engine` will be
+ passed to the :meth:`.CreateEnginePlugin.engine_created` method
+ corresponding to this URL.
+
+ :param url: the :class:`.URL` object. The plugin should inspect
+ what it needs here as well as remove its custom arguments from the
+ :attr:`.URL.query` collection. The URL can be modified in-place
+ in any other way as well.
+ :param kwargs: The keyword arguments passed to :func`.create_engine`.
+ The plugin can read and modify this dictionary in-place, to affect
+ the ultimate arguments used to create the engine. It should
+ remove its custom arguments from the dictionary as well.
+
+ """
+ self.url = url
+
+ def engine_created(self, engine):
+ """Receive the :class:`.Engine` object when it is fully constructed.
+
+ The plugin may make additional changes to the engine, such as
+ registering engine or connection pool events.
+
+ """
+
+
class ExecutionContext(object):
"""A messenger object for a Dialect that corresponds to a single
execution.
# create url.URL object
u = url.make_url(name_or_url)
+ plugins = u._instantiate_plugins(kwargs)
+
+ u.query.pop('plugin', None)
+
entrypoint = u._get_entrypoint()
dialect_cls = entrypoint.get_dialect_cls(u)
if entrypoint is not dialect_cls:
entrypoint.engine_created(engine)
+ for plugin in plugins:
+ plugin.engine_created(engine)
+
return engine
import re
from .. import exc, util
from . import Dialect
-from ..dialects import registry
+from ..dialects import registry, plugins
class URL(object):
else:
return self.drivername.split('+')[1]
+ def _instantiate_plugins(self, kwargs):
+ plugin_names = util.to_list(self.query.get('plugin', ()))
+
+ return [
+ plugins.load(plugin_name)(self, kwargs)
+ for plugin_name in plugin_names
+ ]
+
def _get_entrypoint(self):
"""Return the "entry point" dialect class.
from sqlalchemy.testing import fixtures
from sqlalchemy import testing
from sqlalchemy.testing.mock import Mock, MagicMock, call
-from sqlalchemy import event
-from sqlalchemy import select
+from sqlalchemy.dialects import registry
+from sqlalchemy.dialects import plugins
dialect = None
def test_engine_from_config_custom(self):
from sqlalchemy import util
- from sqlalchemy.dialects import registry
tokens = __name__.split(".")
class MyDialect(MockDialect):
class TestRegNewDBAPI(fixtures.TestBase):
def test_register_base(self):
- from sqlalchemy.dialects import registry
registry.register("mockdialect", __name__, "MockDialect")
e = create_engine("mockdialect://")
assert isinstance(e.dialect, MockDialect)
def test_register_dotted(self):
- from sqlalchemy.dialects import registry
registry.register("mockdialect.foob", __name__, "MockDialect")
e = create_engine("mockdialect+foob://")
assert isinstance(e.dialect, MockDialect)
def test_register_legacy(self):
- from sqlalchemy.dialects import registry
tokens = __name__.split(".")
global dialect
assert isinstance(e.dialect, MockDialect)
def test_register_per_dbapi(self):
- from sqlalchemy.dialects import registry
registry.register("mysql.my_mock_dialect", __name__, "MockDialect")
e = create_engine("mysql+my_mock_dialect://")
WrapperFactory = Mock()
WrapperFactory.get_dialect_cls.side_effect = get_dialect_cls
- from sqlalchemy.dialects import registry
registry.register("wrapperdialect", __name__, "WrapperFactory")
from sqlalchemy.dialects import sqlite
]
)
+ @testing.requires.sqlite
+ def test_plugin_registration(self):
+ from sqlalchemy.dialects import sqlite
+
+ global MyEnginePlugin
+
+ def side_effect(url, kw):
+ eq_(kw, {"logging_name": "foob"})
+ kw['logging_name'] = 'bar'
+ return MyEnginePlugin
+
+ MyEnginePlugin = Mock(side_effect=side_effect)
+
+ plugins.register("engineplugin", __name__, "MyEnginePlugin")
+
+ e = create_engine(
+ "sqlite:///?plugin=engineplugin&foo=bar", logging_name='foob')
+ eq_(e.dialect.name, "sqlite")
+ eq_(e.logging_name, "bar")
+ assert isinstance(e.dialect, sqlite.dialect)
+
+ eq_(
+ MyEnginePlugin.mock_calls,
+ [
+ call(e.url, {}),
+ call.engine_created(e)
+ ]
+ )
+ eq_(
+ str(MyEnginePlugin.mock_calls[0][1][0]),
+ "sqlite:///?foo=bar"
+ )
+
class MockDialect(DefaultDialect):
@classmethod