Skip to content

Commit 7c6e378

Browse files
author
r.inyakin
committed
dbapi: implementation dsn
Added support for DSN strings in the dbapi `connect` method. The following format is supported: `[scheme://][user[:password]@]host:port[?option=value&...]`. Closes #170
1 parent 453be26 commit 7c6e378

5 files changed

Lines changed: 688 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88

99
### Added
10+
- Support for a DSN in `tarantool.dbapi.connect()`. The expected format
11+
is `[scheme://][user[:password]@]host:port[?option=value&...]`, where
12+
a Unix socket address is either `unix/:path` or an absolute path, and
13+
an IPv6 address is enclosed in `[]`. The scheme, if any, is ignored,
14+
as Tarantool itself ignores it. Allowed options are the Tarantool URI
15+
parameters: `transport`, `ssl_key_file`, `ssl_cert_file`,
16+
`ssl_ca_file`, `ssl_ciphers`, `ssl_password`, `ssl_password_file` and
17+
`auth_type`. Parameters set explicitly take precedence over the ones
18+
from the DSN (PR #351).
1019

1120
### Changed
1221

‎tarantool/dbapi.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
# pylint: disable=fixme,unused-import,bad-option-value,no-self-use
77
# flake8: noqa: F401
88

9+
import typing
10+
911
from tarantool.connection import Connection as BaseConnection
1012
from tarantool.error import (
13+
ConfigurationError,
1114
Error,
1215
InterfaceError,
1316
DatabaseError,
@@ -17,6 +20,7 @@
1720
ProgrammingError,
1821
NotSupportedError,
1922
)
23+
from tarantool.utils import parse_dsn
2024

2125
Warning = Warning # pylint: disable=redefined-builtin,self-assigning-variable
2226

@@ -400,9 +404,10 @@ def connect(dsn=None, host=None, port=None,
400404
"""
401405
Constructor for creating a connection to the database.
402406
403-
:param dsn: **Not implemented**. Tarantool server URI:
404-
``[[[username[:password]@]host:]port``.
405-
:type dsn: :obj:`str`
407+
:param dsn: Tarantool server DSN, refer to
408+
:func:`~tarantool.utils.parse_dsn`. Parameters set explicitly
409+
take precedence over the ones from the DSN.
410+
:type dsn: :obj:`str`, optional
406411
407412
:param host: Refer to :paramref:`~tarantool.Connection.params.host`.
408413
@@ -415,14 +420,18 @@ def connect(dsn=None, host=None, port=None,
415420
416421
:rtype: :class:`~tarantool.Connection`
417422
418-
:raise: :exc:`~NotImplementedError`,
423+
:raise: :exc:`~tarantool.error.InterfaceError`,
419424
:class:`~tarantool.Connection` exceptions
420425
"""
421426

427+
params: typing.Dict[str, typing.Any] = {}
428+
422429
if dsn:
423-
raise NotImplementedError("dsn param is not implemented in"
424-
"this version of dbapi module")
425-
params = {}
430+
try:
431+
params = parse_dsn(dsn)
432+
except ConfigurationError as exc:
433+
raise InterfaceError(str(exc)) from exc
434+
426435
if host:
427436
params["host"] = host
428437
if port:
@@ -432,6 +441,6 @@ def connect(dsn=None, host=None, port=None,
432441
if password:
433442
params["password"] = password
434443

435-
kwargs.update(params)
444+
params.update(kwargs)
436445

437-
return Connection(**kwargs)
446+
return Connection(**params)

‎tarantool/utils.py‎

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,47 @@
44

55
from base64 import decodebytes as base64_decode
66
from dataclasses import dataclass
7+
import re
78
import typing
89
import uuid
10+
import socket
11+
12+
from tarantool.error import ConfigurationError
913

1014
ENCODING_DEFAULT = "utf-8"
1115

16+
DSN_DEFAULT_HOST = "127.0.0.1"
17+
"""
18+
Host to use if a DSN consists of a port only.
19+
"""
20+
21+
DSN_UNIX_PREFIX = "unix/:"
22+
"""
23+
Prefix of a Unix socket address in a DSN.
24+
"""
25+
26+
DSN_SCHEME_RE = re.compile(r'[A-Za-z][A-Za-z0-9+.\-]*://')
27+
"""
28+
Scheme of a DSN. Only a scheme at the very beginning of a DSN is
29+
recognized as such, the same way Tarantool does it.
30+
31+
:meta private:
32+
"""
33+
34+
DSN_OPTIONS: typing.Tuple[str, ...] = (
35+
'transport',
36+
'ssl_key_file',
37+
'ssl_cert_file',
38+
'ssl_ca_file',
39+
'ssl_ciphers',
40+
'ssl_password',
41+
'ssl_password_file',
42+
'auth_type',
43+
)
44+
"""
45+
Tarantool URI parameters allowed in a DSN query.
46+
"""
47+
1248

1349
def strxor(rhs, lhs):
1450
"""
@@ -146,3 +182,245 @@ def greeting_decode(greeting_buf):
146182
except ValueError as exc:
147183
print('exx', exc)
148184
raise ValueError("Invalid greeting: " + str(greeting_buf)) from exc
185+
186+
187+
def _dsn_error(dsn: str, msg: str) -> ConfigurationError:
188+
"""
189+
Build a DSN parse error.
190+
191+
:param dsn: Source DSN.
192+
:type dsn: :obj:`str`
193+
194+
:param msg: Error description.
195+
:type msg: :obj:`str`
196+
197+
:rtype: :exc:`~tarantool.error.ConfigurationError`
198+
199+
:meta private:
200+
"""
201+
202+
return ConfigurationError(f'DSN "{dsn}": {msg}')
203+
204+
205+
def _is_dsn_port(port_str: str) -> bool:
206+
"""
207+
Check whether a DSN substring is a port.
208+
209+
:param port_str: Port substring.
210+
:type port_str: :obj:`str`
211+
212+
:rtype: :obj:`bool`
213+
214+
:meta private:
215+
"""
216+
217+
return port_str.isascii() and port_str.isdigit()
218+
219+
220+
def _is_dsn_userinfo(userinfo: str) -> bool:
221+
"""
222+
Check whether a DSN substring before an ``@`` is a user name with
223+
a password rather than a part of a Unix socket path. Tarantool
224+
allows neither ``/`` nor more than one ``:`` in a user name and a
225+
password, so ``/tmp/tt@1.sock`` is a socket path, not a user name.
226+
227+
:param userinfo: Substring before the first ``@``.
228+
:type userinfo: :obj:`str`
229+
230+
:rtype: :obj:`bool`
231+
232+
:meta private:
233+
"""
234+
235+
return '/' not in userinfo
236+
237+
238+
def _parse_dsn_port(dsn: str, port_str: str) -> int:
239+
"""
240+
Parse the port of a DSN.
241+
242+
:param dsn: Source DSN.
243+
:type dsn: :obj:`str`
244+
245+
:param port_str: Port substring.
246+
:type port_str: :obj:`str`
247+
248+
:rtype: :obj:`int`
249+
250+
:raise: :exc:`~tarantool.error.ConfigurationError`
251+
252+
:meta private:
253+
"""
254+
255+
if not _is_dsn_port(port_str):
256+
raise _dsn_error(dsn, f'port "{port_str}" is not a number')
257+
port = int(port_str)
258+
if port < 1 or port > 65535:
259+
raise _dsn_error(dsn, 'port must be in range [1, 65535]')
260+
return port
261+
262+
263+
def _parse_dsn_address(
264+
dsn: str,
265+
address: str) -> typing.Tuple[typing.Optional[str],
266+
typing.Union[int, str]]:
267+
"""
268+
Parse the address of a DSN. For a Unix socket address, host is
269+
``None`` and port is a socket path.
270+
271+
:param dsn: Source DSN.
272+
:type dsn: :obj:`str`
273+
274+
:param address: Address substring.
275+
:type address: :obj:`str`
276+
277+
:return: `(host, port)` pair.
278+
:rtype: :obj:`tuple`
279+
280+
:raise: :exc:`~tarantool.error.ConfigurationError`
281+
282+
:meta private:
283+
"""
284+
# pylint: disable=too-many-return-statements,too-many-branches
285+
286+
if address.startswith(DSN_UNIX_PREFIX):
287+
path = address[len(DSN_UNIX_PREFIX):]
288+
if not path:
289+
raise _dsn_error(dsn, 'Unix socket path is empty')
290+
if not path.startswith(('/', './')):
291+
raise _dsn_error(dsn, f'Unix socket path "{path}" is neither '
292+
'absolute nor started with "./"')
293+
return None, path
294+
295+
if address.startswith(('/', './')):
296+
return None, address
297+
298+
if '/' in address:
299+
raise _dsn_error(dsn, f'address "{address}" is neither a host with '
300+
'a port nor a Unix socket path')
301+
302+
if address.startswith('['):
303+
delim = address.find(']')
304+
if delim == -1:
305+
raise _dsn_error(dsn, 'IPv6 address is not closed with "]"')
306+
host, tail = address[1:delim], address[delim + 1:]
307+
try:
308+
socket.inet_pton(socket.AF_INET6, host)
309+
except (OSError, ValueError):
310+
raise _dsn_error(dsn, f'"{host}" is not an IPv6 address') from None
311+
if not tail.startswith(':'):
312+
raise _dsn_error(dsn, 'port is not specified')
313+
return host, _parse_dsn_port(dsn, tail[1:])
314+
315+
if ':' in address:
316+
host, port_str = address.rsplit(':', 1)
317+
if not host:
318+
raise _dsn_error(dsn, 'host value is empty')
319+
if ':' in host:
320+
raise _dsn_error(dsn, 'IPv6 address must be enclosed in "[]"')
321+
return host, _parse_dsn_port(dsn, port_str)
322+
323+
if _is_dsn_port(address):
324+
return DSN_DEFAULT_HOST, _parse_dsn_port(dsn, address)
325+
326+
raise _dsn_error(dsn, 'port is not specified')
327+
328+
329+
def _parse_dsn_options(dsn: str, query: str) -> typing.Dict[str, str]:
330+
"""
331+
Parse the query of a DSN into connection options.
332+
333+
:param dsn: Source DSN.
334+
:type dsn: :obj:`str`
335+
336+
:param query: Query substring.
337+
:type query: :obj:`str`
338+
339+
:rtype: :obj:`dict`
340+
341+
:raise: :exc:`~tarantool.error.ConfigurationError`
342+
343+
:meta private:
344+
"""
345+
346+
options: typing.Dict[str, str] = {}
347+
for option_str in query.split('&'):
348+
if not option_str:
349+
continue
350+
name, delim, value = option_str.partition('=')
351+
if not delim:
352+
raise _dsn_error(dsn, f'option "{name}" has no value')
353+
if name not in DSN_OPTIONS:
354+
raise _dsn_error(dsn, f'unknown option "{name}"')
355+
options[name] = value
356+
return options
357+
358+
359+
def parse_dsn(dsn: str) -> typing.Dict[str, typing.Any]:
360+
"""
361+
Parse a Tarantool DSN string into :class:`~tarantool.Connection`
362+
parameters.
363+
364+
Expected format is
365+
``[scheme://][user[:password]@]host:port[?option=value&...]``.
366+
A Unix socket address is either ``unix/:path`` or a path itself,
367+
absolute or started with ``./``; an IPv6 address must be enclosed
368+
in ``[]``. The scheme, if any, is ignored, as Tarantool itself
369+
ignores it, but it is recognized only at the very beginning of a
370+
DSN. Values are not percent-decoded, so neither ``@`` nor ``/``
371+
is allowed in a user name or a password. Whitespace is not
372+
allowed anywhere. Allowed options are the Tarantool URI parameters
373+
listed in :data:`~tarantool.utils.DSN_OPTIONS`.
374+
375+
:param dsn: Tarantool server DSN.
376+
:type dsn: :obj:`str`
377+
378+
:return: Keyword arguments for :class:`~tarantool.Connection`.
379+
Only the parameters explicitly set in the DSN are present.
380+
:rtype: :obj:`dict`
381+
382+
:raise: :exc:`~tarantool.error.ConfigurationError`
383+
"""
384+
385+
if not isinstance(dsn, str):
386+
raise ConfigurationError('DSN should be of a string type')
387+
388+
source = dsn
389+
if not dsn:
390+
raise ConfigurationError('DSN should not be an empty string')
391+
392+
if any(char.isspace() for char in dsn):
393+
raise _dsn_error(source, 'whitespace is not allowed')
394+
395+
params: typing.Dict[str, typing.Any] = {}
396+
397+
scheme = DSN_SCHEME_RE.match(dsn)
398+
if scheme:
399+
dsn = dsn[scheme.end():]
400+
401+
query = ''
402+
if '?' in dsn:
403+
dsn, query = dsn.split('?', 1)
404+
405+
userinfo, delim, tail = dsn.partition('@')
406+
if delim and _is_dsn_userinfo(userinfo):
407+
dsn = tail
408+
if '@' in dsn:
409+
raise _dsn_error(source, '"@" is not allowed in a user name '
410+
'or a password')
411+
user, delim, password = userinfo.partition(':')
412+
if not user:
413+
raise _dsn_error(source, 'user value is empty')
414+
if ':' in password:
415+
raise _dsn_error(source, '":" is not allowed in a password')
416+
params['user'] = user
417+
if delim:
418+
params['password'] = password
419+
420+
if not dsn:
421+
raise _dsn_error(source, 'address is not specified')
422+
423+
params['host'], params['port'] = _parse_dsn_address(source, dsn)
424+
params.update(_parse_dsn_options(source, query))
425+
426+
return params

‎test/suites/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from .test_push import TestSuitePush
2727
from .test_connection import TestSuiteConnection
2828
from .test_crud import TestSuiteCrud
29+
from .test_dsn import TestSuiteDsnParse
30+
from .test_dsn import TestSuiteDsnConnect
2931

3032
test_cases = (TestSuiteSchemaUnicodeConnection,
3133
TestSuiteSchemaBinaryConnection,
@@ -34,7 +36,8 @@
3436
TestSuiteEncoding, TestSuitePool, TestSuiteSsl,
3537
TestSuiteDecimal, TestSuiteUUID, TestSuiteDatetime,
3638
TestSuiteInterval, TestSuitePackage, TestSuiteErrorExt,
37-
TestSuitePush, TestSuiteConnection, TestSuiteCrud, TestSuiteSocketFD)
39+
TestSuitePush, TestSuiteConnection, TestSuiteCrud, TestSuiteSocketFD,
40+
TestSuiteDsnParse, TestSuiteDsnConnect)
3841

3942

4043
def load_tests(loader, tests, pattern):

0 commit comments

Comments
 (0)