# SPDX-FileCopyrightText: 2024 Geoffrey Lentner# SPDX-License-Identifier: Apache-2.0"""Database interface, models, and methods."""# type annotationsfrom__future__importannotationsfromtypingimportFinal# standard libsimportsysimportfunctools# external libsfromcmdkit.appimportApplication,exit_statusfromcmdkit.cliimportInterfacefromcmdkit.configimportConfigurationErrorfromsqlalchemyimportinspectfromsqlalchemy.ormimportclose_all_sessionsfromsqlalchemy.excimportOperationalError# internal libsfromhypershell.core.loggingimportLoggerfromhypershell.core.configimportconfigfromhypershell.core.exceptionsimporthandle_exception,DatabaseUninitialized,get_shared_exception_mappingfromhypershell.data.coreimportengine,in_memory,schemafromhypershell.data.modelimportEntity,Task# public interface__all__=['InitDBApp','initdb','truncatedb','checkdb','ensuredb','DATABASE_ENABLED',]# initialize loggerlog=Logger.with_name(__name__)DATABASE_ENABLED:Final[bool]=notin_memory"""Set if database has been configured."""
[docs]deftruncatedb()->None:"""Truncate database tables."""# NOTE: We still might hang here if other sessions exist outside this app instanceclose_all_sessions()log.trace('Dropping all tables')Entity.metadata.drop_all(engine)log.trace('Creating all tables')Entity.metadata.create_all(engine)log.warning(f'Truncated database')
[docs]defcheckdb()->None:"""Ensure database connection and tables exist."""ifnotinspect(engine).has_table('task',schema=schema):raiseDatabaseUninitialized('Use \'initdb\' to initialize the database')
[docs]defensuredb(auto_init:bool=False)->None:""" Ensure database configuration before applying any operations. If SQLite and `auto_init` we run :meth:`initdb`, else :meth:`checkdb`. """db=config.database.get('file',None)orconfig.database.get('database',None)ifconfig.database.provider=='sqlite'anddbin('',':memory:',None):raiseConfigurationError('Missing database configuration')ifconfig.database.provider=='sqlite'orauto_initisTrue:initdb()else:checkdb()
INITDB_PROGRAM='hs initdb'INITDB_USAGE=f"""\Usage:{INITDB_PROGRAM} [-h] [--truncate [--yes]] Initialize database (not needed for SQLite). Use --truncate to zero out the task metadata.\"""INITDB_HELP=f"""\{INITDB_USAGE}Options: -t, --truncate Truncate database (task metadata will be lost). -y, --yes Auto-confirm truncation (default will prompt). -h, --help Show this message and exit.\"""classInitDBApp(Application):"""Initialize database (not needed for SQLite)."""interface=Interface(INITDB_PROGRAM,INITDB_USAGE,INITDB_HELP)ALLOW_NOARGS=Truetruncate:bool=Falseinterface.add_argument('-t','--truncate',action='store_true')auto_confirm:bool=Falseinterface.add_argument('-y','--yes',action='store_true',dest='auto_confirm')exceptions={OperationalError:functools.partial(handle_exception,logger=log,status=exit_status.runtime_error),**get_shared_exception_mapping(__name__),}defrun(self:InitDBApp)->None:"""Business logic for `initdb`."""ifnotDATABASE_ENABLED:raiseConfigurationError('No database configured')elifnotself.truncate:initdb()elifself.auto_confirm:truncatedb()elifnotsys.stdout.isatty():raiseRuntimeError('Non-interactive prompt cannot confirm --truncate (see --yes).')else:ifconfig.database.provider=='sqlite':site=config.database.fileelse:site=config.database.get('host','localhost')print(f'Connected to: {config.database.provider} ({site})')response=input(f'Truncate database ({Task.count()} tasks)? [Y]es/no: ').strip()ifresponse.lower()in['','y','yes']:truncatedb()elifresponse.lower()in['n','no']:print('Stopping')else:raiseRuntimeError(f'Stopping (invalid response: "{response}")')