]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- Added a new entrypoint system to the engine to allow "plugins" to
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 6 Jan 2016 22:20:57 +0000 (17:20 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 6 Jan 2016 22:20:57 +0000 (17:20 -0500)
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.
fixes #3536

doc/build/changelog/changelog_11.rst
doc/build/core/connections.rst
lib/sqlalchemy/dialects/__init__.py
lib/sqlalchemy/engine/__init__.py
lib/sqlalchemy/engine/interfaces.py
lib/sqlalchemy/engine/strategies.py
lib/sqlalchemy/engine/url.py
test/engine/test_parseconnect.py

index 63e0ca472aea372dba35e1c92faa42663dcee803..0c103a0d147e3615407b94b5b6cb28b09c5a167e 100644 (file)
 .. 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
index 72e1d6a610fc8774f8b7a5beada2f349aee43a66..a41babd29f29144ce738846bd557daec9b5cde11 100644 (file)
@@ -656,6 +656,9 @@ Connection / Engine API
 .. autoclass:: Connectable
    :members:
 
+.. autoclass:: CreateEnginePlugin
+   :members:
+
 .. autoclass:: Engine
    :members:
 
index d90a838099f8d14791ad1cc866d82cea847f7858..f851a4ab88f945b8c6685bc48b45cd5ab02d3c81 100644 (file)
@@ -43,3 +43,5 @@ def _auto_fn(name):
         return None
 
 registry = util.PluginLoader("sqlalchemy.dialects", auto_fn=_auto_fn)
+
+plugins = util.PluginLoader("sqlalchemy.plugins")
\ No newline at end of file
index 0b0d50329aeea318e1511ef5caebcafcdcdf8185..02c35d6a9d0a728789312e6832cea34c463b6270 100644 (file)
@@ -53,6 +53,7 @@ url.py
 
 from .interfaces import (
     Connectable,
+    CreateEnginePlugin,
     Dialect,
     ExecutionContext,
     ExceptionContext,
@@ -390,7 +391,7 @@ def engine_from_config(configuration, prefix='sqlalchemy.', **kwargs):
     """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
index 3bad765dfe466be22e413fc03916200fdab2d598..41325878c7842c529c2aea8b0635b1fad788193e 100644 (file)
@@ -781,6 +781,111 @@ class Dialect(object):
         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.
index a539ee9f715782ea22e08a7e3b0f0b2ad1ef63f1..0d0414ed1d736e4710fc6d623682b370a3f2b411 100644 (file)
@@ -48,6 +48,10 @@ class DefaultEngineStrategy(EngineStrategy):
         # 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)
 
@@ -169,6 +173,9 @@ class DefaultEngineStrategy(EngineStrategy):
         if entrypoint is not dialect_cls:
             entrypoint.engine_created(engine)
 
+        for plugin in plugins:
+            plugin.engine_created(engine)
+
         return engine
 
 
index 32e3f8a6bd0faa8acd4636090744424e0095fa36..9a955948ae6e19860de436a90126cefc2c379e2f 100644 (file)
@@ -17,7 +17,7 @@ be used directly and is also accepted directly by ``create_engine()``.
 import re
 from .. import exc, util
 from . import Dialect
-from ..dialects import registry
+from ..dialects import registry, plugins
 
 
 class URL(object):
@@ -117,6 +117,14 @@ 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.
 
index 4601a6bda15b76087ceacb797695387b109869fc..0e1f6c3d2e8f7d6f4c3474e8369f2f88f384194e 100644 (file)
@@ -6,8 +6,8 @@ import sqlalchemy as tsa
 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
 
@@ -172,7 +172,6 @@ class CreateEngineTest(fixtures.TestBase):
 
     def test_engine_from_config_custom(self):
         from sqlalchemy import util
-        from sqlalchemy.dialects import registry
         tokens = __name__.split(".")
 
         class MyDialect(MockDialect):
@@ -325,21 +324,18 @@ class CreateEngineTest(fixtures.TestBase):
 
 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
@@ -351,7 +347,6 @@ class TestRegNewDBAPI(fixtures.TestBase):
         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://")
@@ -367,7 +362,6 @@ class TestRegNewDBAPI(fixtures.TestBase):
         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
@@ -384,6 +378,39 @@ class TestRegNewDBAPI(fixtures.TestBase):
             ]
         )
 
+    @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