async def test_getstatus_sync(reader, config: Config, snapshot: Snapshot, time: FakeTime): data = await reader.getjson("getstatus") assert data['firstSync'] is False assert data['folder_id'] is not None assert data['last_error'] is None assert data['last_snapshot_text'] != "Never" assert data['next_snapshot_text'] != "right now" assert len(data['snapshots']) == 1 assert data['sources'][SOURCE_GOOGLE_DRIVE] == { 'deletable': 1, 'name': SOURCE_GOOGLE_DRIVE, 'retained': 0, 'snapshots': 1, 'latest': time.asRfc3339String(time.now()), 'size': data['sources'][SOURCE_GOOGLE_DRIVE]['size'], 'enabled': True, 'max': config.get(Setting.MAX_SNAPSHOTS_IN_GOOGLE_DRIVE), 'title': "Google Drive" } assert data['sources'][SOURCE_HA] == { 'deletable': 1, 'name': SOURCE_HA, 'retained': 0, 'snapshots': 1, 'latest': time.asRfc3339String(time.now()), 'size': data['sources'][SOURCE_HA]['size'], 'enabled': True, 'max': config.get(Setting.MAX_SNAPSHOTS_IN_HASSIO), 'title': "Home Assistant", 'free_space': "0.0 B" } assert len(data['sources']) == 2
async def test_update_disable_drive(reader: ReaderHelper, server, coord: Coordinator, config: Config, drive_requests: DriveRequests): # Disable drive drive_requests.creds = None os.remove(config.get(Setting.CREDENTIALS_FILE_PATH)) assert not coord.enabled() await coord.sync() assert len(coord.snapshots()) == 0 # Disable Drive Upload update = { "config": { Setting.ENABLE_DRIVE_UPLOAD.value: False }, "snapshot_folder": "" } assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved', "reload_page": True } assert config.get(Setting.ENABLE_DRIVE_UPLOAD) is False # Verify the app is working fine. assert coord.enabled() await coord.waitForSyncToFinish() assert len(coord.snapshots()) == 1
async def test_update_error_reports_false(reader, ui_server, config: Config, supervisor: SimulatedSupervisor): assert config.get(Setting.SEND_ERROR_REPORTS) is False assert not config.isExplicit(Setting.SEND_ERROR_REPORTS) assert await reader.getjson("errorreports?send=false") == {'message': 'Configuration updated'} assert config.get(Setting.SEND_ERROR_REPORTS) is False assert config.isExplicit(Setting.SEND_ERROR_REPORTS) assert supervisor._options["send_error_reports"] is False
def get_username_password(): if not (Config.get(ConfigKey.USERNAME) and Config.get(ConfigKey.PASSWORD)): username, password = prompt_username_password() Config.set(ConfigKey.USERNAME, username) Config.set(ConfigKey.PASSWORD, password) return (Config.get(ConfigKey.USERNAME), Config.get(ConfigKey.PASSWORD))
async def test_update_sync_interval(reader, ui_server, config: Config, supervisor: SimulatedSupervisor): # Make sure the default saves nothing update = { "config": { "max_sync_interval_seconds": '1 hour', }, "snapshot_folder": "unused" } assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved' } assert config.get(Setting.MAX_SYNC_INTERVAL_SECONDS) == 60 * 60 assert "max_sync_interval_seconds" not in supervisor._options # Update custom update = { "config": { "max_sync_interval_seconds": '2 hours', }, "snapshot_folder": "unused" } assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved' } assert config.get(Setting.MAX_SYNC_INTERVAL_SECONDS) == 60 * 60 * 2 assert supervisor._options["max_sync_interval_seconds"] == 60 * 60 * 2
async def test_update_non_ui_setting(reader: ReaderHelper, server, session, coord: Coordinator, folder_finder: FolderFinder, config: Config): await coord.sync() # Change some config update = { "config": { "new_snapshot_timeout_seconds": 10 }, "snapshot_folder": "" } assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved', "reload_page": False } assert config.get(Setting.NEW_SNAPSHOT_TIMEOUT_SECONDS) == 10 update = {"config": {"max_snapshots_in_hassio": 1}, "snapshot_folder": ""} assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved', "reload_page": False } assert config.get(Setting.NEW_SNAPSHOT_TIMEOUT_SECONDS) == 10
async def test_snapshot_to_backup_upgrade_use_old_values( reader: ReaderHelper, time: FakeTime, coord: Coordinator, config: Config, supervisor: SimulatedSupervisor, ha: HaSource, drive: DriveSource, data_cache: DataCache, updater: HaUpdater): """ Test the path where a user upgrades from the addon before the backup rename and then chooses to use the old names""" status = await reader.getjson("getstatus") assert not status["warn_backup_upgrade"] # simulate upgrading config supervisor._options = {Setting.DEPRECTAED_MAX_BACKUPS_IN_HA.value: 7} await coord.sync() assert Setting.CALL_BACKUP_SNAPSHOT.value in supervisor._options assert config.get(Setting.CALL_BACKUP_SNAPSHOT) status = await reader.getjson("getstatus") assert status["warn_backup_upgrade"] assert not data_cache.checkFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME) assert not updater._trigger_once # simulate user clicking the button to use new names assert await reader.getjson("callbackupsnapshot?switch=false") == { 'message': 'Configuration updated' } assert data_cache.checkFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME) status = await reader.getjson("getstatus") assert not status["warn_backup_upgrade"] assert config.get(Setting.CALL_BACKUP_SNAPSHOT)
async def test_manual_creds(reader: ReaderHelper, ui_server: UiServer, config: Config, server, session, drive: DriveSource): # get the auth url req_path = "manualauth?client_id={}&client_secret={}".format(config.get( Setting.DEFAULT_DRIVE_CLIENT_ID), config.get(Setting.DEFAULT_DRIVE_CLIENT_SECRET)) data = await reader.getjson(req_path) assert "auth_url" in data # request the auth code from "google" async with session.get(data["auth_url"], allow_redirects=False) as resp: code = (await resp.json())["code"] drive.saveCreds(None) assert not drive.enabled() # Pass the auth code to generate creds req_path = "manualauth?code={}".format(code) assert await reader.getjson(req_path) == { 'auth_url': "index?fresh=true" } # verify creds are saved and drive is enabled assert drive.enabled() # Now verify that bad creds fail predictably req_path = "manualauth?code=bad_code" assert await reader.getjson(req_path) == { 'error': 'Your Google Drive credentials have expired. Please reauthorize with Google Drive through the Web UI.' }
async def test_update_non_ui_setting(reader: ReaderHelper, server, session, coord: Coordinator, folder_finder: FolderFinder, config: Config): await coord.sync() # Change some config update = { "config": { Setting.NEW_BACKUP_TIMEOUT_SECONDS.value: 10 }, "backup_folder": "" } assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved', "reload_page": False } assert config.get(Setting.NEW_BACKUP_TIMEOUT_SECONDS) == 10 update = { "config": { Setting.MAX_BACKUPS_IN_HA.value: 1 }, "backup_folder": "" } assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved', "reload_page": False } assert config.get(Setting.NEW_BACKUP_TIMEOUT_SECONDS) == 10
async def test_cred_refresh_upgrade_default_client(drive: DriveSource, server: SimulationServer, time: FakeTime, config: Config): return # TODO: Enable this when we start removing the default client_secret config.override(Setting.DEFAULT_DRIVE_CLIENT_ID, server.getSetting("drive_client_id")) creds = server.getCurrentCreds() creds_with_secret = server.getCurrentCreds() creds_with_secret._secret = server.getSetting("drive_client_secret") with open(config.get(Setting.CREDENTIALS_FILE_PATH), "w") as f: json.dump(creds_with_secret.serialize(), f) # reload the creds drive.drivebackend.tryLoadCredentials() # Verify the "client secret" was removed with open(config.get(Setting.CREDENTIALS_FILE_PATH)) as f: saved_creds = json.load(f) assert saved_creds == creds.serialize() await drive.get() old_creds = drive.drivebackend.cred_bearer await drive.get() assert old_creds == drive.drivebackend.cred_bearer time.advanceDay() await drive.get() assert old_creds != drive.drivebackend.cred_bearer
def __init__(self, config: Config, exchanger_builder: ClassAssistedBuilder[Exchanger], logger: CloudLogger, error_store: ErrorStore): self.exchanger = exchanger_builder.build( client_id=config.get(Setting.DEFAULT_DRIVE_CLIENT_ID), client_secret=config.get(Setting.DEFAULT_DRIVE_CLIENT_SECRET), redirect=config.get(Setting.AUTHENTICATE_URL)) self.logger = logger self.config = config self.error_store = error_store
def __init__(self, config: Config, exchanger_builder: ClassAssistedBuilder[Exchanger], logger: CloudLogger, error_store: ErrorStore): self.exchanger = exchanger_builder.build( client_id=config.get(Setting.DEFAULT_DRIVE_CLIENT_ID), client_secret=config.get(Setting.DEFAULT_DRIVE_CLIENT_SECRET), redirect=URL(config.get(Setting.AUTHORIZATION_HOST)).with_path("/drive/authorize")) self.logger = logger self.config = config self.error_store = error_store
def test_values_can_be_set_by_environment(self): for key in ConfigKey: os.environ["TEST_EDEM_BACKUP_%s" % key.value] = "%s_VALUE" % \ key.value for key in ConfigKey: self.assertEqual(Config.get(key), "%s_VALUE" % key.value)
async def test_failed_snapshot_retry(ha: HaSource, time: FakeTime, config: Config, supervisor: SimulatedSupervisor, interceptor: RequestInterceptor): # create a blocking snapshot interceptor.setError(URL_MATCH_SNAPSHOT_FULL, 524) config.override(Setting.NEW_SNAPSHOT_TIMEOUT_SECONDS, 0) await supervisor.toggleBlockSnapshot() snapshot_immediate = await ha.create(CreateOptions(time.now(), "Some Name")) assert isinstance(snapshot_immediate, PendingSnapshot) assert snapshot_immediate.name() == "Some Name" assert not ha.check() assert not snapshot_immediate.isFailed() await supervisor.toggleBlockSnapshot() # let the snapshot attempt to complete await asyncio.wait({ha._pending_snapshot_task}) # verify it failed with the expected http error assert snapshot_immediate.isFailed() assert snapshot_immediate._exception.status == 524 assert ha.check() assert not ha.check() time.advance(seconds=config.get(Setting.FAILED_SNAPSHOT_TIMEOUT_SECONDS)) # should trigger a sync after the failed snapshot timeout assert ha.check() await ha.get() assert not ha.check()
async def test_drive_cred_generation(reader: ReaderHelper, ui_server: UiServer, snapshot, config: Config, global_info: GlobalInfo, session: ClientSession, google): status = await reader.getjson("getstatus") assert len(status["snapshots"]) == 1 assert global_info.credVersion == 0 # Invalidate the drive creds, sync, then verify we see an error google.expireCreds() status = await reader.getjson("sync") assert status["last_error"]["error_type"] == ERROR_CREDS_EXPIRED # simulate the user going through the Drive authentication workflow auth_url = URL(config.get(Setting.AUTHENTICATE_URL)).with_query({ "redirectbacktoken": reader.getUrl(True) + "token", "version": VERSION, "return": reader.getUrl(True) }) async with session.get(auth_url) as resp: resp.raise_for_status() html = await resp.text() page = BeautifulSoup(html, 'html.parser') area = page.find("textarea") creds = str(area.getText()).strip() cred_url = URL(reader.getUrl(True) + "token").with_query({"creds": creds, "host": reader.getUrl(True)}) async with session.get(cred_url) as resp: resp.raise_for_status() # verify we got redirected to the addon main page. assert resp.url == URL(reader.getUrl(True)) await ui_server.sync(None) assert global_info._last_error is None assert global_info.credVersion == 1
def save(config: Config, to_start, to_watchdog_enable): with open(config.get(Setting.STOP_ADDON_STATE_PATH), "w") as f: json.dump( { "start": list(to_start), "watchdog": list(to_watchdog_enable) }, f)
def test_get_calls_load_when_needed(self, load): try: Config.get('foo') except KeyError: pass load.assert_called_once() load.reset_mock() try: Config.get('foo') except KeyError: pass load.assert_not_called()
async def test_update_multiple_deletes_setting(reader, ui_server, server, config: Config, time: FakeTime, ha: HaSource, global_info: GlobalInfo): assert await reader.getjson("confirmdelete?always=true") == { 'message': 'Configuration updated, I\'ll never ask again' } assert not config.get(Setting.CONFIRM_MULTIPLE_DELETES)
async def test_snapshot_to_backup_upgrade_avoid_default_overwrite( reader: ReaderHelper, time: FakeTime, coord: Coordinator, config: Config, supervisor: SimulatedSupervisor, ha: HaSource, drive: DriveSource, data_cache: DataCache, updater: HaUpdater): """ Test the path where a user upgrades from the addon but a new value with a default value gets overwritten""" status = await reader.getjson("getstatus") assert not status["warn_backup_upgrade"] # simulate upgrading config supervisor._options = { Setting.DEPRECTAED_MAX_BACKUPS_IN_HA.value: 7, Setting.MAX_BACKUPS_IN_HA.value: 4 # defuault, should get overridden } await coord.sync() assert Setting.CALL_BACKUP_SNAPSHOT.value in supervisor._options assert config.get(Setting.CALL_BACKUP_SNAPSHOT) assert config.get(Setting.MAX_BACKUPS_IN_HA) == 7
def getConfig(self) -> Config: alt_config = None index = 1 for arg in sys.argv[1:]: if arg == "--config": alt_config = sys.argv[index + 1] break index += 1 if alt_config: config = Config.withFileOverrides(alt_config) elif "PYTEST_CURRENT_TEST" in os.environ: config = Config() else: config = Config.fromFile(Setting.CONFIG_FILE_PATH.default()) logger.overrideLevel(config.get(Setting.CONSOLE_LOG_LEVEL), config.get(Setting.LOG_LEVEL)) return config
async def test_getstatus(reader, config: Config, ha, server, ports: Ports): File.touch(config.get(Setting.INGRESS_TOKEN_FILE_PATH)) await ha.init() data = await reader.getjson("getstatus") assert data['ask_error_reports'] is True assert data['cred_version'] == 0 assert data['firstSync'] is True assert data['folder_id'] is None assert data['last_error'] is None assert data['last_snapshot_text'] == "Never" assert data['next_snapshot_text'] == "right now" assert data['snapshot_name_template'] == config.get(Setting.SNAPSHOT_NAME) assert data['warn_ingress_upgrade'] is False assert len(data['snapshots']) == 0 assert data['sources'][SOURCE_GOOGLE_DRIVE] == { 'deletable': 0, 'name': SOURCE_GOOGLE_DRIVE, 'retained': 0, 'snapshots': 0, 'latest': None, 'size': '0.0 B', 'enabled': True, 'max': config.get(Setting.MAX_SNAPSHOTS_IN_GOOGLE_DRIVE), 'title': "Google Drive", 'icon': 'google-drive', 'ignored': 0, 'ignored_size': '0.0 B', } assert data['sources'][SOURCE_HA] == { 'deletable': 0, 'name': SOURCE_HA, 'retained': 0, 'snapshots': 0, 'latest': None, 'size': '0.0 B', 'enabled': True, 'max': config.get(Setting.MAX_SNAPSHOTS_IN_HASSIO), 'title': "Home Assistant", 'free_space': "0.0 B", 'icon': 'home-assistant', 'ignored': 0, 'ignored_size': '0.0 B', } assert len(data['sources']) == 2
async def test_cred_refresh_no_secret(drive: DriveSource, server: SimulationServer, time: FakeTime, config: Config): drive.saveCreds(server.getCurrentCreds()) await drive.get() old_creds = drive.drivebackend.creds await drive.get() assert old_creds.access_token == drive.drivebackend.creds.access_token time.advanceDay() await drive.get() assert old_creds.access_token != drive.drivebackend.creds.access_token with open(config.get(Setting.CREDENTIALS_FILE_PATH)) as f: assert "client_secret" not in json.load(f)
async def test_auth_and_restart(reader, ui_server, config: Config, restarter, coord: Coordinator, supervisor: SimulatedSupervisor): update = { "config": { "require_login": True, "expose_extra_server": True }, "snapshot_folder": "unused" } assert ui_server._starts == 1 assert not config.get(Setting.REQUIRE_LOGIN) assert await reader.postjson("saveconfig", json=update) == { 'message': 'Settings saved', "reload_page": False } await restarter.waitForRestart() assert config.get(Setting.REQUIRE_LOGIN) assert supervisor._options['require_login'] assert ui_server._starts == 2 await reader.get("getstatus", status=401, ingress=False) await reader.get("getstatus", auth=BasicAuth("user", "badpassword"), status=401, ingress=False) await reader.get("getstatus", auth=BasicAuth("user", "pass"), ingress=False) await coord.waitForSyncToFinish() status = await reader.getjson("getstatus", auth=BasicAuth("user", "pass"), ingress=False) # verify a the sync succeeded (no errors) assert status["last_error"] is None # The ingress server shouldn't require login, even though its turned on for the extra server await reader.get("getstatus") # even a bad user/pass should work await reader.get("getstatus", auth=BasicAuth("baduser", "badpassword"))
async def test_resolve_folder_new(reader, config: Config, snapshot, time, drive): # Simulate an existing folder error old_folder = await drive.getFolderId() os.remove(config.get(Setting.FOLDER_FILE_PATH)) time.advance(days=1) status = await reader.getjson("sync") assert status["last_error"]["error_type"] == ERROR_EXISTING_FOLDER assert (await reader.getjson("resolvefolder?use_existing=false")) == {'message': 'Done'} status = await reader.getjson("sync") assert status["last_error"] is None assert old_folder != await drive.getFolderId()
async def test_getstatus_sync(reader, config: Config, backup: Backup, time: FakeTime): data = await reader.getjson("getstatus") assert data['firstSync'] is False assert data['folder_id'] is not None assert data['last_error'] is None assert data['last_backup_text'] != "Never" assert data['next_backup_text'] != "right now" assert len(data['backups']) == 1 assert data['sources'][SOURCE_GOOGLE_DRIVE] == { 'deletable': 1, 'name': SOURCE_GOOGLE_DRIVE, 'retained': 0, 'backups': 1, 'latest': time.asRfc3339String(time.now()), 'size': data['sources'][SOURCE_GOOGLE_DRIVE]['size'], 'enabled': True, 'max': config.get(Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE), 'title': "Google Drive", 'icon': 'google-drive', 'free_space': "4.0 GB", 'ignored': 0, 'ignored_size': '0.0 B', } assert data['sources'][SOURCE_HA] == { 'deletable': 1, 'name': SOURCE_HA, 'retained': 0, 'backups': 1, 'latest': time.asRfc3339String(time.now()), 'size': data['sources'][SOURCE_HA]['size'], 'enabled': True, 'max': config.get(Setting.MAX_BACKUPS_IN_HA), 'title': "Home Assistant", 'free_space': data['sources'][SOURCE_HA]['free_space'], 'icon': 'home-assistant', 'ignored': 0, 'ignored_size': '0.0 B', } assert len(data['sources']) == 2
async def test_confirm_multiple_deletes(reader, ui_server, server, config: Config, time: FakeTime, ha: HaSource): # reconfigure to only store 1 snapshot server._options.update({ "max_snapshots_in_hassio": 1, "max_snapshots_in_google_drive": 1 }) config.override(Setting.MAX_SNAPSHOTS_IN_HASSIO, 1) config.override(Setting.MAX_SNAPSHOTS_IN_GOOGLE_DRIVE, 1) # create three snapshots await ha.create(CreateOptions(time.now(), "Name1")) await ha.create(CreateOptions(time.now(), "Name2")) await ha.create(CreateOptions(time.now(), "Name3")) # verify we have 3 snapshots an the multiple delete error status = await reader.getjson("sync") assert len(status['snapshots']) == 3 assert status["last_error"]["error_type"] == ERROR_MULTIPLE_DELETES assert status["last_error"]["data"] == { SOURCE_GOOGLE_DRIVE: 0, SOURCE_HA: 2 } # request that multiple deletes be allowed assert await reader.getjson("confirmdelete?always=false") == { 'message': 'Snapshots deleted this one time' } assert config.get(Setting.CONFIRM_MULTIPLE_DELETES) # backup, verify the deletes go through status = await reader.getjson("sync") assert status["last_error"] is None assert len(status["snapshots"]) == 1 # create another snapshot, verify we delete the one await ha.create(CreateOptions(time.now(), "Name1")) status = await reader.getjson("sync") assert len(status['snapshots']) == 1 assert status["last_error"] is None # create two mroe snapshots, verify we see the error again await ha.create(CreateOptions(time.now(), "Name1")) await ha.create(CreateOptions(time.now(), "Name2")) status = await reader.getjson("sync") assert len(status['snapshots']) == 3 assert status["last_error"]["error_type"] == ERROR_MULTIPLE_DELETES assert status["last_error"]["data"] == { SOURCE_GOOGLE_DRIVE: 0, SOURCE_HA: 2 }
async def test_cred_refresh_with_secret(drive: DriveSource, server: SimulationServer, time: FakeTime, config: Config): server.resetDriveAuth() with open(config.get(Setting.CREDENTIALS_FILE_PATH), "w") as f: creds = server.getCurrentCreds() creds._secret = config.get(Setting.DEFAULT_DRIVE_CLIENT_SECRET) json.dump(creds.serialize(), f) drive.drivebackend.tryLoadCredentials() await drive.get() old_creds = drive.drivebackend.creds # valid creds should be reused await drive.get() assert old_creds.access_token == drive.drivebackend.creds.access_token # then refreshed when they expire time.advanceDay() await drive.get() assert old_creds.access_token != drive.drivebackend.creds.access_token # verify the client_secret is kept with open(config.get(Setting.CREDENTIALS_FILE_PATH)) as f: assert "client_secret" in json.load(f)
async def test_config(reader, ui_server, config: Config, supervisor: SimulatedSupervisor): update = { "config": { "days_between_snapshots": 20, "drive_ipv4": "" }, "snapshot_folder": "unused" } assert ui_server._starts == 1 assert await reader.postjson("saveconfig", json=update) == {'message': 'Settings saved', "reload_page": False} assert config.get(Setting.DAYS_BETWEEN_SNAPSHOTS) == 20 assert supervisor._options["days_between_snapshots"] == 20 assert ui_server._starts == 1
async def test_getstatus(reader, config: Config, ha, server, ports: Ports): File.touch(config.get(Setting.INGRESS_TOKEN_FILE_PATH)) await ha.init() data = await reader.getjson("getstatus") assert data['ask_error_reports'] is True assert data['cred_version'] == 0 assert data['drive_enabled'] is True assert data['firstSync'] is True assert data['folder_id'] is None assert data['last_error'] is None assert data['last_snapshot_text'] == "Never" assert data['maxSnapshotsInDrive'] == config.get( Setting.MAX_SNAPSHOTS_IN_GOOGLE_DRIVE) assert data['maxSnapshotsInHasssio'] == config.get( Setting.MAX_SNAPSHOTS_IN_HASSIO) assert data['next_snapshot_text'] == "right now" assert data['restore_link'] == "https://{0}:{1}/hassio/snapshots".format( "{host}", ports.server) assert data['snapshot_name_template'] == config.get(Setting.SNAPSHOT_NAME) assert data['warn_ingress_upgrade'] is False assert len(data['snapshots']) == 0 assert data['sources'][SOURCE_GOOGLE_DRIVE] == { 'deletable': 0, 'name': SOURCE_GOOGLE_DRIVE, 'retained': 0, 'snapshots': 0, 'latest': None, 'size': '0.0 B' } assert data['sources'][SOURCE_HA] == { 'deletable': 0, 'name': SOURCE_HA, 'retained': 0, 'snapshots': 0, 'latest': None, 'size': '0.0 B' } assert len(data['sources']) == 2
def __init__(self, logger: CloudLogger, config: Config): try: cred = credentials.ApplicationDefault() firebase_admin.initialize_app(cred, { 'projectId': config.get(Setting.SERVER_PROJECT_ID), }) self.db = firestore.client() except Exception as e: logger.log_struct({ "error": "unable to initialize firestore, errors will not be logged to firestore. If you are running this on a developer machine, this error is normal.", "exception": str(e) }) self.db = None self.last_error = None