From 76a73cd7cdaab885f1b5c00fa39e8f6c24c988a7 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Sat, 3 May 2025 14:54:37 +0200 Subject: [PATCH 01/19] Fix readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dab83da..4b371a0 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ This project is using [pytest](https://docs.pytest.org/en/stable/) for testing, tests add several options via `tests/conftest.py`. By default, tests are configured to use local Firebird installation via network access. -To use local instllation in `mebedded` mode, comment out the section: +To use local installation in `embedded` mode, comment out the section: ``` [tool.hatch.envs.hatch-test] extra-args = ["--host=localhost"] From 5c5c5cbe33ae2ac076fa47e034e923e2a0657ccc Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 5 May 2025 16:29:13 +0200 Subject: [PATCH 02/19] Fox for #48 --- CHANGELOG.md | 6 ++++++ docs/changelog.txt | 5 +++++ src/firebird/driver/core.py | 18 ++++++------------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a570257..e8f5194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.0.1] - 2025-05-05 + +### Fixed + +- #48: AttributeError object has no attribute 'logging_id' in "__del__" methods + ## [2.0.0] - 2025-04-30 ### Fixed diff --git a/docs/changelog.txt b/docs/changelog.txt index 78831c0..1a3c243 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -2,6 +2,11 @@ Changelog ######### +Version 2.0.1 +============= + +- #48: AttributeError object has no attribute 'logging_id' in "__del__" methods + Version 2.0.0 ============= diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index 6bf9bdd..d89d3ef 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -1795,7 +1795,7 @@ def __init__(self, att: iAttachment, dsn: str, dpb: bytes | None=None, sql_diale self.__FIREBIRD_LIB__ = None def __del__(self): if not self.is_closed(): - warn(f"Connection '{self.logging_id}' disposed without prior close()", ResourceWarning) + warn(f"Connection disposed without prior close()", ResourceWarning) self._close() self._close_internals() self._att.detach() @@ -2537,7 +2537,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._tra is not None: - warn(f"Transaction '{self.logging_id}' disposed while active", ResourceWarning) + warn(f"Transaction disposed while active", ResourceWarning) self._finish() def __dead_con(self, obj: Connection) -> None: # noqa: ARG002 self._connection = None @@ -2947,12 +2947,8 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.free() def __del__(self): if self._in_meta or self._out_meta or self._istmt: - warn(f"Statement '{self.logging_id}' disposed without prior free()", ResourceWarning) + warn(f"Statement disposed without prior free()", ResourceWarning) self.free() - def __str__(self): - return f'{self.logging_id}[{self.sql}]' - def __repr__(self): - return str(self) def __dead_con(self, obj: Connection) -> None: # noqa: ARG002 self._connection = None def __get_plan(self, *, detailed: bool) -> str: @@ -3085,10 +3081,8 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._blob is not None: - warn(f"BlobReader '{self.logging_id}' disposed without prior close()", ResourceWarning) + warn(f"BlobReader disposed without prior close()", ResourceWarning) self.close() - def __repr__(self): - return f'{self.logging_id}[size={self.length}]' def flush(self) -> None: """Does nothing. """ @@ -3292,7 +3286,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._result is not None or self._stmt is not None or self.__blob_readers: - warn(f"Cursor '{self.logging_id}' disposed without prior close()", ResourceWarning) + warn(f"Cursor disposed without prior close()", ResourceWarning) self.close() def __next__(self): if (row := self.fetchone()) is not None: @@ -5547,7 +5541,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._svc is not None: - warn(f"Server '{self.logging_id}' disposed without prior close()", ResourceWarning) + warn(f"Server disposed without prior close()", ResourceWarning) self.close() def __next__(self): if (line := self.readline()) is not None: From 692c49b9cb26e59f7e77a83e3c677a93452ab3d8 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 5 May 2025 16:30:14 +0200 Subject: [PATCH 03/19] Bump version number --- src/firebird/driver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firebird/driver/__init__.py b/src/firebird/driver/__init__.py index 7a9e7b9..89139fa 100644 --- a/src/firebird/driver/__init__.py +++ b/src/firebird/driver/__init__.py @@ -133,4 +133,4 @@ ) #: Current driver version, SEMVER string. -__VERSION__ = '2.0.0' +__VERSION__ = '2.0.1' From 6924db86cb10521a571b2ea96cc267370d46f9b1 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 5 May 2025 16:42:16 +0200 Subject: [PATCH 04/19] Typo --- tests/test_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_server.py b/tests/test_server.py index a3c54fa..93e1e7f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -87,7 +87,7 @@ def test_query(server_connection, fb_vars, dsn, db_file): elif version in SpecifierSet('>=4.0'): assert sec_db.endswith('SECURITY4.FDB') else: # FB30 - assert sec_db.endswith('SECURITY53.FDB') + assert sec_db.endswith('SECURITY3.FDB') assert isinstance(svc.info.lock_directory, str) # Path can vary caps = svc.info.capabilities From 93110a3691a61534d76dfc3f6a8d0349e8c40e61 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Wed, 21 May 2025 18:58:50 +0200 Subject: [PATCH 05/19] Fix for #49 --- src/firebird/driver/core.py | 99 +++++++++++++++++--- tests/test_connection.py | 177 +++++++++++++++++++++++++++++++++++- 2 files changed, 261 insertions(+), 15 deletions(-) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index d89d3ef..76528d9 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -53,6 +53,7 @@ import sys import threading import weakref +from urllib.parse import urlparse from abc import ABC, abstractmethod from collections.abc import Callable, Mapping, Sequence from ctypes import addressof, byref, create_string_buffer, memmove, memset, pointer, string_at @@ -1795,7 +1796,7 @@ def __init__(self, att: iAttachment, dsn: str, dpb: bytes | None=None, sql_diale self.__FIREBIRD_LIB__ = None def __del__(self): if not self.is_closed(): - warn(f"Connection disposed without prior close()", ResourceWarning) + warn("Connection disposed without prior close()", ResourceWarning) self._close() self._close_internals() self._att.detach() @@ -2179,6 +2180,75 @@ def _connect_helper(dsn: str, host: str, port: str, database: str, protocol: Net dsn += database return dsn +def _is_dsn(value: str) -> bool: + """ + Checks if the given string matches known patterns for Firebird DSNs. + + This function analyzes the string for structures that are typical for + Firebird connection strings, based on how the firebird-driver might + construct them (per _connect_helper) or how the Firebird client + library generally interprets them. + + Args: + value: The string to check. + + Returns: + True if the string matches a DSN pattern, False otherwise. + """ + if not isinstance(value, str) or not value.strip(): + # Empty or whitespace-only strings are not DSNs + return False + + # 1. Protocol-based DSNs (e.g., inet://localhost/employee) + # These are directly produced by _connect_helper if a protocol is specified. + try: + parsed = urlparse(value) + if parsed.scheme in [p.name.lower() for p in NetProtocol]: + # A scheme-based DSN must have a network location (host/port) or a path (database part). + # parsed.path.lstrip('/') handles cases like "xnet:///path/to/db" where netloc is empty. + if parsed.netloc or (parsed.path and parsed.path.lstrip('/')): + return True + except ValueError: + # urlparse can raise ValueError for some malformed inputs (e.g. "::1") + # These are unlikely to be scheme-based DSNs in the firebird context. + pass + + # 2. Windows Named Pipes (e.g., \\server\pipe_name or \\server@port\pipe_name) + # These are indicated by starting with \\. + if value.startswith("\\\\"): + # Basic check: must have some content after \\, and not be just \\ or \\\ + if len(value) > 2 and value[2] not in ['\\', '/']: + return True + + # 3. Classic host:database or host/port:database syntax + # (e.g., localhost:employee, server/3050:/data/db.fdb) + # This pattern should not be confused with "C:\path" or "http://..." + colon_idx = value.find(':') + if colon_idx > 0 and "://" not in value[:colon_idx]: # Colon exists, not at start, and not part of a scheme + host_spec = value[:colon_idx] + # db_spec = value[colon_idx+1:] # Not strictly needed for this check + + # Avoid misinterpreting "C:\path" as "host C, db \path". + # If host_spec is a single letter (like a drive) AND the char after colon is a path separator, + # it's more likely an absolute path. os.path.isabs handles these better. + is_windows_drive_abs_path_candidate = ( + len(host_spec) == 1 and host_spec[0].isalpha() and + len(value) > colon_idx + 1 and value[colon_idx+1] in ('/', '\\') + ) + + if not is_windows_drive_abs_path_candidate: + # host_spec should not contain backslashes if it's a hostname. + # It can contain a forward slash for host/port. + if '\\' not in host_spec: + if '/' in host_spec: # Potential host/port:db + # e.g., "server/3050:dbname" + parts = host_spec.split('/', 1) # Split only on the first / + if len(parts) == 2 and parts[0] and parts[1].isdigit(): # host part and port part (digits) + return True + elif host_spec: # Plain host:db, ensure host_spec is not empty + # e.g., "localhost:dbname", "localhost:/path/to/db", "localhost:C:relative_path_on_C" + return True + return False def __make_connection(dsn: str, utf8filename: bool, dpb: bytes, sql_dialect: int, charset: str, # noqa: FBT001 crypt_callback: iCryptKeyCallbackImpl, *, create: bool) -> Connection: with a.get_api().master.get_dispatcher() as provider: @@ -2244,13 +2314,14 @@ def connect(database: str | Path, *, user: str | None=None, password: str | None if isinstance(database, Path): database = str(database) db_config: DatabaseConfig = driver_config.get_database(database) + dsn: str | None = None if db_config is None: db_config = driver_config.db_defaults - # we'll assume that 'database' is 'dsn' - dsn = database - database = None srv_config = driver_config.server_defaults - srv_config.host.clear() + if _is_dsn(database): + dsn = database + database = None + srv_config.host.clear() else: database = db_config.database.value dsn = db_config.dsn.value @@ -2328,11 +2399,11 @@ def create_database(database: str | Path, *, user: str | None=None, password: st db_config: DatabaseConfig = driver_config.get_database(database) if db_config is None: db_config = driver_config.db_defaults - # we'll assume that 'database' is 'dsn' - dsn = database - database = None srv_config = driver_config.server_defaults - srv_config.host.clear() + if _is_dsn(database): + dsn = database + database = None + srv_config.host.clear() else: database = db_config.database.value dsn = db_config.dsn.value @@ -2537,7 +2608,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._tra is not None: - warn(f"Transaction disposed while active", ResourceWarning) + warn("Transaction disposed while active", ResourceWarning) self._finish() def __dead_con(self, obj: Connection) -> None: # noqa: ARG002 self._connection = None @@ -2947,7 +3018,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.free() def __del__(self): if self._in_meta or self._out_meta or self._istmt: - warn(f"Statement disposed without prior free()", ResourceWarning) + warn("Statement disposed without prior free()", ResourceWarning) self.free() def __dead_con(self, obj: Connection) -> None: # noqa: ARG002 self._connection = None @@ -3081,7 +3152,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._blob is not None: - warn(f"BlobReader disposed without prior close()", ResourceWarning) + warn("BlobReader disposed without prior close()", ResourceWarning) self.close() def flush(self) -> None: """Does nothing. @@ -3286,7 +3357,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._result is not None or self._stmt is not None or self.__blob_readers: - warn(f"Cursor disposed without prior close()", ResourceWarning) + warn("Cursor disposed without prior close()", ResourceWarning) self.close() def __next__(self): if (row := self.fetchone()) is not None: @@ -5541,7 +5612,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def __del__(self): if self._svc is not None: - warn(f"Server disposed without prior close()", ResourceWarning) + warn("Server disposed without prior close()", ResourceWarning) self.close() def __next__(self): if (line := self.readline()) is not None: diff --git a/tests/test_connection.py b/tests/test_connection.py index a6d6e50..b5a035a 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -27,7 +27,8 @@ import firebird.driver as driver from firebird.driver.types import ImpData, ImpDataOld from firebird.driver import (NetProtocol, connect, Isolation, tpb, DefaultAction, - DbInfoCode, DbWriteMode, DbAccessMode, DbSpaceReservation) + DbInfoCode, DbWriteMode, DbAccessMode, DbSpaceReservation, + driver_config) def test_connect_helper(): DB_LINUX_PATH = '/path/to/db/employee.fdb' @@ -376,3 +377,177 @@ def test_db_info(db_connection, fb_vars, db_file): guid = con.info.get_info(DbInfoCode.DB_GUID) assert isinstance(guid, str) assert len(guid) == 38 # Example check for {GUID} format + +def test_connect_with_driver_config_server_defaults_local(driver_cfg, db_file, fb_vars): + """ + Tests connect() using driver_config.server_defaults for a local connection. + The database alias registered for this test will have its 'server' attribute + set to None, which means it should pick up settings from server_defaults. + """ + db_alias = "pytest_cfg_local_db" + db_path_str = str(db_file) + + # Save original server_defaults to restore them, though driver_cfg fixture handles full reset + original_s_host = driver_config.server_defaults.host.value + original_s_port = driver_config.server_defaults.port.value + original_s_user = driver_config.server_defaults.user.value + original_s_password = driver_config.server_defaults.password.value + + # Configure server_defaults for a local connection + driver_config.server_defaults.host.value = None + driver_config.server_defaults.port.value = None # Explicitly None for local + driver_config.server_defaults.user.value = fb_vars['user'] + driver_config.server_defaults.password.value = fb_vars['password'] + + # Ensure the test-specific DB alias is clean if it exists from a prior failed run + if driver_config.get_database(db_alias): + driver_config.databases.value = [db_cfg for db_cfg in driver_config.databases.value if db_cfg.name != db_alias] + + # Register a database alias that will use these server_defaults + test_db_config_entry = driver_config.register_database(db_alias) + test_db_config_entry.database.value = db_path_str + test_db_config_entry.server.value = None # Key: This tells driver to use server_defaults + + # For a local connection (host=None, port=None), DSN is just the database path + expected_dsn = db_path_str + + conn = None + try: + conn = driver.connect(db_alias, charset='UTF8') + assert conn._att is not None, "Connection attachment failed" + assert conn.dsn == expected_dsn, f"Expected DSN '{expected_dsn}', got '{conn.dsn}'" + + # Verify connection is usable with a simple query + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM RDB$DATABASE") + assert cur.fetchone()[0] == 1, "Query failed on the connection" + finally: + if conn and not conn.is_closed(): + conn.close() + # Restore original server_defaults values (driver_cfg also handles full reset) + driver_config.server_defaults.host.value = original_s_host + driver_config.server_defaults.port.value = original_s_port + driver_config.server_defaults.user.value = original_s_user + driver_config.server_defaults.password.value = original_s_password + + +def test_connect_with_driver_config_server_defaults_remote(driver_cfg, db_file, fb_vars): + """ + Tests connect() using driver_config.server_defaults for a remote-like connection. + This test relies on fb_vars providing a host (and optionally port) from conftest.py. + If no host is configured in fb_vars, this test variant is skipped. + """ + db_alias = "pytest_cfg_remote_db" + db_path_str = str(db_file) + + test_host = fb_vars.get('host') + test_port = fb_vars.get('port') # Can be None or empty string + + if not test_host: + pytest.skip("Skipping remote server_defaults test as no host is configured in fb_vars. " + "This test requires a configured host (and optionally port) for execution.") + return + + # Save original server_defaults + original_s_host = driver_config.server_defaults.host.value + original_s_port = driver_config.server_defaults.port.value + original_s_user = driver_config.server_defaults.user.value + original_s_password = driver_config.server_defaults.password.value + + # Configure server_defaults for a "remote" connection + driver_config.server_defaults.host.value = test_host + driver_config.server_defaults.port.value = str(test_port) if test_port else None + driver_config.server_defaults.user.value = fb_vars['user'] + driver_config.server_defaults.password.value = fb_vars['password'] + + # Ensure the test-specific DB alias is clean + if driver_config.get_database(db_alias): + driver_config.databases.value = [db_cfg for db_cfg in driver_config.databases.value if db_cfg.name != db_alias] + + test_db_config_entry = driver_config.register_database(db_alias) + test_db_config_entry.database.value = db_path_str + test_db_config_entry.server.value = None # Use server_defaults + + # Determine expected DSN based on _connect_helper logic for non-protocol DSNs + if test_host.startswith("\\\\"): # Windows Named Pipes + if test_port: + expected_dsn = f"{test_host}@{test_port}\\{db_path_str}" + else: + expected_dsn = f"{test_host}\\{db_path_str}" + elif test_port: # TCP/IP with port + expected_dsn = f"{test_host}/{test_port}:{db_path_str}" + else: # TCP/IP without port (or other local-like with host) + expected_dsn = f"{test_host}:{db_path_str}" + + conn = None + try: + conn = driver.connect(db_alias, charset='UTF8') + assert conn._att is not None, "Connection attachment failed" + assert conn.dsn == expected_dsn, f"Expected DSN '{expected_dsn}', got '{conn.dsn}'" + + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM RDB$DATABASE") + assert cur.fetchone()[0] == 1, "Query failed on the connection" + finally: + if conn and not conn.is_closed(): + conn.close() + # Restore original server_defaults + driver_config.server_defaults.host.value = original_s_host + driver_config.server_defaults.port.value = original_s_port + driver_config.server_defaults.user.value = original_s_user + driver_config.server_defaults.password.value = original_s_password + +def test_connect_with_driver_config_db_defaults_local(driver_cfg, db_file, fb_vars): + """ + Tests connect() when db_defaults provides the database path, and + server_defaults provides local connection info (host=None, port=None). + Here, connect() is called with a DSN-like string that is *not* a registered alias. + """ + db_path_str = str(db_file) # This will be our "DSN" to connect to + + # Save original defaults + original_s_host = driver_config.server_defaults.host.value + original_s_port = driver_config.server_defaults.port.value + original_s_user = driver_config.server_defaults.user.value + original_s_password = driver_config.server_defaults.password.value + original_db_database = driver_config.db_defaults.database.value + original_db_server = driver_config.db_defaults.server.value + + + # Configure server_defaults for local connection + driver_config.server_defaults.host.value = None + driver_config.server_defaults.port.value = None + driver_config.server_defaults.user.value = fb_vars['user'] + driver_config.server_defaults.password.value = fb_vars['password'] + + # Configure db_defaults (it won't be used for database path if DSN is absolute path) + # but it's good to ensure it's set to something known for the test. + # The key here is that if connect(db_path_str) is called and db_path_str is + # an absolute path, it's treated as the DSN. Server info then comes from + # server_defaults IF db_path_str is NOT a full DSN with host/port. + # If db_path_str is an absolute path, it's treated as the direct database target. + driver_config.db_defaults.database.value = "some_default_db_ignore" # Should not be used if DSN is absolute + driver_config.db_defaults.server.value = None # Use server_defaults + + expected_dsn = db_path_str # For local connection with absolute path, DSN is the path + + conn = None + try: + # Connect using the absolute path as the DSN + conn = driver.connect(db_path_str, charset='UTF8') + assert conn._att is not None, "Connection attachment failed" + assert conn.dsn == expected_dsn, f"Expected DSN '{expected_dsn}', got '{conn.dsn}'" + + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM RDB$DATABASE") + assert cur.fetchone()[0] == 1, "Query failed on the connection" + finally: + if conn and not conn.is_closed(): + conn.close() + # Restore originals + driver_config.server_defaults.host.value = original_s_host + driver_config.server_defaults.port.value = original_s_port + driver_config.server_defaults.user.value = original_s_user + driver_config.server_defaults.password.value = original_s_password + driver_config.db_defaults.database.value = original_db_database + driver_config.db_defaults.server.value = original_db_server From d7d184ea7a456e4e55c701f7ee3a15f24231f531 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Wed, 21 May 2025 19:00:43 +0200 Subject: [PATCH 06/19] Release 2.0.2 --- CHANGELOG.md | 6 ++++++ docs/changelog.txt | 5 +++++ pyproject.toml | 5 ++++- src/firebird/driver/__init__.py | 2 +- tests/test_insert_data.py | 14 +++++++------- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f5194..72fc2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.0.2] - 2025-05-21 + +### Fixed + +- #49: database host configuration not work with version 2 + ## [2.0.1] - 2025-05-05 ### Fixed diff --git a/docs/changelog.txt b/docs/changelog.txt index 1a3c243..b525f18 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -2,6 +2,11 @@ Changelog ######### +Version 2.0.2 +============= + +- #49: database host configuration not work with version 2 + Version 2.0.1 ============= diff --git a/pyproject.toml b/pyproject.toml index c6e6c2b..84ebbf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ dependencies = [ [tool.hatch.envs.hatch-test] extra-args = ["--host=localhost"] +extra-dependencies = [ + "packaging>=25.0", +] [[tool.hatch.envs.hatch-test.matrix]] python = ["3.11", "3.12", "3.13"] @@ -119,7 +122,7 @@ ban-relative-imports = "all" [tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +"test_*" = ["PLR2004", "S101", "TID252"] "fbapi.py" = ["N801", "E501"] "interfaces.py" = ["ARG001", "ARG002", "N801", "N803", "E501", "FBT001"] "hooks.py" = ["F401"] diff --git a/src/firebird/driver/__init__.py b/src/firebird/driver/__init__.py index 89139fa..8d286ef 100644 --- a/src/firebird/driver/__init__.py +++ b/src/firebird/driver/__init__.py @@ -133,4 +133,4 @@ ) #: Current driver version, SEMVER string. -__VERSION__ = '2.0.1' +__VERSION__ = '2.0.2' diff --git a/tests/test_insert_data.py b/tests/test_insert_data.py index d70d51d..336228b 100644 --- a/tests/test_insert_data.py +++ b/tests/test_insert_data.py @@ -49,7 +49,7 @@ def utf8_connection(dsn): def test_insert_integers(db_connection): with db_connection.cursor() as cur: - cur.execute('insert into T2 (C1,C2,C3) values (?,?,?)', [1, 1, 1]) + cur.execute('insert into T2 (C1,C2,C3) values (?,?,?)', ['1', '1', '1']) db_connection.commit() cur.execute('select C1,C2,C3 from T2 where C1 = 1') rows = cur.fetchall() @@ -97,12 +97,12 @@ def test_insert_datetime(db_connection): # Insert from string (driver handles conversion if possible, though explicit types are better) # Note: Microsecond separator might vary based on driver/server locale. Use types. - # cur.execute('insert into T2 (C1,C6,C7,C8) values (?,?,?,?)', [4, '2011-11-13', '15:0:1.200', '2011-11-13 15:0:1.2000']) - # db_connection.commit() - # cur.execute('select C1,C6,C7,C8 from T2 where C1 = 4') - # rows = cur.fetchall() - # assert rows == [(4, datetime.date(2011, 11, 13), datetime.time(15, 0, 1, 200000), - # datetime.datetime(2011, 11, 13, 15, 0, 1, 200000))] + cur.execute('insert into T2 (C1,C6,C7,C8) values (?,?,?,?)', [4, '2011-11-13', '15:0:1.200', '2011-11-13 15:0:1.2000']) + db_connection.commit() + cur.execute('select C1,C6,C7,C8 from T2 where C1 = 4') + rows = cur.fetchall() + assert rows == [(4, datetime.date(2011, 11, 13), datetime.time(15, 0, 1, 200000), + datetime.datetime(2011, 11, 13, 15, 0, 1, 200000))] # encode date before 1859-11-17 produce a negative number From c4111d10d9bdc120c12588061e297fa91dbc391b Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 2 Jun 2025 21:09:59 +0200 Subject: [PATCH 07/19] fix --- .github/FUNDING.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index cc93cff..268ea1c 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: [pcisar] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username @@ -12,4 +12,4 @@ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cl polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username -custom: https://firebirdsql.org/en/donate/ +custom: # https://firebirdsql.org/en/donate/ From c8293502226d499baf33c1dd8dcb94bf56482769 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Tue, 3 Jun 2025 08:45:52 +0200 Subject: [PATCH 08/19] Fix required Python version --- docs/index.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.txt b/docs/index.txt index 1d59c97..3a5e65f 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -18,7 +18,7 @@ written by Helen Borrie and published by IBPhoenix_. .. seealso:: `firebird-lib`_ package for optional extensions to this driver. -.. note:: Requires Python 3.8+ +.. note:: Requires Python 3.11+ .. tip:: You can download docset for Dash_ (MacOS) or Zeal_ (Windows / Linux) documentation readers from releases_ at github. From 2b7040a2fdce7baa0afef56f6e386ce92eea108b Mon Sep 17 00:00:00 2001 From: Hristo Stefanov <1440027+qweqq@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:55:46 +0300 Subject: [PATCH 09/19] fix #53 Loss of scale when reading numeric with zero value --- src/firebird/driver/core.py | 4 ++-- tests/test_issues.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index 76528d9..4d10555 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -3392,7 +3392,7 @@ def _extract_db_array_to_list(self, esize: int, dtype: int, subtype: int, elif dtype in (a.blr_short, a.blr_long, a.blr_int64): val = (0).from_bytes(buf[bufpos:bufpos + esize], 'little', signed=True) if subtype or scale: - val = decimal.Decimal(val) / _ten_to[abs(scale)] + val = decimal.Decimal(val).scaleb(-abs(scale)) elif dtype == a.blr_bool: val = (0).from_bytes(buf[bufpos:bufpos + esize], 'little') == 1 elif dtype == a.blr_float: @@ -3799,7 +3799,7 @@ def _unpack_output(self) -> tuple: value = (0).from_bytes(buffer[offset:offset + length], 'little', signed=True) # It's scalled integer? if desc.subtype or desc.scale: - value = decimal.Decimal(value) / _ten_to[abs(desc.scale)] + value = decimal.Decimal(value).scaleb(-abs(desc.scale)) elif datatype == SQLDataType.DATE: value = _util.decode_date(buffer[offset:offset+length]) elif datatype == SQLDataType.TIME: diff --git a/tests/test_issues.py b/tests/test_issues.py index c52fbaa..8f27d58 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -31,3 +31,11 @@ def test_issue_02(db_connection): cur.execute('select C1,C2,C3 from T2 where C1 = 1') rows = cur.fetchall() assert rows == [(1, None, 1)] + +def test_issue_53(db_connection): + with db_connection.cursor() as cur: + cur.execute("select cast('0.00' as numeric(9,2)) from rdb$database") + numeric_val = cur.fetchone()[0] + numeric_val_exponent = numeric_val.as_tuple()[2] + db_connection.commit() + assert numeric_val_exponent == -2 From 0bb5400a7321b8cb261739e1613fc8d5355d5eda Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Tue, 5 Aug 2025 18:39:25 +0200 Subject: [PATCH 10/19] Fix for #51 and Cursor.description - Fix for #51 - Fixed problem with DECFLOAT and TZ DATE/TIMESTAMP in Cursor.description --- src/firebird/driver/core.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index 4d10555..1416bc6 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -1799,7 +1799,8 @@ def __del__(self): warn("Connection disposed without prior close()", ResourceWarning) self._close() self._close_internals() - self._att.detach() + with contextlib.suppress: + self._att.detach() def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_value, traceback) -> None: @@ -4278,7 +4279,8 @@ def description(self) -> tuple[DESCRIPTION]: elif meta.datatype == SQLDataType.INT64: vtype = int dispsize = 20 - elif meta.datatype in (SQLDataType.FLOAT, SQLDataType.D_FLOAT, SQLDataType.DOUBLE): + elif meta.datatype in (SQLDataType.FLOAT, SQLDataType.D_FLOAT, SQLDataType.DOUBLE, + SQLDataType.DEC16, SQLDataType.DEC34): # Special case, dialect 1 DOUBLE/FLOAT # could be Fixed point if (self._stmt._dialect < 3) and meta.scale: @@ -4291,13 +4293,13 @@ def description(self) -> tuple[DESCRIPTION]: vtype = str if meta.subtype == 1 else bytes scale = meta.subtype dispsize = 0 - elif meta.datatype == SQLDataType.TIMESTAMP: + elif meta.datatype in (SQLDataType.TIMESTAMP, SQLDataType.TIMESTAMP_TZ, SQLDataType.TIMESTAMP_TZ_EX): vtype = datetime.datetime dispsize = 22 elif meta.datatype == SQLDataType.DATE: vtype = datetime.date dispsize = 10 - elif meta.datatype == SQLDataType.TIME: + elif meta.datatype in (SQLDataType.TIME, SQLDataType.TIME_TZ, SQLDataType.TIME_TZ_EX): vtype = datetime.time dispsize = 11 elif meta.datatype == SQLDataType.ARRAY: From 78c79f19e3870ccdc8e79b82e07feb1454e4f3a7 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 16 Nov 2025 21:19:19 -0300 Subject: [PATCH 11/19] Remove extra newline in readline_timed(). --- src/firebird/driver/core.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index 1416bc6..faa1a43 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -5723,9 +5723,7 @@ def readline_timed(self, timeout: int) -> str | Sentinel | None: data = self.response.read_sized_string(encoding=self.encoding, errors=self.encoding_errors) if self.response.get_tag() == SrvInfoCode.TIMEOUT: return TIMEOUT - if data: - return data + '\n' - return None + return data if data else None def readline(self) -> str | None: """Get next line of textual output from last service query. From 151d49d5a1dc6631067800b88c12017bd26e65d4 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 16 Nov 2025 21:36:37 -0300 Subject: [PATCH 12/19] Remove extra trailing space. --- src/firebird/driver/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index faa1a43..9d5375e 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -5723,6 +5723,10 @@ def readline_timed(self, timeout: int) -> str | Sentinel | None: data = self.response.read_sized_string(encoding=self.encoding, errors=self.encoding_errors) if self.response.get_tag() == SrvInfoCode.TIMEOUT: return TIMEOUT + # read_sized_string() returns lines ending with '\r ' (CR + space). + # Strip the trailing space to get proper '\r' line endings. + if data and data.endswith('\r '): + data = data[:-1] # Remove space, keep '\r' return data if data else None def readline(self) -> str | None: """Get next line of textual output from last service query. From e8e89c25a38addd8b7e314eb2613a9e41b1fd99f Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Fri, 12 Dec 2025 18:01:53 +0100 Subject: [PATCH 13/19] Added DeepWiki shield --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4b371a0..2f57d17 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) [![PyPI - Downloads](https://img.shields.io/pypi/dm/firebird-driver)](https://pypi.org/project/firebird-driver) [![Libraries.io SourceRank](https://img.shields.io/librariesio/sourcerank/pypi/firebird-driver)](https://libraries.io/pypi/firebird-driver) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/FirebirdSQL/python3-driver) This package provides official Python Database API 2.0-compliant driver for the open source relational database Firebird®. In addition to the minimal feature set of From 2efbed39b4cddb78eca02ecf908219e28be7d824 Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Sun, 25 Jan 2026 09:32:58 -0300 Subject: [PATCH 14/19] Specify exception type in contextlib.suppress(). Fix #63. --- src/firebird/driver/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index 9d5375e..a5154ee 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -1799,7 +1799,7 @@ def __del__(self): warn("Connection disposed without prior close()", ResourceWarning) self._close() self._close_internals() - with contextlib.suppress: + with contextlib.suppress(DatabaseError): self._att.detach() def __enter__(self) -> Self: return self From 447b5fd64c3c718f912e70b6eddd205b408fd79e Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Wed, 15 Apr 2026 23:11:14 -0300 Subject: [PATCH 15/19] Fix segfault when closing cursor/connection after Statement.free(). Fix #65. When a Statement was freed (via context manager or explicit free()) before its Cursor was closed, Cursor._clear() would attempt to close an already- invalidated IResultSet, causing a segfault or DatabaseError. Now checks whether the Statement's interface is still valid before closing the result set. If the statement was already freed, the result set reference is safely discarded instead. --- src/firebird/driver/core.py | 8 +++++++- tests/conftest.py | 2 +- tests/test_issues.py | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index a5154ee..0615987 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -3965,7 +3965,13 @@ def _execute(self, operation: str | Statement, in_meta.release() def _clear(self) -> None: if self._result is not None: - self._result.close() + if self._stmt is not None and self._stmt._istmt is not None: + self._result.close() + else: + # Statement was already freed; the result set is invalidated + # at the Firebird API level, so we must not call close() on it. + # Also prevent __del__ from calling release() on the invalid interface. + self._result._refcnt = 0 self._result = None self._name = None self._last_fetch_status = None diff --git a/tests/conftest.py b/tests/conftest.py index 45776d9..14d72e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,7 +122,7 @@ def pytest_configure(config): client_lib = Path(client_lib) if not client_lib.is_file(): pytest.exit(f"Client library '{client_lib}' not found!") - driver_config.fb_client_library.value = client_lib + driver_config.fb_client_library.value = str(client_lib) # if host := config.getoption('host'): _vars_['host'] = host diff --git a/tests/test_issues.py b/tests/test_issues.py index 8f27d58..cdfc3f2 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -39,3 +39,29 @@ def test_issue_53(db_connection): numeric_val_exponent = numeric_val.as_tuple()[2] db_connection.commit() assert numeric_val_exponent == -2 + +def test_issue_65_prepare_ctx_mgr(db_connection): + """Freeing a Statement via context manager must not crash when cursor/connection closes.""" + with db_connection.cursor() as cur: + with cur.prepare('select count(*) from country where 1 < ?') as stmt: + row = cur.execute(stmt, (2,)).fetchone() + assert row is not None + +def test_issue_65_free_then_cursor_close(db_connection): + """Explicit stmt.free() followed by cursor.close() must not crash.""" + cur = db_connection.cursor() + stmt = cur.prepare('select count(*) from country where 1 < ?') + row = cur.execute(stmt, (2,)).fetchone() + assert row is not None + stmt.free() + cur.close() + +def test_issue_65_free_then_conn_close(dsn): + """stmt.free() followed by connection close must not crash.""" + from firebird.driver import connect + with connect(dsn) as conn: + cur = conn.cursor() + stmt = cur.prepare('select count(*) from country where 1 < ?') + row = cur.execute(stmt, (2,)).fetchone() + assert row is not None + stmt.free() From 509119ec78c1187a0a390cdd0ab636db62e865a8 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 20 Apr 2026 13:23:49 +0200 Subject: [PATCH 16/19] Fix issue with connection to server --- src/firebird/driver/core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index a5154ee..3713c7e 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -5854,11 +5854,11 @@ def connect_server(server: str, *, user: str | None=None, password: str | None=N srv_config = driver_config.get_server(server) if srv_config is None: srv_config = driver_config.server_defaults - host = server or None - port = None - else: - host = srv_config.host.value - port = srv_config.port.value + #host = server or None + #port = None + #else: + host = srv_config.host.value + port = srv_config.port.value if host is None: host = 'service_mgr' if not host.endswith('service_mgr'): From 9e842aaafbd89fd153d0c4111bcc26a35da2f187 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 20 Apr 2026 13:37:34 +0200 Subject: [PATCH 17/19] Fix for #56 --- src/firebird/driver/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/firebird/driver/core.py b/src/firebird/driver/core.py index 25a4775..211188c 100644 --- a/src/firebird/driver/core.py +++ b/src/firebird/driver/core.py @@ -2398,6 +2398,7 @@ def create_database(database: str | Path, *, user: str | None=None, password: st if isinstance(database, Path): database = str(database) db_config: DatabaseConfig = driver_config.get_database(database) + dsn: str | None = None if db_config is None: db_config = driver_config.db_defaults srv_config = driver_config.server_defaults From 38c5932dace67f4d38ecb789e5c4c25195d36f0b Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 20 Apr 2026 13:49:30 +0200 Subject: [PATCH 18/19] Update --- CHANGELOG.md | 11 +++++++++++ docs/changelog.txt | 10 ++++++++++ pyproject.toml | 11 ++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fc2aa..57c5dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.0.3] - 2026-04-20 + +### Fixed + +- #65: Segmentation fault when close connection or cursor +- #63: Exception ignored in: function Connection.__del__ +- #58: readline_timed() method appending \n to data received +- #56: Variable 'dsn' needs to be initialized in create_database() +- #53: Loss of scale when reading numeric with zero value +- #51: Exception ignored in: function Connection.__del__ ... connection shutdown + ## [2.0.2] - 2025-05-21 ### Fixed diff --git a/docs/changelog.txt b/docs/changelog.txt index b525f18..757ea57 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -2,6 +2,16 @@ Changelog ######### +Version 2.0.3 +============= + +- #65: Segmentation fault when close connection or cursor +- #63: Exception ignored in: function Connection.__del__ +- #58: readline_timed() method appending \n to data received +- #56: Variable 'dsn' needs to be initialized in create_database() +- #53: Loss of scale when reading numeric with zero value +- #51: Exception ignored in: function Connection.__del__ ... connection shutdown + Version 2.0.2 ============= diff --git a/pyproject.toml b/pyproject.toml index 84ebbf5..b4d010a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,15 @@ allow-direct-references = true dependencies = [ ] +[tool.hatch.envs.gdocs] +dependencies = [ + "great-docs", +] + +[tool.hatch.envs.gdocs.scripts] +build = "great-docs build" +preview = "great-docs preview" + [tool.hatch.envs.hatch-test] extra-args = ["--host=localhost"] extra-dependencies = [ @@ -62,7 +71,7 @@ extra-dependencies = [ ] [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.11", "3.12", "3.13"] +python = ["3.11", "3.12", "3.13", "3.14"] [tool.hatch.envs.doc] detached = false From 4d446148520b17d4f4dc12a99eefa0973b843c76 Mon Sep 17 00:00:00 2001 From: Pavel Cisar Date: Mon, 20 Apr 2026 13:56:44 +0200 Subject: [PATCH 19/19] Version bump --- src/firebird/driver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firebird/driver/__init__.py b/src/firebird/driver/__init__.py index 8d286ef..315b540 100644 --- a/src/firebird/driver/__init__.py +++ b/src/firebird/driver/__init__.py @@ -133,4 +133,4 @@ ) #: Current driver version, SEMVER string. -__VERSION__ = '2.0.2' +__VERSION__ = '2.0.3'