]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
make URL immutable
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 21 Aug 2020 18:44:04 +0000 (14:44 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 26 Aug 2020 00:10:16 +0000 (20:10 -0400)
it's not really correct that URL is mutable and doesn't do
any argument checking.   propose replacing it with an immutable
named tuple with rich copy-and-mutate methods.

At the moment this makes a hard change to the CreateEnginePlugin
docs that previously recommended url.query.pop().  I can't find
any plugins on github other than my own that are using this
feature, so see if we can just make a hard change on this one.

Fixes: #5526
Change-Id: I28a0a471d80792fa8c28f4fa573d6352966a4a79

20 files changed:
doc/build/changelog/migration_14.rst
doc/build/changelog/unreleased_14/5526.rst [new file with mode: 0644]
doc/build/conf.py
doc/build/core/engines.rst
doc/build/core/internals.rst
lib/sqlalchemy/dialects/mysql/provision.py
lib/sqlalchemy/dialects/oracle/provision.py
lib/sqlalchemy/dialects/sqlite/pysqlite.py
lib/sqlalchemy/engine/__init__.py
lib/sqlalchemy/engine/base.py
lib/sqlalchemy/engine/create.py
lib/sqlalchemy/engine/interfaces.py
lib/sqlalchemy/engine/result.py
lib/sqlalchemy/engine/url.py
lib/sqlalchemy/testing/assertions.py
lib/sqlalchemy/testing/assertsql.py
lib/sqlalchemy/testing/provision.py
test/engine/test_execute.py
test/engine/test_parseconnect.py
test/engine/test_reconnect.py

index 14584fd430b8e7d8b7fdd0e6163639b5f2e3e636..e21c53ef6e5a2ad5e3376ea7fc9c49d42a3686e6 100644 (file)
@@ -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 (file)
index 0000000..3479356
--- /dev/null
@@ -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
+
index d4fdf58a00a7a462e91a4fc5fd982e5c27f210ec..857bec64950391123299b66795530b9d7f0be508 100644 (file)
@@ -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",
index 2404414d17f7899f501f6e4a9443916e3b968777..02495404c8ff579701af395344810838da1598e6 100644 (file)
@@ -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
 =======
 
index 965c03fd9ff2e0c9599df44814d41bd711964a71..34bf0407cdd87361ceeb74334d319d05741ffac3 100644 (file)
@@ -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:
 
 
index bbe752d78273d497ac34ae985b18baec78b94148..a1d82222db77ad91ccbbf1e782d9d5914f26f3d3 100644 (file)
@@ -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()
index 9de14bff08975a93e5470eb66bfb7287ed417982..01854fdce53995130649e16c2783fc29799b009b 100644 (file)
@@ -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")
index 8da2a0323e62503c3b2037a66e27a710f824c517..3c88dab8ee9f02300c5138a13332a813ac478a4a 100644 (file)
@@ -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
index 625c26d2d06ebc937795b2d3b6b9768466aa1482..7523f3b26e6bfe8a38b53e3c63855cf5def99d9b 100644 (file)
@@ -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
 
index 0eaa1fae1157ca1048e48132f19c96d233a87d16..91fff45490dca98097c7d9cd7d02bc157718cc1b 100644 (file)
@@ -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
index 66173d9b038b1b1635cb7dfea7fa7faa997cb427..e31f3a12dcf7bfe2717a6be6534a81686875c3a1 100644 (file)
@@ -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)
index 7d51ab15917ff45709ff25498affbc767581594e..e0e4a9a8333e359ab45a9de8a6d51ae5ecaf6fde 100644 (file)
 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"""
 
index 10a88c7d880e85563471190babbc336120e4fe99..ab9fb4ac08e44cc47141fb38c6cefde612ba5e03 100644 (file)
@@ -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`.
 
index f0685d9e33677d9737fbae62d83e252b18631fd4..6d2f4aa244979cf6238384a0b1a67ee41244180c 100644 (file)
@@ -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
index f9fabbeed5df7ea366afb6c23d215b0532819639..f78ebf4963a0294b1d22e025faf29c31a68be00f 100644 (file)
@@ -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
index c86e26ccfc15b4a52456447420581422e73b5b17..e20209ba55a08fde30abecc1dba76767f2e7d1ba 100644 (file)
@@ -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
index 094d1ea94bf3118c5b735880d0068e05baab70a4..0edaae4909c17fac478e95a116382f7c95e8ee3a 100644 (file)
@@ -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
index 5b922a97d8cf2403d71fe84e719338456f3ee30d..cac254c2bc008b4008e41e2a366afcec3d109df7 100644 (file)
@@ -2847,7 +2847,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)
             ),
index 77b882f2c19696fbb00c77406a5476604704b1fa..99df6a1e92d2d268f5782c01ba95ecf8b07317e0 100644 (file)
@@ -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")
 
index 53a5ec6f4de44d3cfcfd2bcb54cb552ddb5e7382..b8a8621dfa291e26c49544da49bdad74ca7877d9 100644 (file)
@@ -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={},
         )