From 23b3375dcd89b0ad268f8c96a5782f45bf9a99af Mon Sep 17 00:00:00 2001 From: Federico Caselli Date: Wed, 8 Sep 2021 22:25:12 +0200 Subject: [PATCH] Ensure str is callect on the URL password. Ensure that ``str()`` is called on the an ``URL.password`` argument, allowing usage of objects that implement the ``__str__()`` method as password attributes. Also clarified that one such object is not appropriate to dynamically change the password. Fixes: #6958 Change-Id: Id0690990a64b9e0935537b7b8f5a73efe6a9e3dc --- doc/build/changelog/unreleased_14/6958.rst | 9 +++++ lib/sqlalchemy/engine/events.py | 30 +++++++++----- lib/sqlalchemy/engine/url.py | 32 ++++++++++++--- test/engine/test_parseconnect.py | 47 ++++++++++++++++++++++ 4 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 doc/build/changelog/unreleased_14/6958.rst diff --git a/doc/build/changelog/unreleased_14/6958.rst b/doc/build/changelog/unreleased_14/6958.rst new file mode 100644 index 0000000000..61fd141310 --- /dev/null +++ b/doc/build/changelog/unreleased_14/6958.rst @@ -0,0 +1,9 @@ +.. change:: + :tags: bug, engine + :tickets: 6958 + + Ensure that ``str()`` is called on the an ``URL.password`` argument, + allowing usage of objects that implement the ``__str__()`` method + as password attributes. + Also clarified that one such object is not appropriate to dynamically + change the password. diff --git a/lib/sqlalchemy/engine/events.py b/lib/sqlalchemy/engine/events.py index f3775aed79..f091c7733a 100644 --- a/lib/sqlalchemy/engine/events.py +++ b/lib/sqlalchemy/engine/events.py @@ -722,15 +722,27 @@ class DialectEvents(event.Events): def do_connect(self, dialect, conn_rec, cargs, cparams): """Receive connection arguments before a connection is made. - Return a DBAPI connection to halt further events from invoking; - the returned connection will be used. - - Alternatively, the event can manipulate the cargs and/or cparams - collections; cargs will always be a Python list that can be mutated - in-place and cparams a Python dictionary. Return None to - allow control to pass to the next event handler and ultimately - to allow the dialect to connect normally, given the updated - arguments. + This event is useful in that it allows the handler to manipulate the + cargs and/or cparams collections that control how the DBAPI + ``connect()`` function will be called. ``cargs`` will always be a + Python list that can be mutated in-place, and ``cparams`` a Python + dictionary that may also be mutated:: + + e = create_engine("postgresql+psycopg2://user@host/dbname") + + @event.listens_for(e, 'do_connect') + def receive_do_connect(dialect, conn_rec, cargs, cparams): + cparams["password"] = "some_password" + + The event hook may also be used to override the call to ``connect()`` + entirely, by returning a non-``None`` DBAPI connection object:: + + e = create_engine("postgresql+psycopg2://user@host/dbname") + + @event.listens_for(e, 'do_connect') + def receive_do_connect(dialect, conn_rec, cargs, cparams): + return psycopg2.connect(*cargs, **cparams) + .. versionadded:: 1.0.3 diff --git a/lib/sqlalchemy/engine/url.py b/lib/sqlalchemy/engine/url.py index 1b96c3c2e1..d91f060113 100644 --- a/lib/sqlalchemy/engine/url.py +++ b/lib/sqlalchemy/engine/url.py @@ -67,8 +67,13 @@ class URL( * :attr:`_engine.URL.drivername`: database backend and driver name, such as ``postgresql+psycopg2`` * :attr:`_engine.URL.username`: username string - * :attr:`_engine.URL.password`: password, which is normally a string but - may also be any object that has a ``__str__()`` method. + * :attr:`_engine.URL.password`: password string, or object that includes + a ``__str__()`` method that produces a password. + + .. note:: A password-producing object will be stringified only + **once** per :class:`_engine.Engine` object. For dynamic password + generation per connect, see :ref:`engines_dynamic_tokens`. + * :attr:`_engine.URL.host`: string hostname * :attr:`_engine.URL.port`: integer port number * :attr:`_engine.URL.database`: string database name @@ -108,8 +113,13 @@ class URL( 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 password: database password. Is typically a string, but may + also be an object that can be stringified with ``str()``. + + .. note:: A password-producing object will be stringified only + **once** per :class:`_engine.Engine` object. For dynamic password + generation per connect, see :ref:`engines_dynamic_tokens`. + :param host: The name of the host. :param port: The port number. :param database: The database name. @@ -666,6 +676,14 @@ class URL( names, but correlates the name to the original positionally. """ + if names is not None: + util.warn_deprecated( + "The `URL.translate_connect_args.name`s parameter is " + "deprecated. Please pass the " + "alternate names as kw arguments.", + "1.4", + ) + translated = {} attribute_names = ["host", "database", "username", "password", "port"] for sname in attribute_names: @@ -676,7 +694,11 @@ class URL( else: name = sname if name is not None and getattr(self, sname, False): - translated[name] = getattr(self, sname) + if sname == "password": + translated[name] = str(getattr(self, sname)) + else: + translated[name] = getattr(self, sname) + return translated diff --git a/test/engine/test_parseconnect.py b/test/engine/test_parseconnect.py index 9acedaa852..136695279a 100644 --- a/test/engine/test_parseconnect.py +++ b/test/engine/test_parseconnect.py @@ -367,6 +367,16 @@ class URLTest(fixtures.TestBase): ) eq_(u1, url.make_url("somedriver://user@hostname:52")) + def test_deprecated_translate_connect_args_names(self): + u = url.make_url("somedriver://user@hostname:52") + + with testing.expect_deprecated( + "The `URL.translate_connect_args.name`s parameter is " + ): + res = u.translate_connect_args(["foo"]) + is_true("foo" in res) + eq_(res["foo"], u.host) + class DialectImportTest(fixtures.TestBase): def test_import_base_dialects(self): @@ -691,6 +701,43 @@ class CreateEngineTest(fixtures.TestBase): _initialize=False, ) + @testing.combinations(True, False) + def test_password_object_str(self, creator): + class SecurePassword: + def __init__(self, value): + self.called = 0 + self.value = value + + def __str__(self): + self.called += 1 + return self.value + + sp = SecurePassword("secured_password") + u = url.URL.create( + "postgresql", username="x", password=sp, host="localhost" + ) + if not creator: + dbapi = MockDBAPI( + user="x", password="secured_password", host="localhost" + ) + + e = create_engine(u, module=dbapi, _initialize=False) + + else: + dbapi = MockDBAPI(foober=12, lala=18, fooz="somevalue") + + def connect(): + return dbapi.connect(foober=12, lala=18, fooz="somevalue") + + e = create_engine( + u, creator=connect, module=dbapi, _initialize=False + ) + e.connect() + e.connect() + e.connect() + e.connect() + eq_(sp.called, 1) + class TestRegNewDBAPI(fixtures.TestBase): def test_register_base(self): -- 2.47.2