Skip to content

Commit 1d650ed

Browse files
committed
Add support for specifying multiple host addresses when connecting
The behavior matches that of libpq. Multiple hosts can now be specified in the DSN, e.g. `postgres://host1,host2:5433`. The `host` and `port` arguments now also accept lists. Like libpq, asyncpg will select the first host it can successfully connect to. Closes: #257 Related: #352
1 parent 716fd9d commit 1d650ed

File tree

3 files changed

+242
-70
lines changed

3 files changed

+242
-70
lines changed

asyncpg/connect_utils.py

Lines changed: 107 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,10 @@ def _read_password_file(passfile: pathlib.Path) \
104104

105105
def _read_password_from_pgpass(
106106
*, passfile: typing.Optional[pathlib.Path],
107-
hosts: typing.List[typing.Union[str, typing.Tuple[str, int]]],
108-
port: int, database: str, user: str):
107+
hosts: typing.List[str],
108+
ports: typing.List[int],
109+
database: str,
110+
user: str):
109111
"""Parse the pgpass file and return the matching password.
110112
111113
:return:
@@ -116,7 +118,7 @@ def _read_password_from_pgpass(
116118
if not passtab:
117119
return None
118120

119-
for host in hosts:
121+
for host, port in zip(hosts, ports):
120122
if host.startswith('/'):
121123
# Unix sockets get normalized into 'localhost'
122124
host = 'localhost'
@@ -137,27 +139,83 @@ def _read_password_from_pgpass(
137139
return None
138140

139141

142+
def _validate_port_spec(hosts, port):
143+
if isinstance(port, list):
144+
# If there is a list of ports, its length must
145+
# match that of the host list.
146+
if len(port) != len(hosts):
147+
raise exceptions.InterfaceError(
148+
'could not match {} port numbers to {} hosts'.format(
149+
len(port), len(hosts)))
150+
else:
151+
port = [port for _ in range(len(hosts))]
152+
153+
return port
154+
155+
156+
def _parse_hostlist(hostlist, port):
157+
if ',' in hostlist:
158+
# A comma-separated list of host addresses.
159+
hostspecs = hostlist.split(',')
160+
else:
161+
hostspecs = [hostlist]
162+
163+
hosts = []
164+
hostlist_ports = []
165+
166+
if not port:
167+
portspec = os.environ.get('PGPORT')
168+
if portspec:
169+
if ',' in portspec:
170+
default_port = [int(p) for p in portspec.split(',')]
171+
else:
172+
default_port = int(portspec)
173+
else:
174+
default_port = 5432
175+
176+
default_port = _validate_port_spec(hostspecs, default_port)
177+
178+
else:
179+
port = _validate_port_spec(hostspecs, port)
180+
181+
for i, hostspec in enumerate(hostspecs):
182+
addr, _, hostspec_port = hostspec.partition(':')
183+
hosts.append(addr)
184+
185+
if not port:
186+
if hostspec_port:
187+
hostlist_ports.append(int(hostspec_port))
188+
else:
189+
hostlist_ports.append(default_port[i])
190+
191+
if not port:
192+
port = hostlist_ports
193+
194+
return hosts, port
195+
196+
140197
def _parse_connect_dsn_and_args(*, dsn, host, port, user,
141198
password, passfile, database, ssl,
142199
connect_timeout, server_settings):
143-
if host is not None and not isinstance(host, str):
144-
raise TypeError(
145-
'host argument is expected to be str, got {!r}'.format(
146-
type(host)))
200+
# `auth_hosts` is the version of host information for the purposes
201+
# of reading the pgpass file.
202+
auth_hosts = None
147203

148204
if dsn:
149205
parsed = urllib.parse.urlparse(dsn)
150206

151207
if parsed.scheme not in {'postgresql', 'postgres'}:
152208
raise ValueError(
153-
'invalid DSN: scheme is expected to be either of '
209+
'invalid DSN: scheme is expected to be either '
154210
'"postgresql" or "postgres", got {!r}'.format(parsed.scheme))
155211

156-
if parsed.port and port is None:
157-
port = int(parsed.port)
212+
if not host and parsed.netloc:
213+
if '@' in parsed.netloc:
214+
auth, _, hostspec = parsed.netloc.partition('@')
215+
else:
216+
hostspec = parsed.netloc
158217

159-
if parsed.hostname and host is None:
160-
host = parsed.hostname
218+
host, port = _parse_hostlist(hostspec, port)
161219

162220
if parsed.path and database is None:
163221
database = parsed.path
@@ -178,13 +236,13 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user,
178236

179237
if 'host' in query:
180238
val = query.pop('host')
181-
if host is None:
182-
host = val
239+
if not host and val:
240+
host, port = _parse_hostlist(val, port)
183241

184242
if 'port' in query:
185-
val = int(query.pop('port'))
186-
if port is None:
187-
port = val
243+
val = query.pop('port')
244+
if not port and val:
245+
port = [int(p) for p in val.split(',')]
188246

189247
if 'dbname' in query:
190248
val = query.pop('dbname')
@@ -222,40 +280,44 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user,
222280
else:
223281
server_settings = {**query, **server_settings}
224282

225-
# On env-var -> connection parameter conversion read here:
226-
# https://www.postgresql.org/docs/current/static/libpq-envars.html
227-
# Note that env values may be an empty string in cases when
228-
# the variable is "unset" by setting it to an empty value
229-
# `auth_hosts` is the version of host information for the purposes
230-
# of reading the pgpass file.
231-
auth_hosts = None
232-
if host is None:
233-
host = os.getenv('PGHOST')
234-
if not host:
235-
auth_hosts = ['localhost']
283+
if not host:
284+
hostspec = os.environ.get('PGHOST')
285+
if hostspec:
286+
host, port = _parse_hostlist(hostspec, port)
236287

237-
if _system == 'Windows':
238-
host = ['localhost']
239-
else:
240-
host = ['/tmp', '/private/tmp',
241-
'/var/pgsql_socket', '/run/postgresql',
242-
'localhost']
288+
if not host:
289+
auth_hosts = ['localhost']
290+
291+
if _system == 'Windows':
292+
host = ['localhost']
293+
else:
294+
host = ['/run/postgresql', '/var/run/postgresql',
295+
'/tmp', '/private/tmp', 'localhost']
243296

244297
if not isinstance(host, list):
245298
host = [host]
246299

247300
if auth_hosts is None:
248301
auth_hosts = host
249302

250-
if port is None:
251-
port = os.getenv('PGPORT')
252-
if port:
253-
port = int(port)
303+
if not port:
304+
portspec = os.environ.get('PGPORT')
305+
if portspec:
306+
if ',' in portspec:
307+
port = [int(p) for p in portspec.split(',')]
308+
else:
309+
port = int(portspec)
254310
else:
255311
port = 5432
312+
313+
elif isinstance(port, (list, tuple)):
314+
port = [int(p) for p in port]
315+
256316
else:
257317
port = int(port)
258318

319+
port = _validate_port_spec(host, port)
320+
259321
if user is None:
260322
user = os.getenv('PGUSER')
261323
if not user:
@@ -293,19 +355,20 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user,
293355

294356
if passfile is not None:
295357
password = _read_password_from_pgpass(
296-
hosts=auth_hosts, port=port, database=database, user=user,
358+
hosts=auth_hosts, ports=port,
359+
database=database, user=user,
297360
passfile=passfile)
298361

299362
addrs = []
300-
for h in host:
363+
for h, p in zip(host, port):
301364
if h.startswith('/'):
302365
# UNIX socket name
303366
if '.s.PGSQL.' not in h:
304-
h = os.path.join(h, '.s.PGSQL.{}'.format(port))
367+
h = os.path.join(h, '.s.PGSQL.{}'.format(p))
305368
addrs.append(h)
306369
else:
307370
# TCP host/port
308-
addrs.append((h, port))
371+
addrs.append((h, p))
309372

310373
if not addrs:
311374
raise ValueError(
@@ -329,7 +392,8 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user,
329392
sslmode = SSLMODES[ssl]
330393
except KeyError:
331394
modes = ', '.join(SSLMODES.keys())
332-
raise ValueError('`sslmode` parameter must be one of ' + modes)
395+
raise exceptions.InterfaceError(
396+
'`sslmode` parameter must be one of: {}'.format(modes))
333397

334398
# sslmode 'allow' is currently handled as 'prefer' because we're
335399
# missing the "retry with SSL" behavior for 'allow', but do have the

asyncpg/connection.py

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,77 +1506,120 @@ async def connect(dsn=None, *,
15061506
server_settings=None):
15071507
r"""A coroutine to establish a connection to a PostgreSQL server.
15081508
1509+
The connection parameters may be specified either as a connection
1510+
URI in *dsn*, or as specific keyword arguments, or both.
1511+
If both *dsn* and keyword arguments are specified, the latter
1512+
override the corresponding values parsed from the connection URI.
1513+
The default values for the majority of arguments can be specified
1514+
using `environment variables <postgres envvars>`_.
1515+
15091516
Returns a new :class:`~asyncpg.connection.Connection` object.
15101517
15111518
:param dsn:
15121519
Connection arguments specified using as a single string in the
1513-
following format:
1514-
``postgres://user:pass@host:port/database?option=value``
1520+
`libpq connection URI format`_:
1521+
``postgres://user:password@host:port/database?option=value``.
1522+
The following options are recognized by asyncpg: host, port,
1523+
user, database (or dbname), password, passfile, sslmode.
1524+
Unlike libpq, asyncpg will treat unrecognized options
1525+
as `server settings`_ to be used for the connection.
15151526
15161527
:param host:
1517-
database host address or a path to the directory containing
1518-
database server UNIX socket (defaults to the default UNIX socket,
1519-
or the value of the ``PGHOST`` environment variable, if set).
1528+
Database host address as one of the following:
1529+
1530+
- an IP address or a domain name;
1531+
- an absolute path to the directory containing the database
1532+
server Unix-domain socket (not supported on Windows);
1533+
- a sequence of any of the above, in which case the addresses
1534+
will be tried in order, and the first successful connection
1535+
will be returned.
1536+
1537+
If not specified, asyncpg will try the following, in order:
1538+
1539+
- host address(es) parsed from the *dsn* argument,
1540+
- the value of the ``PGHOST`` environment variable,
1541+
- on Unix, common directories used for PostgreSQL Unix-domain
1542+
sockets: ``"/run/postgresql"``, ``"/var/run/postgresl"``,
1543+
``"/var/pgsql_socket"``, ``"/private/tmp"``, and ``"/tmp"``,
1544+
- ``"localhost"``.
15201545
15211546
:param port:
1522-
connection port number (defaults to ``5432``, or the value of
1523-
the ``PGPORT`` environment variable, if set)
1547+
Port number to connect to at the server host
1548+
(or Unix-domain socket file extension). If multiple host
1549+
addresses were specified, this parameter may specify a
1550+
sequence of port numbers of the same length as the host sequence,
1551+
or it may specify a single port number to be used for all host
1552+
addresses.
1553+
1554+
If not specified, the value parsed from the *dsn* argument is used,
1555+
or the value of the ``PGPORT`` environment variable, or ``5432`` if
1556+
neither is specified.
15241557
15251558
:param user:
1526-
the name of the database role used for authentication
1527-
(defaults to the name of the effective user of the process
1528-
making the connection, or the value of ``PGUSER`` environment
1529-
variable, if set)
1559+
The name of the database role used for authentication.
1560+
1561+
If not specified, the value parsed from the *dsn* argument is used,
1562+
or the value of the ``PGUSER`` environment variable, or the
1563+
operating system name of the user running the application.
15301564
15311565
:param database:
1532-
the name of the database (defaults to the value of ``PGDATABASE``
1533-
environment variable, if set.)
1566+
The name of the database to connect to.
1567+
1568+
If not specified, the value parsed from the *dsn* argument is used,
1569+
or the value of the ``PGDATABASE`` environment variable, or the
1570+
operating system name of the user running the application.
15341571
15351572
:param password:
1536-
password used for authentication
1573+
Password to be used for authentication, if the server requires
1574+
one. If not specified, the value parsed from the *dsn* argument
1575+
is used, or the value of the ``PGPASSWORD`` environment variable.
1576+
Note that the use of the environment variable is discouraged as
1577+
other users and applications may be able to read it without needing
1578+
specific privileges. It is recommended to use *passfile* instead.
15371579
15381580
:param passfile:
1539-
the name of the file used to store passwords
1581+
The name of the file used to store passwords
15401582
(defaults to ``~/.pgpass``, or ``%APPDATA%\postgresql\pgpass.conf``
1541-
on Windows)
1583+
on Windows).
15421584
15431585
:param loop:
15441586
An asyncio event loop instance. If ``None``, the default
15451587
event loop will be used.
15461588
15471589
:param float timeout:
1548-
connection timeout in seconds.
1590+
Connection timeout in seconds.
15491591
15501592
:param int statement_cache_size:
1551-
the size of prepared statement LRU cache. Pass ``0`` to
1593+
The size of prepared statement LRU cache. Pass ``0`` to
15521594
disable the cache.
15531595
15541596
:param int max_cached_statement_lifetime:
1555-
the maximum time in seconds a prepared statement will stay
1597+
The maximum time in seconds a prepared statement will stay
15561598
in the cache. Pass ``0`` to allow statements be cached
15571599
indefinitely.
15581600
15591601
:param int max_cacheable_statement_size:
1560-
the maximum size of a statement that can be cached (15KiB by
1602+
The maximum size of a statement that can be cached (15KiB by
15611603
default). Pass ``0`` to allow all statements to be cached
15621604
regardless of their size.
15631605
15641606
:param float command_timeout:
1565-
the default timeout for operations on this connection
1607+
The default timeout for operations on this connection
15661608
(the default is ``None``: no timeout).
15671609
15681610
:param ssl:
1569-
pass ``True`` or an `ssl.SSLContext <SSLContext_>`_ instance to
1611+
Pass ``True`` or an `ssl.SSLContext <SSLContext_>`_ instance to
15701612
require an SSL connection. If ``True``, a default SSL context
15711613
returned by `ssl.create_default_context() <create_default_context_>`_
15721614
will be used.
15731615
15741616
:param dict server_settings:
1575-
an optional dict of server runtime parameters. Refer to
1576-
PostgreSQL documentation for a `list of supported options`_.
1617+
An optional dict of server runtime parameters. Refer to
1618+
PostgreSQL documentation for
1619+
a `list of supported options <server settings>`_.
15771620
15781621
:param Connection connection_class:
1579-
class of the returned connection object. Must be a subclass of
1622+
Class of the returned connection object. Must be a subclass of
15801623
:class:`~asyncpg.connection.Connection`.
15811624
15821625
:return: A :class:`~asyncpg.connection.Connection` instance.
@@ -1613,8 +1656,13 @@ class of the returned connection object. Must be a subclass of
16131656
.. _SSLContext: https://docs.python.org/3/library/ssl.html#ssl.SSLContext
16141657
.. _create_default_context:
16151658
https://docs.python.org/3/library/ssl.html#ssl.create_default_context
1616-
.. _list of supported options:
1659+
.. _server settings:
16171660
https://www.postgresql.org/docs/current/static/runtime-config.html
1661+
.. _postgres envvars:
1662+
https://www.postgresql.org/docs/current/static/libpq-envars.html
1663+
.. _libpq connection URI format:
1664+
https://www.postgresql.org/docs/current/static/\
1665+
libpq-connect.html#LIBPQ-CONNSTRING
16181666
"""
16191667
if not issubclass(connection_class, Connection):
16201668
raise TypeError(

0 commit comments

Comments
 (0)