From: Mike Bayer Date: Wed, 6 Jan 2016 22:20:57 +0000 (-0500) Subject: - Added a new entrypoint system to the engine to allow "plugins" to X-Git-Tag: rel_1_1_0b1~84^2~59 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=c8b7729338ba32a726be72b5409b4651326042e9;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git - 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. fixes #3536 --- diff --git a/doc/build/changelog/changelog_11.rst b/doc/build/changelog/changelog_11.rst index 63e0ca472a..0c103a0d14 100644 --- a/doc/build/changelog/changelog_11.rst +++ b/doc/build/changelog/changelog_11.rst @@ -21,6 +21,19 @@ .. 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 diff --git a/doc/build/core/connections.rst b/doc/build/core/connections.rst index 72e1d6a610..a41babd29f 100644 --- a/doc/build/core/connections.rst +++ b/doc/build/core/connections.rst @@ -656,6 +656,9 @@ Connection / Engine API .. autoclass:: Connectable :members: +.. autoclass:: CreateEnginePlugin + :members: + .. autoclass:: Engine :members: diff --git a/lib/sqlalchemy/dialects/__init__.py b/lib/sqlalchemy/dialects/__init__.py index d90a838099..f851a4ab88 100644 --- a/lib/sqlalchemy/dialects/__init__.py +++ b/lib/sqlalchemy/dialects/__init__.py @@ -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 diff --git a/lib/sqlalchemy/engine/__init__.py b/lib/sqlalchemy/engine/__init__.py index 0b0d50329a..02c35d6a9d 100644 --- a/lib/sqlalchemy/engine/__init__.py +++ b/lib/sqlalchemy/engine/__init__.py @@ -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 diff --git a/lib/sqlalchemy/engine/interfaces.py b/lib/sqlalchemy/engine/interfaces.py index 3bad765dfe..41325878c7 100644 --- a/lib/sqlalchemy/engine/interfaces.py +++ b/lib/sqlalchemy/engine/interfaces.py @@ -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. diff --git a/lib/sqlalchemy/engine/strategies.py b/lib/sqlalchemy/engine/strategies.py index a539ee9f71..0d0414ed1d 100644 --- a/lib/sqlalchemy/engine/strategies.py +++ b/lib/sqlalchemy/engine/strategies.py @@ -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 diff --git a/lib/sqlalchemy/engine/url.py b/lib/sqlalchemy/engine/url.py index 32e3f8a6bd..9a955948ae 100644 --- a/lib/sqlalchemy/engine/url.py +++ b/lib/sqlalchemy/engine/url.py @@ -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. diff --git a/test/engine/test_parseconnect.py b/test/engine/test_parseconnect.py index 4601a6bda1..0e1f6c3d2e 100644 --- a/test/engine/test_parseconnect.py +++ b/test/engine/test_parseconnect.py @@ -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