diff --git a/python/rcdb/app_context.py b/python/rcdb/app_context.py index 0e2cf7c..af79b71 100644 --- a/python/rcdb/app_context.py +++ b/python/rcdb/app_context.py @@ -19,6 +19,18 @@ def db(self) -> RCDBProvider: self._db_instance = RCDBProvider(self.connection_str) return self._db_instance + def close(self): + """Disconnect the database if one was opened. + + Safe to call when no connection was ever made. The CLI registers this on + the Click context so the engine is disposed deterministically when the + command finishes, instead of waiting for garbage collection (which on + Python 3.13+ leaks a ``ResourceWarning: unclosed database``). + """ + if self._db_instance is not None: + self._db_instance.disconnect() + self._db_instance = None + def require_connected_db(self) -> RCDBProvider: """Return the RCDBProvider, or fail with a clear CLI error if no connection was given. diff --git a/python/rcdb/cli/app.py b/python/rcdb/cli/app.py index ccfac5d..625b21c 100644 --- a/python/rcdb/cli/app.py +++ b/python/rcdb/cli/app.py @@ -60,6 +60,12 @@ def rcdb_cli(ctx, user_config, connection, config, verbose): for key, value in config: ctx.obj.set_config(key, value) + # Dispose the DB engine deterministically when the command finishes, rather + # than leaving it to garbage collection. On Python 3.13+ a lingering, GC'd + # SQLite connection emits "ResourceWarning: unclosed database" at an + # unpredictable time, which can leak into command output captured by tests. + ctx.call_on_close(ctx.obj.close) + # Bo commands given if ctx.invoked_subcommand is None: # There is a connection but no subcommand diff --git a/python/rcdb/provider.py b/python/rcdb/provider.py index 453c1df..97f2c61 100644 --- a/python/rcdb/provider.py +++ b/python/rcdb/provider.py @@ -150,9 +150,19 @@ def connect(self, connection_string="mysql+pymysql://rcdb@127.0.0.1/rcdb", check # Closes connection to data # ------------------------------------------------ def disconnect(self): - """Closes connection to database""" + """Closes connection to database. + + Closes the ORM session and disposes the SQLAlchemy engine so the + underlying connection pool releases its DBAPI connections. Without the + ``engine.dispose()`` the pooled SQLite/MySQL connection stays open until + the engine is garbage collected, which on Python 3.13+ surfaces as a + ``ResourceWarning: unclosed database`` at an unpredictable time. + """ self._is_connected = False - self.session.close() + if self.session is not None: + self.session.close() + if self.engine is not None: + self.engine.dispose() # ------------------------------------------------- # indicates ether the connection is open or not