It's not totally crazy in that I see it all the time, but it's one of the two most common things I've found make Python code difficult to reason about.[0] After all, if you open a DB connection in __init__() -- how do you close it? This isn't C++ where we can tie that to a destructor. I've run into
so many Python codebases that do this and have tons of unclosed connections as a result.
A much cleaner way (IMO) to do this is use context managers that have explicit lifecycles, so something like this:
@contextmanager
def create_db_client(host: str, port: int) -> Generator[_DbClient, None, None]:
try:
connection = mydblib.connect(host, port)
client = _DbClient(connection)
yield client
finally:
connection.close()
class _DbClient:
def __init__(self, connection):
self._connection = connection
def do_thing_that_requires_connection(...):
...
Which lets you write client code that looks like
with create_db_client('localhost', 5432) as db_client: # port 3306 if you're a degenerate
db_client.do_thing_that_requires_connection(...)
This gives you type safety, connection safety, has minimal boilerplate for client code,
and ensures the connection is created and disposed of properly. Obviously in larger codebases there's some more nuances, and you might want to implement a `typing.Protocol` for `_DbClient` that lets you pass it around, but IMO the general idea is much better than initializing a connection to a DB, ZeroMQ socket, gRPC client, etc in __init__.
[0] The second is performing "heavy", potentially failing operations outside of functions and classes, which can cause failures when importing modules.