diff options
-rw-r--r-- | doc/build/changelog/migration_14.rst | 151 | ||||
-rw-r--r-- | doc/build/changelog/unreleased_14/5526.rst | 12 | ||||
-rw-r--r-- | doc/build/conf.py | 2 | ||||
-rw-r--r-- | doc/build/core/engines.rst | 66 | ||||
-rw-r--r-- | doc/build/core/internals.rst | 6 | ||||
-rw-r--r-- | lib/sqlalchemy/dialects/mysql/provision.py | 11 | ||||
-rw-r--r-- | lib/sqlalchemy/dialects/oracle/provision.py | 4 | ||||
-rw-r--r-- | lib/sqlalchemy/dialects/sqlite/pysqlite.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/engine/__init__.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/engine/base.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/engine/create.py | 5 | ||||
-rw-r--r-- | lib/sqlalchemy/engine/interfaces.py | 195 | ||||
-rw-r--r-- | lib/sqlalchemy/engine/result.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/engine/url.py | 579 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/assertions.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/assertsql.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/provision.py | 26 | ||||
-rw-r--r-- | test/engine/test_execute.py | 2 | ||||
-rw-r--r-- | test/engine/test_parseconnect.py | 229 | ||||
-rw-r--r-- | test/engine/test_reconnect.py | 6 |
20 files changed, 1140 insertions, 166 deletions
diff --git a/doc/build/changelog/migration_14.rst b/doc/build/changelog/migration_14.rst index 14584fd43..e21c53ef6 100644 --- a/doc/build/changelog/migration_14.rst +++ b/doc/build/changelog/migration_14.rst @@ -577,6 +577,157 @@ producing:: SELECT address.email_address, user.name FROM user JOIN address ON user.id == address.user_id +.. _change_5526: + +The URL object is now immutable +------------------------------- + +The :class:`_engine.URL` object has been formalized such that it now presents +itself as a ``namedtuple`` with a fixed number of fields that are immutable. In +addition, the dictionary represented by the :attr:`_engine.URL.query` attribute +is also an immutable mapping. Mutation of the :class:`_engine.URL` object was +not a formally supported or documented use case which led to some open-ended +use cases that made it very difficult to intercept incorrect usages, most +commonly mutation of the :attr:`_engine.URL.query` dictionary to include non-string elements. +It also led to all the common problems of allowing mutability in a fundamental +data object, namely unwanted mutations elsewhere leaking into code that didn't +expect the URL to change. Finally, the namedtuple design is inspired by that +of Python's ``urllib.parse.urlparse()`` which returns the parsed object as a +named tuple. + +The decision to change the API outright is based on a calculus weighing the +infeasability of a deprecation path (which would involve changing the +:attr:`_engine.URL.query` dictionary to be a special dictionary that emits deprecation +warnings when any kind of standard library mutation methods are invoked, in +addition that when the dictionary would hold any kind of list of elements, the +list would also have to emit deprecation warnings on mutation) against the +unlikely use case of projects already mutating :class:`_engine.URL` objects in +the first place, as well as that small changes such as that of :ticket:`5341` +were creating backwards-incompatibility in any case. The primary case for +mutation of a +:class:`_engine.URL` object is that of parsing plugin arguments within the +:class:`_engine.CreateEnginePlugin` extension point, itself a fairly recent +addition that based on Github code search is in use by two repositories, +neither of which are actually mutating the URL object. + +The :class:`_engine.URL` object now provides a rich interface inspecting +and generating new :class:`_engine.URL` objects. The +existing mechanism to create a :class:`_engine.URL` object, the +:func:`_engine.make_url` function, remains unchanged:: + + >>> from sqlalchemy.engine import make_url + >>> url = make_url("postgresql+psycopg2://user:pass@host/dbname") + +For programmatic construction, code that may have been using the +:class:`_engine.URL` constructor or ``__init__`` method directly will +receive a deprecation warning if arguments are passed as keyword arguments +and not an exact 7-tuple. The keyword-style constructor is now available +via the :meth:`_engine.URL.create` method:: + + >>> from sqlalchemy.engine import URL + >>> url = URL.create("postgresql", "user", "pass", host="host", database="dbname") + >>> str(url) + 'postgresql://user:pass@host/dbname' + + +Fields can be altered typically using the :meth:`_engine.URL.set` method, which +returns a new :class:`_engine.URL` object with changes applied:: + + >>> mysql_url = url.set(drivername="mysql+pymysql") + >>> str(mysql_url) + 'mysql+pymysql://user:pass@host/dbname' + +To alter the contents of the :attr:`_engine.URL.query` dictionary, methods +such as :meth:`_engine.URL.update_query_dict` may be used:: + + >>> url.update_query_dict({"sslcert": '/path/to/crt'}) + postgresql://user:***@host/dbname?sslcert=%2Fpath%2Fto%2Fcrt + +To upgrade code that is mutating these fields directly, a **backwards and +forwards compatible approach** is to use a duck-typing, as in the following +style:: + + def set_url_drivername(some_url, some_drivername): + # check for 1.4 + if hasattr(some_url, "set"): + return some_url.set(drivername=some_drivername) + else: + # SQLAlchemy 1.3 or earlier, mutate in place + some_url.drivername = some_drivername + return some_url + + def set_ssl_cert(some_url, ssl_cert): + # check for 1.4 + if hasattr(some_url, "update_query_dict"): + return some_url.update_query_dict({"sslcert": ssl_cert}) + else: + # SQLAlchemy 1.3 or earlier, mutate in place + some_url.query["sslcert"] = ssl_cert + return some_url + +The query string retains its existing format as a dictionary of strings +to strings, using sequences of strings to represent multiple parameters. +For example:: + + >>> from sqlalchemy.engine import make_url + >>> url = make_url("postgresql://user:pass@host/dbname?alt_host=host1&alt_host=host2&sslcert=%2Fpath%2Fto%2Fcrt") + >>> url.query + immutabledict({'alt_host': ('host1', 'host2'), 'sslcert': '/path/to/crt'}) + +To work with the contents of the :attr:`_engine.URL.query` attribute such that all values are +normalized into sequences, use the :attr:`_engine.URL.normalized_query` attribute:: + + >>> url.normalized_query + immutabledict({'alt_host': ('host1', 'host2'), 'sslcert': ('/path/to/crt',)}) + +The query string can be appended to via methods such as :meth:`_engine.URL.update_query_dict`, +:meth:`_engine.URL.update_query_pairs`, :meth:`_engine.URL.update_query_string`:: + + >>> url.update_query_dict({"alt_host": "host3"}, append=True) + postgresql://user:***@host/dbname?alt_host=host1&alt_host=host2&alt_host=host3&sslcert=%2Fpath%2Fto%2Fcrt + +.. seealso:: + + :class:`_engine.URL` + + +Changes to CreateEnginePlugin +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The :class:`_engine.CreateEnginePlugin` is also impacted by this change, +as the documentation for custom plugins indicated that the ``dict.pop()`` +method should be used to remove consumed arguments from the URL object. This +should now be acheived using the :meth:`_engine.CreateEnginePlugin.update_url` +method. A backwards compatible approach would look like:: + + from sqlalchemy.engine import CreateEnginePlugin + + class MyPlugin(CreateEnginePlugin): + def __init__(self, url, kwargs): + # check for 1.4 style + if hasattr(CreateEnginePlugin, "update_url"): + self.my_argument_one = url.query['my_argument_one'] + self.my_argument_two = url.query['my_argument_two'] + else: + # legacy + 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) + + def update_url(self, url): + # this method runs in 1.4 only and should be used to consume + # plugin-specific arguments + return url.difference_update_query( + ["my_argument_one", "my_argument_two"] + ) + +See the docstring at :class:`_engine.CreateEnginePlugin` for complete details +on how this class is used. + +:ticket:`5526` + + .. _change_5284: select(), case() now accept positional expressions diff --git a/doc/build/changelog/unreleased_14/5526.rst b/doc/build/changelog/unreleased_14/5526.rst new file mode 100644 index 000000000..34793565f --- /dev/null +++ b/doc/build/changelog/unreleased_14/5526.rst @@ -0,0 +1,12 @@ +.. change:: + :tags: change, engine + :tickets: 5526 + + The :class:`_engine.URL` object is now an immutable named tuple. To modify + a URL object, use the :meth:`_engine.URL.set` method to produce a new URL + object. + + .. seealso:: + + :ref:`change_5526` - notes on migration + diff --git a/doc/build/conf.py b/doc/build/conf.py index d4fdf58a0..857bec649 100644 --- a/doc/build/conf.py +++ b/doc/build/conf.py @@ -103,6 +103,7 @@ autodocmods_convert_modname = { "sqlalchemy.sql.base": "sqlalchemy.sql.expression", "sqlalchemy.event.base": "sqlalchemy.event", "sqlalchemy.engine.base": "sqlalchemy.engine", + "sqlalchemy.engine.url": "sqlalchemy.engine", "sqlalchemy.engine.row": "sqlalchemy.engine", "sqlalchemy.engine.cursor": "sqlalchemy.engine", "sqlalchemy.engine.result": "sqlalchemy.engine", @@ -127,6 +128,7 @@ autodocmods_convert_modname_w_class = { zzzeeksphinx_module_prefixes = { "_sa": "sqlalchemy", "_engine": "sqlalchemy.engine", + "_url": "sqlalchemy.engine", "_result": "sqlalchemy.engine", "_row": "sqlalchemy.engine", "_schema": "sqlalchemy.schema", diff --git a/doc/build/core/engines.rst b/doc/build/core/engines.rst index 2404414d1..02495404c 100644 --- a/doc/build/core/engines.rst +++ b/doc/build/core/engines.rst @@ -194,12 +194,74 @@ Engine Creation API .. autofunction:: sqlalchemy.create_mock_engine -.. autofunction:: sqlalchemy.engine.url.make_url +.. autofunction:: sqlalchemy.engine.make_url -.. autoclass:: sqlalchemy.engine.url.URL +.. autoclass:: sqlalchemy.engine.URL :members: + .. py:attribute:: drivername + :annotation: str + + database backend and driver name, such as + ``postgresql+psycopg2`` + + .. py:attribute:: username + :annotation: str + + username string + + .. py:attribute:: password + :annotation: str + + password, which is normally a string but may also be any + object that has a ``__str__()`` method. + + .. py:attribute:: host + :annotation: str + + string hostname + + .. py:attribute:: port + :annotation: int + + integer port number + + .. py:attribute:: database + :annotation: str + + string database name + + .. py:attribute:: query + :annotation: Mapping[str, Union[str, Sequence[str]]] + + an immutable mapping representing the query string. contains strings + for keys and either strings or tuples of strings for values, e.g.:: + + >>> from sqlalchemy.engine import make_url + >>> url = make_url("postgresql://user:pass@host/dbname?alt_host=host1&alt_host=host2&ssl_cipher=%2Fpath%2Fto%2Fcrt") + >>> url.query + immutabledict({'alt_host': ('host1', 'host2'), 'ssl_cipher': '/path/to/crt'}) + + To create a mutable copy of this mapping, use the ``dict`` constructor:: + + mutable_query_opts = dict(url.query) + + .. seealso:: + + :attr:`_engine.URL.normalized_query` - normalizes all values into sequences + for consistent processing + + Methods for altering the contents of :attr:`_engine.URL.query`: + + :meth:`_engine.URL.update_query_dict` + + :meth:`_engine.URL.update_query_string` + + :meth:`_engine.URL.update_query_pairs` + + :meth:`_engine.URL.difference_update_query` + Pooling ======= diff --git a/doc/build/core/internals.rst b/doc/build/core/internals.rst index 965c03fd9..34bf0407c 100644 --- a/doc/build/core/internals.rst +++ b/doc/build/core/internals.rst @@ -7,7 +7,7 @@ Some key internal constructs are listed here. .. currentmodule:: sqlalchemy -.. autoclass:: sqlalchemy.engine.interfaces.Compiled +.. autoclass:: sqlalchemy.engine.Compiled :members: .. autoclass:: sqlalchemy.sql.compiler.DDLCompiler @@ -18,14 +18,14 @@ Some key internal constructs are listed here. :members: :inherited-members: -.. autoclass:: sqlalchemy.engine.interfaces.Dialect +.. autoclass:: sqlalchemy.engine.Dialect :members: .. autoclass:: sqlalchemy.engine.default.DefaultExecutionContext :members: -.. autoclass:: sqlalchemy.engine.interfaces.ExecutionContext +.. autoclass:: sqlalchemy.engine.ExecutionContext :members: diff --git a/lib/sqlalchemy/dialects/mysql/provision.py b/lib/sqlalchemy/dialects/mysql/provision.py index bbe752d78..a1d82222d 100644 --- a/lib/sqlalchemy/dialects/mysql/provision.py +++ b/lib/sqlalchemy/dialects/mysql/provision.py @@ -1,5 +1,3 @@ -import copy - from ... import exc from ...testing.provision import configure_follower from ...testing.provision import create_db @@ -9,7 +7,7 @@ from ...testing.provision import temp_table_keyword_args @generate_driver_url.for_db("mysql", "mariadb") -def generate_driver_url(url, driver, query): +def generate_driver_url(url, driver, query_str): backend = url.get_backend_name() if backend == "mysql": @@ -17,10 +15,9 @@ def generate_driver_url(url, driver, query): if dialect_cls._is_mariadb_from_url(url): backend = "mariadb" - new_url = copy.copy(url) - new_url.query = dict(new_url.query) - new_url.drivername = "%s+%s" % (backend, driver) - new_url.query.update(query) + new_url = url.set( + drivername="%s+%s" % (backend, driver) + ).update_query_string(query_str) try: new_url.get_dialect() diff --git a/lib/sqlalchemy/dialects/oracle/provision.py b/lib/sqlalchemy/dialects/oracle/provision.py index 9de14bff0..01854fdce 100644 --- a/lib/sqlalchemy/dialects/oracle/provision.py +++ b/lib/sqlalchemy/dialects/oracle/provision.py @@ -97,9 +97,7 @@ def _reap_oracle_dbs(url, idents): @follower_url_from_main.for_db("oracle") def _oracle_follower_url_from_main(url, ident): url = sa_url.make_url(url) - url.username = ident - url.password = "xe" - return url + return url.set(username=ident, password="xe") @temp_table_keyword_args.for_db("oracle") diff --git a/lib/sqlalchemy/dialects/sqlite/pysqlite.py b/lib/sqlalchemy/dialects/sqlite/pysqlite.py index 8da2a0323..3c88dab8e 100644 --- a/lib/sqlalchemy/dialects/sqlite/pysqlite.py +++ b/lib/sqlalchemy/dialects/sqlite/pysqlite.py @@ -491,7 +491,7 @@ class SQLiteDialect_pysqlite(SQLiteDialect): util.coerce_kw_type(opts, key, type_, dest=pysqlite_opts) if pysqlite_opts.get("uri", False): - uri_opts = opts.copy() + uri_opts = dict(opts) # here, we are actually separating the parameters that go to # sqlite3/pysqlite vs. those that go the SQLite URI. What if # two names conflict? again, this seems to be not the case right diff --git a/lib/sqlalchemy/engine/__init__.py b/lib/sqlalchemy/engine/__init__.py index 625c26d2d..7523f3b26 100644 --- a/lib/sqlalchemy/engine/__init__.py +++ b/lib/sqlalchemy/engine/__init__.py @@ -52,6 +52,8 @@ from .row import BaseRow # noqa from .row import LegacyRow # noqa from .row import Row # noqa from .row import RowMapping # noqa +from .url import make_url # noqa +from .url import URL # noqa from .util import connection_memoize # noqa from ..sql import ddl # noqa diff --git a/lib/sqlalchemy/engine/base.py b/lib/sqlalchemy/engine/base.py index 0eaa1fae1..91fff4549 100644 --- a/lib/sqlalchemy/engine/base.py +++ b/lib/sqlalchemy/engine/base.py @@ -2630,7 +2630,7 @@ class Engine(Connectable, log.Identified): echo = log.echo_property() def __repr__(self): - return "Engine(%r)" % self.url + return "Engine(%r)" % (self.url,) def dispose(self): """Dispose of the connection pool used by this diff --git a/lib/sqlalchemy/engine/create.py b/lib/sqlalchemy/engine/create.py index 66173d9b0..e31f3a12d 100644 --- a/lib/sqlalchemy/engine/create.py +++ b/lib/sqlalchemy/engine/create.py @@ -486,10 +486,7 @@ def create_engine(url, **kwargs): # create url.URL object u = _url.make_url(url) - plugins = u._instantiate_plugins(kwargs) - - u.query.pop("plugin", None) - kwargs.pop("plugins", None) + u, plugins, kwargs = u._instantiate_plugins(kwargs) entrypoint = u._get_entrypoint() dialect_cls = entrypoint.get_dialect_cls(u) diff --git a/lib/sqlalchemy/engine/interfaces.py b/lib/sqlalchemy/engine/interfaces.py index 7d51ab159..e0e4a9a83 100644 --- a/lib/sqlalchemy/engine/interfaces.py +++ b/lib/sqlalchemy/engine/interfaces.py @@ -10,6 +10,11 @@ from .. import util from ..sql.compiler import Compiled # noqa from ..sql.compiler import TypeCompiler # noqa +from ..util import compat + +if compat.TYPE_CHECKING: + from typing import Any + from .url import URL class Dialect(object): @@ -926,23 +931,62 @@ class CreateEnginePlugin(object): """A set of hooks intended to augment the construction of an :class:`_engine.Engine` object based on entrypoint names in a URL. - The purpose of :class:`.CreateEnginePlugin` is to allow third-party + The purpose of :class:`_engine.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: + :class:`_engine.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 + A rudimentary :class:`_engine.CreateEnginePlugin` that attaches a logger + to an :class:`_engine.Engine` object might look like:: + + + import logging + + from sqlalchemy.engine import CreateEnginePlugin + from sqlalchemy import event + + class LogCursorEventsPlugin(CreateEnginePlugin): + def __init__(self, url, kwargs): + # consume the parameter "log_cursor_logging_name" from the + # URL query + logging_name = url.query.get("log_cursor_logging_name", "log_cursor") + + self.log = logging.getLogger(logging_name) + + def update_url(self, url): + "update the URL to one that no longer includes our parameters" + return url.difference_update_query(["log_cursor_logging_name"]) + + def engine_created(self, engine): + "attach an event listener after the new Engine is constructed" + event.listen(engine, "before_cursor_execute", self._log_event) + + + def _log_event( + self, + conn, + cursor, + statement, + parameters, + context, + executemany): + + self.log.info("Plugin logged cursor event: %s", statement) + + + Plugins are registered using entry points in a similar way as that of dialects:: entry_points={ 'sqlalchemy.plugins': [ - 'myplugin = myapp.plugins:MyPlugin' + 'log_cursor_plugin = myapp.plugins:LogCursorEventsPlugin' ] A plugin that uses the above names would be invoked from a database @@ -951,10 +995,20 @@ class CreateEnginePlugin(object): from sqlalchemy import create_engine engine = create_engine( - "mysql+pymysql://scott:tiger@localhost/test?plugin=myplugin") + "mysql+pymysql://scott:tiger@localhost/test?" + "plugin=log_cursor_plugin&log_cursor_logging_name=mylogger" + ) - Alternatively, the :paramref:`_sa.create_engine.plugins" argument may be - passed as a list to :func:`_sa.create_engine`:: + The ``plugin`` URL parameter 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") + + The plugin names may also be passed directly to :func:`_sa.create_engine` + using the :paramref:`_sa.create_engine.plugins` argument:: engine = create_engine( "mysql+pymysql://scott:tiger@localhost/test", @@ -963,53 +1017,93 @@ class CreateEnginePlugin(object): .. versionadded:: 1.2.3 plugin names can also be specified to :func:`_sa.create_engine` as a list - 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:`_sa.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:: + A plugin may consume plugin-specific arguments from the + :class:`_engine.URL` object as well as the ``kwargs`` dictionary, which is + the dictionary of arguments passed to the :func:`_sa.create_engine` + call. "Consuming" these arguments includes that they must be removed + when the plugin initializes, so that the arguments are not passed along + to the :class:`_engine.Dialect` constructor, where they will raise an + :class:`_exc.ArgumentError` because they are not known by the dialect. + + As of version 1.4 of SQLAlchemy, arguments should continue to be consumed + from the ``kwargs`` dictionary directly, by removing the values with a + method such as ``dict.pop``. Arguments from the :class:`_engine.URL` object + should be consumed by implementing the + :meth:`_engine.CreateEnginePlugin.update_url` method, returning a new copy + of the :class:`_engine.URL` with plugin-specific parameters removed:: 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_one = url.query['my_argument_one'] + self.my_argument_two = url.query['my_argument_two'] self.my_argument_three = kwargs.pop('my_argument_three', None) - Arguments like those illustrated above would be consumed from the - following:: + def update_url(self, url): + return url.difference_update_query( + ["my_argument_one", "my_argument_two"] + ) + + Arguments like those illustrated above would be consumed from a + :func:`_sa.create_engine` call such as:: 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') + "mysql+pymysql://scott:tiger@localhost/test?" + "plugin=myplugin&my_argument_one=foo&my_argument_two=bar", + my_argument_three='bat' + ) + + .. versionchanged:: 1.4 + + The :class:`_engine.URL` object is now immutable; a + :class:`_engine.CreateEnginePlugin` that needs to alter the + :class:`_engine.URL` should implement the newly added + :meth:`_engine.CreateEnginePlugin.update_url` method, which + is invoked after the plugin is constructed. + + For migration, construct the plugin in the following way, checking + for the existence of the :meth:`_engine.CreateEnginePlugin.update_url` + method to detect which version is running:: + + class MyPlugin(CreateEnginePlugin): + def __init__(self, url, kwargs): + if hasattr(CreateEnginePlugin, "update_url"): + # detect the 1.4 API + self.my_argument_one = url.query['my_argument_one'] + self.my_argument_two = url.query['my_argument_two'] + else: + # detect the 1.3 and earlier API - mutate the + # URL directly + 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) + + def update_url(self, url): + # this method is only called in the 1.4 version + return url.difference_update_query( + ["my_argument_one", "my_argument_two"] + ) + + .. seealso:: + + :ref:`change_5526` - overview of the :class:`_engine.URL` change which + also includes notes regarding :class:`_engine.CreateEnginePlugin`. - 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.Engine` object, it is again passed to the plugin via the - :meth:`.CreateEnginePlugin.engine_created` hook. In this hook, additional + :meth:`_engine.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 - """ + """ # noqa: E501 def __init__(self, url, kwargs): + # type: (URL, dict[str: Any]) """Construct a new :class:`.CreateEnginePlugin`. The plugin object is instantiated individually for each call @@ -1018,18 +1112,39 @@ class CreateEnginePlugin(object): 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 url: the :class:`_engine.URL` object. The plugin may inspect + the :class:`_engine.URL` for arguments. Arguments used by the + plugin should be removed, by returning an updated :class:`_engine.URL` + from the :meth:`_engine.CreateEnginePlugin.update_url` method. + + .. versionchanged:: 1.4 + + The :class:`_engine.URL` object is now immutable, so a + :class:`_engine.CreateEnginePlugin` that needs to alter the + :class:`_engine.URL` object should impliement the + :meth:`_engine.CreateEnginePlugin.update_url` method. + :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 update_url(self, url): + """Update the :class:`_engine.URL`. + + A new :class:`_engine.URL` should be returned. This method is + typically used to consume configuration arguments from the + :class:`_engine.URL` which must be removed, as they will not be + recognized by the dialect. The + :meth:`_engine.URL.difference_update_query` method is available + to remove these arguments. See the docstring at + :class:`_engine.CreateEnginePlugin` for an example. + + + .. versionadded:: 1.4 + + """ + def handle_dialect_kwargs(self, dialect_cls, dialect_args): """parse and modify dialect kwargs""" diff --git a/lib/sqlalchemy/engine/result.py b/lib/sqlalchemy/engine/result.py index 10a88c7d8..ab9fb4ac0 100644 --- a/lib/sqlalchemy/engine/result.py +++ b/lib/sqlalchemy/engine/result.py @@ -738,7 +738,7 @@ class Result(ResultInternal): @_generative def unique(self, strategy=None): - # type(Optional[object]) -> Result + # type: (Optional[object]) -> Result """Apply unique filtering to the objects returned by this :class:`_engine.Result`. diff --git a/lib/sqlalchemy/engine/url.py b/lib/sqlalchemy/engine/url.py index f0685d9e3..6d2f4aa24 100644 --- a/lib/sqlalchemy/engine/url.py +++ b/lib/sqlalchemy/engine/url.py @@ -21,66 +21,524 @@ from .. import exc from .. import util from ..dialects import plugins from ..dialects import registry - - -class URL(object): +from ..util import collections_abc +from ..util import compat + + +if compat.TYPE_CHECKING: + from typing import Mapping + from typing import Optional + from typing import Sequence + from typing import Tuple + from typing import Union + + +class URL( + util.namedtuple( + "URL", + [ + "drivername", + "username", + "password", + "host", + "port", + "database", + "query", + ], + ) +): """ Represent the components of a URL used to connect to a database. This object is suitable to be passed directly to a - :func:`~sqlalchemy.create_engine` call. The fields of the URL are parsed + :func:`_sa.create_engine` call. The fields of the URL are parsed from a string by the :func:`.make_url` function. The string format of the URL is an RFC-1738-style string. - All initialization parameters are available as public attributes. + To create a new :class:`_engine.URL` object, use the + :func:`_engine.url.make_url` function. To construct a :class:`_engine.URL` + programmatically, use the :meth:`_engine.URL.create` constructor. - :param drivername: the name of the database backend. - This name will correspond to a module in sqlalchemy/databases - or a third party plug-in. + .. versionchanged:: 1.4 - :param username: The user name. + The :class:`_engine.URL` object is now an immutable object. To + create a URL, use the :func:`_engine.make_url` or + :meth:`_engine.URL.create` function / method. To modify + a :class:`_engine.URL`, use methods like + :meth:`_engine.URL.set` and + :meth:`_engine.URL.update_query_dict` to return a new + :class:`_engine.URL` object with modifications. See notes for this + change at :ref:`change_5526`. - :param password: database password. + :class:`_engine.URL` contains the following attributes: - :param host: The name of the host. + :var `_engine.URL.driver`: database backend and driver name, such as + ``postgresql+psycopg2`` + :var `_engine.URL.username`: username string + :var `_engine.URL.password`: password, which is normally a string but may + also be any object that has a ``__str__()`` method. + :var `_engine.URL.host`: string hostname + :var `_engine.URL.port`: integer port number + :var `_engine.URL.database`: string database name + :var `_engine.URL.query`: an immutable mapping representing the query + string. contains strings for keys and either strings or tuples of strings + for values. - :param port: The port number. - :param database: The database name. + """ - :param query: A dictionary of options to be passed to the - dialect and/or the DBAPI upon connect. + def __new__(self, *arg, **kw): + if not kw and len(arg) == 7: + return super(URL, self).__new__(self, *arg, **kw) + else: + util.warn_deprecated( + "Calling URL() directly is deprecated and will be disabled " + "in a future release. The public constructor for URL is " + "now the URL.create() method.", + "1.4", + ) + return URL.create(*arg, **kw) + + @classmethod + def create( + cls, + drivername, # type: str + username=None, # type: Optional[str] + password=None, # type: Optional[Union[str, object]] + host=None, # type: Optional[str] + port=None, # type: Optional[int] + database=None, # type: Optional[str] + query=util.EMPTY_DICT, # type: Mapping[str, Union[str, Sequence[str]]] + ): + # type: (...) -> URL + """Create a new :class:`_engine.URL` object. + + :param drivername: the name of the database backend. This name will + correspond to a module in sqlalchemy/databases or a third party + plug-in. + :param username: The user name. + :param password: database password. May be a string or an object that + can be stringified with ``str()``. + :param host: The name of the host. + :param port: The port number. + :param database: The database name. + :param query: A dictionary of string keys to string values to be passed + to the dialect and/or the DBAPI upon connect. To specify non-string + parameters to a Python DBAPI directly, use the + :paramref:`_sa.create_engine.connect_args` parameter to + :func:`_sa.create_engine`. See also + :attr:`_engine.URL.normalized_query` for a dictionary that is + consistently string->list of string. + :return: new :class:`_engine.URL` object. + + .. versionadded:: 1.4 + + The :class:`_engine.URL` object is now an **immutable named + tuple**. In addition, the ``query`` dictionary is also immutable. + To create a URL, use the :func:`_engine.url.make_url` or + :meth:`_engine.URL.create` function/ method. To modify a + :class:`_engine.URL`, use the :meth:`_engine.URL.set` and + :meth:`_engine.URL.update_query` methods. """ - def __init__( + return cls( + cls._assert_str(drivername, "drivername"), + cls._assert_none_str(username, "username"), + password, + cls._assert_none_str(host, "host"), + cls._assert_port(port), + cls._assert_none_str(database, "database"), + cls._str_dict(query), + ) + + @classmethod + def _assert_port(cls, port): + if port is None: + return None + try: + return int(port) + except TypeError: + raise TypeError("Port argument must be an integer or None") + + @classmethod + def _assert_str(cls, v, paramname): + if v is None: + return v + + if not isinstance(v, compat.string_types): + raise TypeError("%s must be a string" % paramname) + return v + + @classmethod + def _assert_none_str(cls, v, paramname): + if v is None: + return v + + return cls._assert_str(v, paramname) + + @classmethod + def _str_dict(cls, dict_): + if dict_ is None: + return util.EMPTY_DICT + + def _assert_value(val): + if isinstance(val, str): + return val + elif isinstance(val, collections_abc.Sequence): + return tuple(_assert_value(elem) for elem in val) + else: + raise TypeError( + "Query dictionary values must be strings or " + "sequences of strings" + ) + + def _assert_str(v): + if not isinstance(v, compat.string_types): + raise TypeError("Query dictionary keys must be strings") + return v + + if isinstance(dict_, collections_abc.Sequence): + dict_items = dict_ + else: + dict_items = dict_.items() + + return util.immutabledict( + { + _assert_str(key): _assert_value(value,) + for key, value in dict_items + } + ) + + def set( self, - drivername, - username=None, - password=None, - host=None, - port=None, - database=None, - query=None, + drivername=None, # type: Optional[str] + username=None, # type: Optional[str] + password=None, # type: Optional[Union[str, object]] + host=None, # type: Optional[str] + port=None, # type: Optional[int] + database=None, # type: Optional[str] + query=None, # type: Optional[Mapping[str, Union[str, Sequence[str]]]] ): - self.drivername = drivername - self.username = username - self.password_original = password - self.host = host + # type: (...) -> URL + """return a new :class:`_engine.URL` object with modifications. + + Values are used if they are non-None. To set a value to ``None`` + explicitly, use the :meth:`_engine.URL._replace` method adapted + from ``namedtuple``. + + :param drivername: new drivername + :param username: new username + :param password: new password + :param host: new hostname + :param port: new port + :param query: new query parameters, passed a dict of string keys + referring to string or sequence of string values. Fully + replaces the previous list of arguments. + + :return: new :class:`_engine.URL` object. + + .. versionadded:: 1.4 + + .. seealso:: + + :meth:`_engine.URL.update_query_dict` + + """ + + kw = {} + if drivername is not None: + kw["drivername"] = drivername + if username is not None: + kw["username"] = username + if password is not None: + kw["password"] = password + if host is not None: + kw["host"] = host if port is not None: - self.port = int(port) + kw["port"] = port + if database is not None: + kw["database"] = database + if query is not None: + kw["query"] = query + + return self._replace(**kw) + + def _replace(self, **kw): + # type: (**object) -> URL + """Override ``namedtuple._replace()`` to provide argument checking.""" + + if "drivername" in kw: + self._assert_str(kw["drivername"], "drivername") + for name in "username", "host", "database": + if name in kw: + self._assert_none_str(kw[name], name) + if "port" in kw: + self._assert_port(kw["port"]) + if "query" in kw: + kw["query"] = self._str_dict(kw["query"]) + + return super(URL, self)._replace(**kw) + + def update_query_string(self, query_string, append=False): + # type: (str, bool) -> URL + """Return a new :class:`_engine.URL` object with the :attr:`_engine.URL.query` + parameter dictionary updated by the given query string. + + E.g.:: + + >>> from sqlalchemy.engine import make_url + >>> url = make_url("postgresql://user:pass@host/dbname") + >>> url = url.update_query_string("alt_host=host1&alt_host=host2&ssl_cipher=%2Fpath%2Fto%2Fcrt") + >>> str(url) + 'postgresql://user:pass@host/dbname?alt_host=host1&alt_host=host2&ssl_cipher=%2Fpath%2Fto%2Fcrt' + + :param query_string: a URL escaped query string, not including the + question mark. + + :param append: if True, parameters in the existing query string will + not be removed; new parameters will be in addition to those present. + If left at its default of False, keys present in the given query + parameters will replace those of the existing query string. + + .. versionadded:: 1.4 + + .. seealso:: + + :attr:`_engine.URL.query` + + :meth:`_engine.URL.update_query_dict` + + """ # noqa: E501 + return self.update_query_pairs( + util.parse_qsl(query_string), append=append + ) + + def update_query_pairs(self, key_value_pairs, append=False): + # type: (Sequence[Tuple[str, str]], bool) -> URL + """Return a new :class:`_engine.URL` object with the + :attr:`_engine.URL.query` + parameter dictionary updated by the given sequence of key/value pairs + + E.g.:: + + >>> from sqlalchemy.engine import make_url + >>> url = make_url("postgresql://user:pass@host/dbname") + >>> url = url.update_query_pairs([("alt_host", "host1"), ("alt_host", "host2"), ("ssl_cipher", "/path/to/crt")]) + >>> str(url) + 'postgresql://user:pass@host/dbname?alt_host=host1&alt_host=host2&ssl_cipher=%2Fpath%2Fto%2Fcrt' + + :param key_value_pairs: A sequence of tuples containing two strings + each. + + :param append: if True, parameters in the existing query string will + not be removed; new parameters will be in addition to those present. + If left at its default of False, keys present in the given query + parameters will replace those of the existing query string. + + .. versionadded:: 1.4 + + .. seealso:: + + :attr:`_engine.URL.query` + + :meth:`_engine.URL.difference_update_query` + + :meth:`_engine.URL.set` + + """ # noqa: E501 + + existing_query = self.query + new_keys = {} + + for key, value in key_value_pairs: + if key in new_keys: + new_keys[key] = util.to_list(new_keys[key]) + new_keys[key].append(value) + else: + new_keys[key] = value + + if append: + new_query = {} + + for k in new_keys: + if k in existing_query: + new_query[k] = util.to_list( + existing_query[k] + ) + util.to_list(new_keys[k]) + else: + new_query[k] = new_keys[k] + + new_query.update( + { + k: existing_query[k] + for k in set(existing_query).difference(new_keys) + } + ) else: - self.port = None - self.database = database - self.query = query or {} + new_query = self.query.union(new_keys) + return self.set(query=new_query) + + def update_query_dict(self, query_parameters, append=False): + # type: (Mapping[str, Union[str, Sequence[str]]], bool) -> URL + """Return a new :class:`_engine.URL` object with the + :attr:`_engine.URL.query` parameter dictionary updated by the given + dictionary. + + The dictionary typically contains string keys and string values. + In order to represent a query parameter that is expressed multiple + times, pass a sequence of string values. + + E.g.:: + + >>> from sqlalchemy.engine import make_url + >>> url = make_url("postgresql://user:pass@host/dbname") + >>> url = url.update_query_dict({"alt_host": ["host1", "host2"], "ssl_cipher": "/path/to/crt"}) + >>> str(url) + 'postgresql://user:pass@host/dbname?alt_host=host1&alt_host=host2&ssl_cipher=%2Fpath%2Fto%2Fcrt' + + + :param query_parameters: A dictionary with string keys and values + that are either strings, or sequences of strings. + + :param append: if True, parameters in the existing query string will + not be removed; new parameters will be in addition to those present. + If left at its default of False, keys present in the given query + parameters will replace those of the existing query string. + + + .. versionadded:: 1.4 + + .. seealso:: + + :attr:`_engine.URL.query` + + :meth:`_engine.URL.update_query_string` + + :meth:`_engine.URL.update_query_pairs` + + :meth:`_engine.URL.difference_update_query` + + :meth:`_engine.URL.set` + + """ # noqa: E501 + return self.update_query_pairs(query_parameters.items(), append=append) + + def difference_update_query(self, names): + # type: (Sequence[str]) -> URL + """ + Remove the given names from the :attr:`_engine.URL.query` dictionary, + returning the new :class:`_engine.URL`. + + E.g.:: + + url = url.difference_update_query(['foo', 'bar']) + + Equivalent to using :meth:`_engine.URL.set` as follows:: + + url = url.set( + query={ + key: url.query[key] + for key in set(url.query).difference(['foo', 'bar']) + } + ) + + .. versionadded:: 1.4 + + .. seealso:: + + :attr:`_engine.URL.query` + + :meth:`_engine.URL.update_query_dict` + + :meth:`_engine.URL.set` + + """ + + if not set(names).intersection(self.query): + return self + + return URL( + self.drivername, + self.username, + self.password, + self.host, + self.port, + self.database, + util.immutabledict( + { + key: self.query[key] + for key in set(self.query).difference(names) + } + ), + ) + + @util.memoized_property + def normalized_query(self): + """Return the :attr:`_engine.URL.query` dictionary with values normalized + into sequences. + + As the :attr:`_engine.URL.query` dictionary may contain either + string values or sequences of string values to differentiate between + parameters that are specified multiple times in the query string, + code that needs to handle multiple parameters generically will wish + to use this attribute so that all parameters present are presented + as sequences. Inspiration is from Python's ``urllib.parse.parse_qs`` + function. E.g.:: + + + >>> from sqlalchemy.engine import make_url + >>> url = make_url("postgresql://user:pass@host/dbname?alt_host=host1&alt_host=host2&ssl_cipher=%2Fpath%2Fto%2Fcrt") + >>> url.query + immutabledict({'alt_host': ('host1', 'host2'), 'ssl_cipher': '/path/to/crt'}) + >>> url.normalized_query + immutabledict({'alt_host': ('host1', 'host2'), 'ssl_cipher': ('/path/to/crt',)}) + + """ # noqa: E501 + + return util.immutabledict( + { + k: (v,) if not isinstance(v, tuple) else v + for k, v in self.query.items() + } + ) + + @util.deprecated( + "1.4", + "The :meth:`_engine.URL.__to_string__ method is deprecated and will " + "be removed in a future release. Please use the " + ":meth:`_engine.URL.render_as_string` method.", + ) def __to_string__(self, hide_password=True): + # type: (bool) -> str + """Render this :class:`_engine.URL` object as a string. + + :param hide_password: Defaults to True. The password is not shown + in the string unless this is set to False. + + """ + return self.render_as_string(hide_password=hide_password) + + def render_as_string(self, hide_password=True): + # type: (bool) -> str + """Render this :class:`_engine.URL` object as a string. + + This method is used when the ``__str__()`` or ``__repr__()`` + methods are used. The method directly includes additional options. + + :param hide_password: Defaults to True. The password is not shown + in the string unless this is set to False. + + """ s = self.drivername + "://" if self.username is not None: s += _rfc_1738_quote(self.username) if self.password is not None: s += ":" + ( - "***" if hide_password else _rfc_1738_quote(self.password) + "***" + if hide_password + else _rfc_1738_quote(str(self.password)) ) s += "@" if self.host is not None: @@ -103,10 +561,10 @@ class URL(object): return s def __str__(self): - return self.__to_string__(hide_password=False) + return self.render_as_string(hide_password=False) def __repr__(self): - return self.__to_string__() + return self.render_as_string() def __hash__(self): return hash(str(self)) @@ -126,24 +584,32 @@ class URL(object): def __ne__(self, other): return not self == other - @property - def password(self): - if self.password_original is None: - return None - else: - return util.text_type(self.password_original) + def get_backend_name(self): + """Return the backend name. - @password.setter - def password(self, password): - self.password_original = password + This is the name that corresponds to the database backend in + use, and is the portion of the :attr:`_engine.URL.drivername` + that is to the left of the plus sign. - def get_backend_name(self): + """ if "+" not in self.drivername: return self.drivername else: return self.drivername.split("+")[0] def get_driver_name(self): + """Return the backend name. + + This is the name that corresponds to the DBAPI driver in + use, and is the portion of the :attr:`_engine.URL.drivername` + that is to the right of the plus sign. + + If the :attr:`_engine.URL.drivername` does not include a plus sign, + then the default :class:`_engine.Dialect` for this :class:`_engine.URL` + is imported in order to get the driver name. + + """ + if "+" not in self.drivername: return self.get_dialect().driver else: @@ -153,11 +619,24 @@ class URL(object): plugin_names = util.to_list(self.query.get("plugin", ())) plugin_names += kwargs.get("plugins", []) - return [ + kwargs = dict(kwargs) + + loaded_plugins = [ plugins.load(plugin_name)(self, kwargs) for plugin_name in plugin_names ] + u = self.difference_update_query(["plugin", "plugins"]) + + for plugin in loaded_plugins: + new_u = plugin.update_url(u) + if new_u is not None: + u = new_u + + kwargs.pop("plugins", None) + + return u, loaded_plugins, kwargs + def _get_entrypoint(self): """Return the "entry point" dialect class. @@ -183,8 +662,9 @@ class URL(object): return cls def get_dialect(self): - """Return the SQLAlchemy database dialect class corresponding + """Return the SQLAlchemy :class:`_engine.Dialect` class corresponding to this URL's driver name. + """ entrypoint = self._get_entrypoint() dialect_cls = entrypoint.get_dialect_cls(self) @@ -285,7 +765,12 @@ def _parse_rfc1738_args(name): ipv6host = components.pop("ipv6host") components["host"] = ipv4host or ipv6host name = components.pop("name") - return URL(name, **components) + + if components["port"]: + components["port"] = int(components["port"]) + + return URL.create(name, **components) + else: raise exc.ArgumentError( "Could not parse rfc1738 URL from string '%s'" % name diff --git a/lib/sqlalchemy/testing/assertions.py b/lib/sqlalchemy/testing/assertions.py index f9fabbeed..f78ebf496 100644 --- a/lib/sqlalchemy/testing/assertions.py +++ b/lib/sqlalchemy/testing/assertions.py @@ -390,7 +390,7 @@ class AssertsCompiledSQL(object): elif dialect == "default_enhanced": dialect = default.StrCompileDialect() elif isinstance(dialect, util.string_types): - dialect = url.URL(dialect).get_dialect()() + dialect = url.URL.create(dialect).get_dialect()() if default_schema_name: dialect.default_schema_name = default_schema_name diff --git a/lib/sqlalchemy/testing/assertsql.py b/lib/sqlalchemy/testing/assertsql.py index c86e26ccf..e20209ba5 100644 --- a/lib/sqlalchemy/testing/assertsql.py +++ b/lib/sqlalchemy/testing/assertsql.py @@ -83,7 +83,7 @@ class CompiledSQL(SQLMatchRule): params = {"implicit_returning": True} else: params = {} - return url.URL(self.dialect).get_dialect()(**params) + return url.URL.create(self.dialect).get_dialect()(**params) def _received_statement(self, execute_observed): """reconstruct the statement and params in terms diff --git a/lib/sqlalchemy/testing/provision.py b/lib/sqlalchemy/testing/provision.py index 094d1ea94..0edaae490 100644 --- a/lib/sqlalchemy/testing/provision.py +++ b/lib/sqlalchemy/testing/provision.py @@ -1,5 +1,4 @@ import collections -import copy import logging from . import config @@ -7,12 +6,14 @@ from . import engines from .. import exc from ..engine import url as sa_url from ..util import compat -from ..util import parse_qsl log = logging.getLogger(__name__) FOLLOWER_IDENT = None +if compat.TYPE_CHECKING: + from ..engine import URL + class register(object): def __init__(self): @@ -140,7 +141,7 @@ def _generate_driver_urls(url, extra_drivers): main_driver = url.get_driver_name() extra_drivers.discard(main_driver) - url = generate_driver_url(url, main_driver, {}) + url = generate_driver_url(url, main_driver, "") yield str(url) for drv in list(extra_drivers): @@ -149,12 +150,11 @@ def _generate_driver_urls(url, extra_drivers): driver_only, query_str = drv.split("?", 1) - query = parse_qsl(query_str) else: driver_only = drv - query = {} + query_str = None - new_url = generate_driver_url(url, driver_only, query) + new_url = generate_driver_url(url, driver_only, query_str) if new_url: extra_drivers.remove(drv) @@ -162,12 +162,13 @@ def _generate_driver_urls(url, extra_drivers): @register.init -def generate_driver_url(url, driver, query): +def generate_driver_url(url, driver, query_str): + # type: (URL, str, str) -> URL backend = url.get_backend_name() - new_url = copy.copy(url) - new_url.query = dict(new_url.query) - new_url.drivername = "%s+%s" % (backend, driver) - new_url.query.update(query) + + new_url = url.set(drivername="%s+%s" % (backend, driver),) + new_url = new_url.update_query_string(query_str) + try: new_url.get_dialect() except exc.NoSuchModuleError: @@ -236,8 +237,7 @@ def follower_url_from_main(url, ident): database name """ url = sa_url.make_url(url) - url.database = ident - return url + return url.set(database=ident) @register.init diff --git a/test/engine/test_execute.py b/test/engine/test_execute.py index 4b527f54c..6f71f46c6 100644 --- a/test/engine/test_execute.py +++ b/test/engine/test_execute.py @@ -2842,7 +2842,7 @@ class HandleInvalidatedOnConnectTest(fixtures.TestBase): port=None, query={}, database=None, - _instantiate_plugins=lambda kw: [], + _instantiate_plugins=lambda kw: (u1, [], kw), _get_entrypoint=Mock( return_value=Mock(get_dialect_cls=lambda u: SomeDialect) ), diff --git a/test/engine/test_parseconnect.py b/test/engine/test_parseconnect.py index 77b882f2c..99df6a1e9 100644 --- a/test/engine/test_parseconnect.py +++ b/test/engine/test_parseconnect.py @@ -9,6 +9,7 @@ from sqlalchemy.dialects import registry from sqlalchemy.engine.default import DefaultDialect import sqlalchemy.engine.url as url from sqlalchemy.testing import assert_raises +from sqlalchemy.testing import assert_raises_message from sqlalchemy.testing import eq_ from sqlalchemy.testing import fixtures from sqlalchemy.testing import is_ @@ -113,22 +114,24 @@ class URLTest(fixtures.TestBase): return self.value sp = SecurePassword("secured_password") - u = url.URL("dbtype", username="x", password=sp, host="localhost") + u = url.URL.create( + "dbtype", username="x", password=sp, host="localhost" + ) eq_(u.password, "secured_password") eq_(str(u), "dbtype://x:secured_password@localhost") # test in-place modification sp.value = "new_secured_password" - eq_(u.password, "new_secured_password") + eq_(u.password, sp) eq_(str(u), "dbtype://x:new_secured_password@localhost") - u.password = "hi" + u = u.set(password="hi") eq_(u.password, "hi") eq_(str(u), "dbtype://x:hi@localhost") - u.password = None + u = u._replace(password=None) is_(u.password, None) eq_(str(u), "dbtype://x@localhost") @@ -141,7 +144,7 @@ class URLTest(fixtures.TestBase): u = url.make_url( "dialect://user:pass@host/db?arg1=param1&arg2=param2&arg2=param3" ) - eq_(u.query, {"arg1": "param1", "arg2": ["param2", "param3"]}) + eq_(u.query, {"arg1": "param1", "arg2": ("param2", "param3")}) eq_( str(u), "dialect://user:pass@host/db?arg1=param1&arg2=param2&arg2=param3", @@ -153,16 +156,6 @@ class URLTest(fixtures.TestBase): eq_(str(u), test_url) def test_comparison(self): - components = ( - "drivername", - "username", - "password", - "host", - "database", - "query", - "port", - ) - common_url = ( "dbtype://username:password" "@[2001:da8:2004:1000:202:116:160:90]:80/database?foo=bar" @@ -178,11 +171,156 @@ class URLTest(fixtures.TestBase): is_true(url1 != url3) is_false(url1 == url3) - for curr_component in components: - setattr(url2, curr_component, "new_changed_value") - is_true(url1 != url2) - is_false(url1 == url2) - setattr(url2, curr_component, getattr(url1, curr_component)) + @testing.combinations( + "drivername", "username", "password", "host", "database", + ) + def test_component_set(self, component): + common_url = ( + "dbtype://username:password" + "@[2001:da8:2004:1000:202:116:160:90]:80/database?foo=bar" + ) + url1 = url.make_url(common_url) + url2 = url.make_url(common_url) + + url3 = url2.set(**{component: "new_changed_value"}) + is_true(url1 != url3) + is_false(url1 == url3) + + url4 = url3.set(**{component: getattr(url1, component)}) + + is_true(url4 == url1) + is_false(url4 != url1) + + @testing.combinations( + ( + "foo1=bar1&foo2=bar2", + {"foo2": "bar22", "foo3": "bar3"}, + "foo1=bar1&foo2=bar22&foo3=bar3", + False, + ), + ( + "foo1=bar1&foo2=bar2", + {"foo2": "bar22", "foo3": "bar3"}, + "foo1=bar1&foo2=bar2&foo2=bar22&foo3=bar3", + True, + ), + ) + def test_update_query_dict(self, starting, update_with, expected, append): + eq_( + url.make_url("drivername:///?%s" % starting).update_query_dict( + update_with, append=append + ), + url.make_url("drivername:///?%s" % expected), + ) + + @testing.combinations( + ( + "foo1=bar1&foo2=bar2", + "foo2=bar22&foo3=bar3", + "foo1=bar1&foo2=bar22&foo3=bar3", + False, + ), + ( + "foo1=bar1&foo2=bar2", + "foo2=bar22&foo3=bar3", + "foo1=bar1&foo2=bar2&foo2=bar22&foo3=bar3", + True, + ), + ( + "foo1=bar1&foo2=bar21&foo2=bar22&foo3=bar31", + "foo2=bar23&foo3=bar32&foo3=bar33", + "foo1=bar1&foo2=bar21&foo2=bar22&foo2=bar23&" + "foo3=bar31&foo3=bar32&foo3=bar33", + True, + ), + ( + "foo1=bar1&foo2=bar21&foo2=bar22&foo3=bar31", + "foo2=bar23&foo3=bar32&foo3=bar33", + "foo1=bar1&foo2=bar23&" "foo3=bar32&foo3=bar33", + False, + ), + ) + def test_update_query_string( + self, starting, update_with, expected, append + ): + eq_( + url.make_url("drivername:///?%s" % starting).update_query_string( + update_with, append=append + ), + url.make_url("drivername:///?%s" % expected), + ) + + @testing.combinations( + "username", "host", "database", + ) + def test_only_str_constructor(self, argname): + assert_raises_message( + TypeError, + "%s must be a string" % argname, + url.URL.create, + "somedriver", + **{argname: 35.8} + ) + + @testing.combinations( + "username", "host", "database", + ) + def test_only_str_set(self, argname): + u1 = url.URL.create("somedriver") + + assert_raises_message( + TypeError, + "%s must be a string" % argname, + u1.set, + **{argname: 35.8} + ) + + def test_only_str_query_key_constructor(self): + assert_raises_message( + TypeError, + "Query dictionary keys must be strings", + url.URL.create, + "somedriver", + query={35.8: "foo"}, + ) + + def test_only_str_query_value_constructor(self): + assert_raises_message( + TypeError, + "Query dictionary values must be strings or sequences of strings", + url.URL.create, + "somedriver", + query={"foo": 35.8}, + ) + + def test_only_str_query_key_update(self): + assert_raises_message( + TypeError, + "Query dictionary keys must be strings", + url.make_url("drivername://").update_query_dict, + {35.8: "foo"}, + ) + + def test_only_str_query_value_update(self): + assert_raises_message( + TypeError, + "Query dictionary values must be strings or sequences of strings", + url.make_url("drivername://").update_query_dict, + {"foo": 35.8}, + ) + + def test_deprecated_constructor(self): + with testing.expect_deprecated( + r"Calling URL\(\) directly is deprecated and will be " + "disabled in a future release." + ): + u1 = url.URL( + drivername="somedriver", + username="user", + port=52, + host="hostname", + ) + eq_(u1, url.make_url("somedriver://user@hostname:52")) class DialectImportTest(fixtures.TestBase): @@ -486,7 +624,7 @@ class TestRegNewDBAPI(fixtures.TestBase): @testing.requires.sqlite def test_wrapper_hooks(self): def get_dialect_cls(url): - url.drivername = "sqlite" + url = url.set(drivername="sqlite") return url.get_dialect() global WrapperFactory @@ -505,7 +643,7 @@ class TestRegNewDBAPI(fixtures.TestBase): eq_( WrapperFactory.mock_calls, [ - call.get_dialect_cls(url.make_url("sqlite://")), + call.get_dialect_cls(url.make_url("wrapperdialect://")), call.engine_created(e), ], ) @@ -527,10 +665,12 @@ class TestRegNewDBAPI(fixtures.TestBase): ) eq_(kw, {"logging_name": "foob"}) kw["logging_name"] = "bar" - url.query.pop("myplugin_arg", None) return MyEnginePlugin - MyEnginePlugin = Mock(side_effect=side_effect) + def update_url(url): + return url.difference_update_query(["myplugin_arg"]) + + MyEnginePlugin = Mock(side_effect=side_effect, update_url=update_url) plugins.register("engineplugin", __name__, "MyEnginePlugin") @@ -548,16 +688,19 @@ class TestRegNewDBAPI(fixtures.TestBase): eq_( MyEnginePlugin.mock_calls, [ - call(url.make_url("sqlite:///?foo=bar"), {}), + call( + url.make_url( + "sqlite:///?plugin=engineplugin" + "&foo=bar&myplugin_arg=bat" + ), + {}, + ), call.handle_dialect_kwargs(sqlite.dialect, mock.ANY), call.handle_pool_kwargs(mock.ANY, {"dialect": e.dialect}), call.engine_created(e), ], ) - # url was modified in place by MyEnginePlugin - eq_(str(MyEnginePlugin.mock_calls[0][1][0]), "sqlite:///?foo=bar") - @testing.requires.sqlite def test_plugin_multiple_url_registration(self): from sqlalchemy.dialects import sqlite @@ -568,24 +711,31 @@ class TestRegNewDBAPI(fixtures.TestBase): def side_effect_1(url, kw): eq_(kw, {"logging_name": "foob"}) kw["logging_name"] = "bar" - url.query.pop("myplugin1_arg", None) return MyEnginePlugin1 def side_effect_2(url, kw): - url.query.pop("myplugin2_arg", None) return MyEnginePlugin2 - MyEnginePlugin1 = Mock(side_effect=side_effect_1) - MyEnginePlugin2 = Mock(side_effect=side_effect_2) + def update_url(url): + return url.difference_update_query( + ["myplugin1_arg", "myplugin2_arg"] + ) + + MyEnginePlugin1 = Mock( + side_effect=side_effect_1, update_url=update_url + ) + MyEnginePlugin2 = Mock( + side_effect=side_effect_2, update_url=update_url + ) plugins.register("engineplugin1", __name__, "MyEnginePlugin1") plugins.register("engineplugin2", __name__, "MyEnginePlugin2") - e = create_engine( + url_str = ( "sqlite:///?plugin=engineplugin1&foo=bar&myplugin1_arg=bat" - "&plugin=engineplugin2&myplugin2_arg=hoho", - logging_name="foob", + "&plugin=engineplugin2&myplugin2_arg=hoho" ) + e = create_engine(url_str, logging_name="foob",) eq_(e.dialect.name, "sqlite") eq_(e.logging_name, "bar") @@ -596,7 +746,7 @@ class TestRegNewDBAPI(fixtures.TestBase): eq_( MyEnginePlugin1.mock_calls, [ - call(url.make_url("sqlite:///?foo=bar"), {}), + call(url.make_url(url_str), {}), call.handle_dialect_kwargs(sqlite.dialect, mock.ANY), call.handle_pool_kwargs(mock.ANY, {"dialect": e.dialect}), call.engine_created(e), @@ -606,7 +756,7 @@ class TestRegNewDBAPI(fixtures.TestBase): eq_( MyEnginePlugin2.mock_calls, [ - call(url.make_url("sqlite:///?foo=bar"), {}), + call(url.make_url(url_str), {}), call.handle_dialect_kwargs(sqlite.dialect, mock.ANY), call.handle_pool_kwargs(mock.ANY, {"dialect": e.dialect}), call.engine_created(e), @@ -632,7 +782,10 @@ class TestRegNewDBAPI(fixtures.TestBase): kw.pop("myplugin_arg", None) return MyEnginePlugin - MyEnginePlugin = Mock(side_effect=side_effect) + def update_url(url): + return url.difference_update_query(["myplugin_arg"]) + + MyEnginePlugin = Mock(side_effect=side_effect, update_url=update_url) plugins.register("engineplugin", __name__, "MyEnginePlugin") diff --git a/test/engine/test_reconnect.py b/test/engine/test_reconnect.py index 53a5ec6f4..b8a8621df 100644 --- a/test/engine/test_reconnect.py +++ b/test/engine/test_reconnect.py @@ -759,7 +759,7 @@ class MockReconnectTest(fixtures.TestBase): class Dialect(DefaultDialect): initialize = Mock() - engine = create_engine(MyURL("foo://"), module=dbapi) + engine = create_engine(MyURL.create("foo://"), module=dbapi) engine.connect() # note that the dispose() call replaces the old pool with a new one; @@ -798,7 +798,7 @@ class MockReconnectTest(fixtures.TestBase): # on a subsequent attempt without initialization having proceeded. Dialect.initialize.side_effect = TypeError - engine = create_engine(MyURL("foo://"), module=dbapi) + engine = create_engine(MyURL.create("foo://"), module=dbapi) assert_raises(TypeError, engine.connect) eq_(Dialect.initialize.call_count, 1) @@ -943,7 +943,7 @@ class CursorErrTest(fixtures.TestBase): url = Mock( get_dialect=lambda: default.DefaultDialect, _get_entrypoint=lambda: default.DefaultDialect, - _instantiate_plugins=lambda kwargs: (), + _instantiate_plugins=lambda kwargs: (url, [], kwargs), translate_connect_args=lambda: {}, query={}, ) |