From abfd659139727c064f03bd1327fe9c090ca1f508 Mon Sep 17 00:00:00 2001 From: Joeri van Ruth Date: Wed, 12 Jun 2024 18:25:50 +0200 Subject: [PATCH] Add support for clientinfo Changelog entry: * Tell the server more about the connecting client: hostname, application name, pymonetdb version, process id and an optional remark. This information will show up in the `sys.sessions` table. Configurable with the new settings `client_info`, `client_application` and `client_remark`. --- CHANGES.md | 6 ++++ pymonetdb/__init__.py | 20 ++++++++++- pymonetdb/mapi.py | 34 ++++++++++++++++++- pymonetdb/target.py | 11 ++++-- tests/test_clientinfo.py | 73 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/test_clientinfo.py diff --git a/CHANGES.md b/CHANGES.md index 7986659b..f137598a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ changes since 1.8.1 +* Tell the server more about the connecting client: hostname, + application name, pymonetdb version, process id and an optional remark. + This information will show up in the `sys.sessions` table. + Configurable with the new settings `client_info`, `client_application` + and `client_remark`. + # 1.8.1 diff --git a/pymonetdb/__init__.py b/pymonetdb/__init__.py index 5eda5840..e983ee06 100644 --- a/pymonetdb/__init__.py +++ b/pymonetdb/__init__.py @@ -10,6 +10,10 @@ # # Copyright 1997 - July 2008 CWI, August 2008 - 2016 MonetDB B.V. +# Set __version__ first, so the imported modules can access it. +__version__ = '1.8.2a0' + + from typing import Optional from pymonetdb import sql from pymonetdb import mapi @@ -26,7 +30,6 @@ from pymonetdb.filetransfer.directoryhandler import SafeDirectoryHandler from pymonetdb.target import Target, looks_like_url -__version__ = '1.8.2a0' apilevel = "2.0" threadsafety = 1 @@ -65,6 +68,9 @@ def connect( # noqa C901 clientcert: Optional[str] = None, schema: Optional[str] = None, timezone: Optional[int] = None, + client_info: Optional[bool] = None, + client_application: Optional[str] = None, + client_remark: Optional[str] = None, dangerous_tls_nocheck: Optional[str] = None, ): """Set up a connection to a MonetDB SQL database @@ -121,6 +127,12 @@ def connect( # noqa C901 the schema to select after connecting timezone : int the time zone to use, in minutes east of UTC + client_info : bool + whether to send client details when connecting + client_application : str + application name to send in the client details + client_remark : str + additional info to send in the client details dangerous_tls_nocheck : str comma-separated list of TLS certificate checks to skip during connecting: 'host': ignore host name mismatch, @@ -171,6 +183,12 @@ def connect( # noqa C901 target.schema = schema if timezone is not None: target.timezone = timezone + if client_info is not None: + target.client_info = client_info + if client_application is not None: + target.client_application = client_application + if client_remark is not None: + target.client_remark = client_remark if dangerous_tls_nocheck is not None: target.dangerous_tls_nocheck = dangerous_tls_nocheck diff --git a/pymonetdb/mapi.py b/pymonetdb/mapi.py index 2defad76..61124bad 100644 --- a/pymonetdb/mapi.py +++ b/pymonetdb/mapi.py @@ -9,18 +9,22 @@ import os +import platform import re import socket import logging import struct import hashlib import ssl +import sys import typing -from typing import Callable, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Optional, Tuple, Union +# import pymonetdb from pymonetdb.exceptions import OperationalError, DatabaseError, \ ProgrammingError, NotSupportedError, IntegrityError from pymonetdb.target import Target +from pymonetdb import __version__ if typing.TYPE_CHECKING: from pymonetdb.filetransfer.downloads import Downloader @@ -98,6 +102,7 @@ class Connection(object): is_raw_control: Optional[bool] = None handshake_options_callback: Optional[Callable[[int], List['HandshakeOption']]] = None remaining_handshake_options: List['HandshakeOption'] = [] + clientinfo: Optional[Dict[str, Optional[str]]] = None uploader: Optional['Uploader'] = None downloader: Optional['Downloader'] = None stashed_buffer: Optional[bytearray] = None @@ -154,6 +159,18 @@ def connect(self, database: Optional[Union[Target, str]] = None, *args, **kwargs # handle during the handshake self.state = STATE_READY + if self.clientinfo: + info = "".join( + f"{k}={v or ''}\n" + for k, v in self.clientinfo.items() + if v is None or '\n' not in v + ) + if info: + try: + self.cmd(f"Xclientinfo {info}") + except OperationalError as e: + logger.warn(f"Server rejected clientinfo: {e}") + for opt in self.remaining_handshake_options: opt.fallback(opt.value) @@ -578,6 +595,21 @@ def _challenge_response(self, challenge: str): # noqa: C901 assert part.startswith('BINARY=') self.binexport_level = int(part[7:]) + if len(challenges) >= 10: + part = challenges[9] + assert part == "CLIENTINFO" + if self.target.client_info: + application_name = self.target.client_application + if not application_name and sys.argv: + application_name = os.path.basename(sys.argv[0]) + self.clientinfo = dict( + ClientHostName=platform.node() or None, + ApplicationName=application_name or None, + ClientLibrary=f"pymonetdb {__version__}", + ClientRemark=self.target.client_remark or None, + ClientPid=str(os.getpid()), + ) + callback = self.handshake_options_callback handshake_options = callback(self.binexport_level) if callback else [] diff --git a/pymonetdb/target.py b/pymonetdb/target.py index 19c09b01..69cc7a47 100644 --- a/pymonetdb/target.py +++ b/pymonetdb/target.py @@ -29,8 +29,9 @@ def looks_like_url(text: str) -> bool: 'sock', 'sockdir', 'sockprefix', 'cert', 'certhash', 'clientkey', 'clientcert', 'user', 'password', 'language', 'autocommit', 'schema', 'timezone', 'binary', 'replysize', 'fetchsize', 'maxprefetch', - 'connect_timeout'] -) + 'connect_timeout', + 'client_info', 'client_application', 'client_remark', +]) IGNORED = set(['hash', 'debug', 'logfile']) VIRTUAL = set([ 'connect_scan', 'connect_sockdir', @@ -73,6 +74,9 @@ def looks_like_url(text: str) -> bool: fetchsize=None, maxprefetch=None, connect_timeout=0, + client_info=True, + client_application="", + client_remark="", dangerous_tls_nocheck="", ) @@ -172,6 +176,9 @@ def clone(self): 'rows beyond this limit are retrieved on demand, <1 means unlimited') maxprefetch = urlparam('maxprefetch', 'integer', 'specific to pymonetdb') connect_timeout = urlparam('connect_timeout', 'integer', 'abort if connect takes longer than this') + client_info = urlparam('client_info', 'bool', 'whether to send client details when connecting') + client_application = urlparam('client_application', 'string', 'application name to send in client details') + client_remark = urlparam('client_remark', 'string', 'application name to send in client details') dangerous_tls_nocheck = urlparam( 'dangerous_tls_nocheck', 'bool', 'comma separated certificate checks to skip, host: do not verify host, cert: do not verify certificate chain') diff --git a/tests/test_clientinfo.py b/tests/test_clientinfo.py new file mode 100644 index 00000000..33bfe7d8 --- /dev/null +++ b/tests/test_clientinfo.py @@ -0,0 +1,73 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright 1997 - July 2008 CWI, August 2008 - 2016 MonetDB B.V. + +from unittest import TestCase, skipUnless +import pymonetdb + +from tests.util import have_monetdb_version_at_least, test_url + +QUERY = """\ +SELECT language, peer, hostname, application, client, clientpid, remark +FROM sys.sessions +WHERE sessionid = sys.current_sessionid() +""" + +SERVER_HAS_CLIENTINFO = have_monetdb_version_at_least(11, 51, 0) + + +@skipUnless(SERVER_HAS_CLIENTINFO, "server does not support clientinfo") +class TestClientInfo(TestCase): + + def get_clientinfo(self, **connect_args): + with pymonetdb.connect(test_url, **connect_args) as conn, conn.cursor() as c: + nrows = c.execute(QUERY) + self.assertEqual(nrows, 1) + row = c.fetchone() + d = dict( + (descr.name, v) for descr, v in zip(c.description, row) + ) + return d + + # 'application': 'python3 -m unittest' + # 'client': 'pymonetdb 1.8.2a0' + # 'clientpid': 2762097 + # 'hostname': 'totoro' + # 'language': 'sql' + # 'peer': '' + # 'remark': None + + def test_default_clientinfo(self): + d = self.get_clientinfo() + self.assertEqual(d['language'], 'sql') + self.assertIsNotNone(d['peer']) + self.assertIsNotNone(d['hostname']) + self.assertIsNotNone(d['application']) + self.assertIn('pymonetdb', d['client']) + self.assertIn(pymonetdb.__version__, d['client']) + self.assertGreater(d['clientpid'], 0) + self.assertIsNone(d['remark']) + + def test_suppressed_clientinfo(self): + d = self.get_clientinfo(client_info=False) + self.assertEqual(d['language'], 'sql') + self.assertIsNotNone(d['peer']) + self.assertIsNone(d['hostname']) + self.assertIsNone(d['application']) + self.assertIsNone(d['client']) + self.assertIsNone(d['clientpid']) + self.assertIsNone(d['remark']) + + def test_set_application_name(self): + d = self.get_clientinfo(client_application='banana') + self.assertEqual(d['application'], 'banana') + d = self.get_clientinfo(client_info=False, client_application='banana') + self.assertIsNone(d['application']) + + def test_set_remark(self): + d = self.get_clientinfo(client_remark='banana') + self.assertEqual(d['remark'], 'banana') + d = self.get_clientinfo(client_info=False, client_remark='banana') + self.assertIsNone(d['remark'])