From 5aee978048aea8625489864272de914315db082d Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 25 Oct 2026 14:58:58 -0600 Subject: [PATCH] Configs without database rows read and validate only; imports run off the event loop The base import for a config with no sections now reads its file once, reports a 0-byte file as empty, treats a missing file as the defaults, and records success or failure itself (sending the failure Event on request). The per-config overrides that only set successful_import are removed. wrolpi.yaml and the downloader settings import in a thread at startup (import_file_configs), and the Import, Save and backup Import buttons run their config work in a thread. The pre-diff import path (import_decision, record_import_version) has no callers left and is removed. Co-Authored-By: Claude Opus 5.5 --- main.py | 37 +----- modules/archive/lib.py | 4 - modules/flasher/config.py | 5 - modules/inventory/catalog.py | 1 - modules/map/pins.py | 4 - modules/videos/lib.py | 4 - wrolpi/bookmarks.py | 6 - wrolpi/common.py | 132 +++++++++---------- wrolpi/config_api.py | 14 +- wrolpi/config_version.py | 6 +- wrolpi/downloader.py | 8 -- wrolpi/test/test_file_config_import.py | 175 +++++++++++++++++++++++++ 12 files changed, 251 insertions(+), 145 deletions(-) create mode 100644 wrolpi/test/test_file_config_import.py diff --git a/main.py b/main.py index c6066e6a..b083f8d6 100755 --- a/main.py +++ b/main.py @@ -4,7 +4,6 @@ import asyncio import logging import os import sys -from contextlib import contextmanager from sanic import Sanic from sanic.signals import Event @@ -15,7 +14,7 @@ from wrolpi import root_api # noqa from wrolpi.api_utils import api_app, perpetual_signal, per_worker_task from wrolpi.common import logger, check_media_directory, set_log_level, limit_concurrent, \ cancel_refresh_tasks, cancel_background_tasks, get_wrolpi_config, can_connect_to_server, wrol_mode_enabled, \ - create_empty_config_files, TRACE_LEVEL + create_empty_config_files, import_file_configs, TRACE_LEVEL from wrolpi.contexts import attach_shared_contexts, reset_shared_contexts, initialize_configs_contexts from wrolpi.dates import Seconds from modules.videos.cookies import lock_cookies @@ -29,15 +28,6 @@ from wrolpi.version import get_version_string logger = logger.getChild('wrolpi-main') -@contextmanager -def log_and_suppress(*exceptions, message="Exception suppressed"): - """Like contextlib.suppress but logs the exception at INFO level first.""" - try: - yield - except exceptions as e: - logger.info(f'{message}: {e}', exc_info=e) - - def db_main(args): """ Handle database migrations. This uses Alembic, supported commands are "upgrade", "downgrade", and "check". @@ -266,29 +256,10 @@ async def start_single_tasks(app: Sanic): logger.debug(f'start_single_tasks started') - # Import configs, ignore errors so the service will start. Configs will refuse to save if they failed to import. - with log_and_suppress(Exception, message='Failed to import wrolpi config'): - get_wrolpi_config().import_config() - logger.debug('wrolpi config imported') - + # Import configs that don't require the database. Errors are logged so the service will start; configs refuse + # to save if they failed to import. + await import_file_configs() wrolpi_config = get_wrolpi_config() - if wrolpi_config.successful_import: - # Only import other configs if WROLPi config was imported, and WROL mode is not enabled. - if wrolpi_config.wrol_mode: - logger.warning('Refusing to import other configs when WROL mode is enabled!') - else: - # Import configs that don't require the database. - with log_and_suppress(Exception, message='Failed to import videos downloader config'): - get_videos_downloader_config().import_config() - logger.debug('videos downloader config imported') - with log_and_suppress(Exception, message='Failed to import archive downloader config'): - from modules.archive.lib import get_archive_downloader_config - get_archive_downloader_config().import_config() - logger.debug('archive downloader config imported') - with log_and_suppress(Exception, message='Failed to import download cache config'): - from wrolpi.downloader import get_download_cache_config - get_download_cache_config().import_config() - logger.debug('download cache config imported') logger.debug('start_single_tasks waiting for db...') async with flags.db_up.wait_for(): diff --git a/modules/archive/lib.py b/modules/archive/lib.py index 78adffed..5112efe9 100644 --- a/modules/archive/lib.py +++ b/modules/archive/lib.py @@ -418,10 +418,6 @@ class ArchiveDownloaderConfig(ConfigFile): """Get the custom user agent, or None for system default.""" return self._config.get('user_agent') - def import_config(self, file: pathlib.Path = None, send_events=False): - super().import_config(file, send_events) - self.successful_import = True - # Global instance for archive downloader config ARCHIVE_DOWNLOADER_CONFIG: ArchiveDownloaderConfig = ArchiveDownloaderConfig() diff --git a/modules/flasher/config.py b/modules/flasher/config.py index a6bbd390..837b4c3b 100644 --- a/modules/flasher/config.py +++ b/modules/flasher/config.py @@ -26,11 +26,6 @@ class FlasherConfig(ConfigFile): ) validator = FlasherConfigValidator - def import_config(self, file=None, send_events=False): - super().import_config(file, send_events) - # YAML-only config (no database); a successful read is a successful import. - self.successful_import = True - @property def configurations(self) -> List[dict]: return list(self._config.get('configurations', [])) diff --git a/modules/inventory/catalog.py b/modules/inventory/catalog.py index fdd65a9d..b5d2a4aa 100644 --- a/modules/inventory/catalog.py +++ b/modules/inventory/catalog.py @@ -105,7 +105,6 @@ def import_catalog_config(): config = get_catalog_config() if config.get_file().is_file(): config.import_config() - config.successful_import = True seed_catalog_defaults(config) diff --git a/modules/map/pins.py b/modules/map/pins.py index a04014f4..d392f4e0 100644 --- a/modules/map/pins.py +++ b/modules/map/pins.py @@ -22,10 +22,6 @@ class MapPinsConfig(ConfigFile): ) validator = MapPinsConfigValidator - def import_config(self, file=None, send_events=False): - super().import_config(file, send_events) - self.successful_import = True - @property def pins(self) -> List[dict]: return list(self._config.get('pins', [])) diff --git a/modules/videos/lib.py b/modules/videos/lib.py index 777c07db..550ead92 100644 --- a/modules/videos/lib.py +++ b/modules/videos/lib.py @@ -909,10 +909,6 @@ class VideoDownloaderConfig(ConfigFile): def strict_codecs(self, value: bool): self.update({**self._config, 'strict_codecs': value}) - def import_config(self, file: pathlib.Path = None, send_events=False): - super().import_config(file, send_events) - self.successful_import = True - VIDEOS_DOWNLOADER_CONFIG: VideoDownloaderConfig = VideoDownloaderConfig() TEST_VIDEOS_DOWNLOADER_CONFIG: VideoDownloaderConfig = None diff --git a/wrolpi/bookmarks.py b/wrolpi/bookmarks.py index b3e4a34a..1fd53237 100644 --- a/wrolpi/bookmarks.py +++ b/wrolpi/bookmarks.py @@ -149,12 +149,6 @@ class BookmarksConfig(ConfigFile): return False return True - def import_config(self, file=None, send_events=False): - # An invalid file raises here, leaving successful_import False so save() will not - # overwrite it. A missing file is imported as empty, so the first save creates it. - super().import_config(file, send_events) - self.successful_import = True - @property def bookmarks(self) -> List[dict]: # A deep copy: the tree is nested, and callers mutate what they get back before diff --git a/wrolpi/common.py b/wrolpi/common.py index 0d366c86..62e23d61 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -772,26 +772,33 @@ class ConfigFile: if self.sections: return self.import_sections(file, send_events) + # A config without sections keeps no database rows: read and validate its file, nothing more. + from wrolpi.events import Events file = file or self.get_file() file_str = str(get_relative_to_media_directory(file)) - # Caller will set to successful if it works. self.successful_import = False - # Subclasses count entries they cannot import; a fresh import starts clean. self.import_skipped = 0 - if file.is_file(): - if not self.is_valid(file): - raise InvalidConfig(f'Config is invalid: {file_str}') - - data = self.read_config_file(file) - new_data, extra_data = partition(lambda i: i[0] in self.default_config, data.items()) - new_data, extra_data = dict(new_data), dict(extra_data) - if extra_data: - logger_.warning(f'Ignoring extra config data ({file_str}): {extra_data}') - self._config.update(new_data) - # Import call above this will set successful_import. - # self.successful_import = True - else: - logger_.error(f'Failed to import {file_str} because it does not exist.') + try: + if file.is_file(): + data = self.read_config_file(file) if file.stat().st_size else None + if not data or not self.validate(data): + raise InvalidConfig(f'Config is {"invalid" if data else "empty"}: {file_str}') + new_data, extra_data = partition(lambda i: i[0] in self.default_config, data.items()) + new_data, extra_data = dict(new_data), dict(extra_data) + if extra_data: + logger_.warning(f'Ignoring extra config data ({file_str}): {extra_data}') + self._config.update(new_data) + else: + # The defaults apply; the first save creates the file. + logger_.info(f'No config file, using the defaults: {file_str}') + self.successful_import = True + except Exception as e: + self.successful_import = False + message = f'Failed to import {file_str}' + logger_.error(message, exc_info=e) + if send_events: + Events.send_config_import_failed(message) + raise def import_sections(self, file: pathlib.Path = None, send_events=False): """Import a config with `sections`: read and validate the file once, decide what its version allows (see @@ -891,7 +898,7 @@ class ConfigFile: f'{len(diff.removed)} removed' for name, diff in changed.items()) or 'no changes')) self.after_import(diffs) if decision != IMPORT: - # The database may have rows the file does not list; save them (see `record_import_version`). + # The database may have rows the file does not list; save them (see `record_saved_version`). self.background_dump.activate_switch() return True @@ -924,41 +931,16 @@ class ConfigFile: try: self.import_config(send_events=send_events) finally: - # An import that returned before `import_decision` must not force a later one. + # An import that returned before reading `force_import` must not force a later one. self.force_import = False def is_versioned_by_database(self) -> bool: from wrolpi.config_version import versioned_config_names return self.file_name in versioned_config_names() - # What the last `import_decision` allowed; `record_import_version` records the version that follows from it. - last_import_decision: str = None - - def import_decision(self) -> str: - """After reading the file (`ConfigFile.import_config`), decide what importing it may do: IMPORT, - IMPORT_WITHOUT_DELETES, or SAVE (the database is newer; call `save_database_instead` and import nothing). - - On IMPORT the database's version is removed until `record_import_version`: the import's own writes must not - count, or an import that fails partway would leave the database "newer" than the file it did not finish - applying, and the next startup would save that partial database over the file.""" - from wrolpi.config_version import IMPORT, decide, delete_config_version, get_config_version - from wrolpi.db import get_db_session - with get_db_session(commit=True) as session: - db_version = get_config_version(session, self.file_name) - if self.force_import: - self.force_import = False - decision = IMPORT - else: - decision = decide(self.version or 0, db_version) - if decision == IMPORT: - delete_config_version(session, self.file_name) - logger_.debug(f'{self.file_name}: file version {self.version}, database version {db_version}: {decision}') - self.last_import_decision = decision - return decision - def version_decision(self, forced: bool = False) -> Tuple[str, Optional[int]]: - """What importing this config's file (already read) may do, and the database version that decided it. For - `import_sections`, which records the version in its own write (`import_decision` is for the others).""" + """What importing this config's file (already read) may do (IMPORT, IMPORT_WITHOUT_DELETES, or SAVE), and the + database version that decided it. `import_sections` records the file's version in its own write.""" from wrolpi.config_version import IMPORT, decide, get_config_version from wrolpi.db import get_db_session with get_db_session() as session: @@ -968,27 +950,11 @@ class ConfigFile: return decision, db_version def save_database_instead(self): - """The database has changes this config's file does not (see `import_decision`); save them.""" + """The database has changes this config's file does not (see `version_decision`); save them.""" logger_.warning(f'{self.file_name} is older than the database; saving the database instead of importing it') self.successful_import = True self.dump_config() - def record_import_version(self): - """The database now matches this config's file: record the file's version. Call last, after every write - the import makes. - - Not after IMPORT_WITHOUT_DELETES: the database may keep rows the file does not list (changes that were never - saved), so recording the file's version would let the next import delete them. Save them into the file - instead; that save records the version (`record_saved_version`). A save after a partial import is refused, - so the version stays unknown until an import applies the whole file.""" - from wrolpi.config_version import IMPORT, set_config_version - from wrolpi.db import get_db_session - if self.last_import_decision != IMPORT: - self.background_dump.activate_switch() - return - with get_db_session(commit=True) as session: - set_config_version(session, self.file_name, self.version or 0) - def db_version_for_dump(self, session) -> int: """The version `dump_config` writes: the database's, read in the same transaction as the rows it saves. Before the database has one, the file's next version (`save` records it).""" @@ -997,8 +963,8 @@ class ConfigFile: return db_version if db_version is not None else (self.version or 0) + 1 def record_saved_version(self, version: int): - """The file was just written from the database. If the database's version was unknown (see - `record_import_version`), the two are now in step: record it, so changes from here on count against it.""" + """The file was just written from the database. If the database's version was unknown (an import without + deletes), the two are now in step: record it, so changes from here on count against it.""" from wrolpi.config_version import create_config_version from wrolpi.db import get_db_session with get_db_session(commit=True) as session: @@ -1466,6 +1432,38 @@ def get_all_configs() -> Dict[str, ConfigFile]: return all_configs +async def import_file_configs(): + """Import the configs that do not need the database: wrolpi.yaml, then the downloader settings (not when + wrolpi.yaml failed, or while WROL mode is enabled). Errors are logged so the service starts; a config that failed + to import refuses to save. + + This runs from a worker's after_server_start while the worker is already accepting requests, so no import may + block the event loop.""" + from modules.archive.lib import get_archive_downloader_config + from modules.videos.lib import get_videos_downloader_config + from wrolpi.downloader import get_download_cache_config + + wrolpi_config = get_wrolpi_config() + try: + await asyncio.to_thread(wrolpi_config.import_config) + logger.debug('wrolpi config imported') + except Exception as e: + logger.warning(f'Failed to import wrolpi config: {e}') + + if not wrolpi_config.successful_import: + return + if wrolpi_config.wrol_mode: + logger.warning('Refusing to import other configs when WROL mode is enabled!') + return + + for config in (get_videos_downloader_config(), get_archive_downloader_config(), get_download_cache_config()): + try: + await asyncio.to_thread(config.import_config) + logger.debug(f'{config.file_name} imported') + except Exception as e: + logger.warning(f'Failed to import {config.file_name}: {e}') + + async def import_all_db_configs() -> dict[str, bool]: """ Import all configs that require the database. @@ -1670,10 +1668,8 @@ class WROLPiConfig(ConfigFile): return default_config_path def import_config(self, file: pathlib.Path = None, send_events=False): + super().import_config(file, send_events) try: - # WROLPiConfig does not sync to database. - super().import_config(file) - # Destinations cannot be empty. self._config['archive_destination'] = self.archive_destination or self.default_config['archive_destination'] self._config['map_destination'] = self.map_destination or self.default_config['map_destination'] @@ -1699,8 +1695,6 @@ class WROLPiConfig(ConfigFile): if repos_root not in ignored: ignored.append(repos_root) self._config['ignored_directories'] = ignored - - self.successful_import = True except Exception as e: self.successful_import = False message = f'Failed to import {self.file_name}' diff --git a/wrolpi/config_api.py b/wrolpi/config_api.py index 6f23424c..7564c49e 100644 --- a/wrolpi/config_api.py +++ b/wrolpi/config_api.py @@ -1,3 +1,5 @@ +import asyncio + from sanic import response, Blueprint from sanic.request import Request from sanic_ext.extensions.openapi import openapi @@ -46,11 +48,11 @@ def post_config_update(request: Request, body: config_schema.ConfigUpdateRequest body=config_schema.ConfigsRequest, validate=True, ) -def post_config_import(_: Request, body: config_schema.ConfigsRequest): +async def post_config_import(_: Request, body: config_schema.ConfigsRequest): config = get_config_by_file_name(body.file_name) try: # The user chose to apply the file, whatever its version. - config.import_forced(send_events=True) + await asyncio.to_thread(config.import_forced, send_events=True) except Exception as e: logger.error(f'Failed to import config: {body.file_name}', exc_info=e) raise InvalidConfig(f'Failed to import config {body.file_name}') @@ -64,10 +66,10 @@ def post_config_import(_: Request, body: config_schema.ConfigsRequest): body=config_schema.ConfigsRequest, validate=True, ) -def post_config_dump(_: Request, body: config_schema.ConfigsRequest): +async def post_config_dump(_: Request, body: config_schema.ConfigsRequest): config = get_config_by_file_name(body.file_name) try: - config.dump_config(send_events=True, overwrite=body.overwrite) + await asyncio.to_thread(config.dump_config, send_events=True, overwrite=body.overwrite) except Exception as e: logger.error(f'Failed to dump config: {body.file_name}', exc_info=e) raise InvalidConfig(f'Failed to dump config {body.file_name}') @@ -114,7 +116,7 @@ def post_config_backup_preview(_: Request, body: config_schema.ConfigBackupPrevi body=config_schema.ConfigBackupImportRequest, validate=True, ) -def post_config_backup_import(_: Request, body: config_schema.ConfigBackupImportRequest): +async def post_config_backup_import(_: Request, body: config_schema.ConfigBackupImportRequest): if body.mode not in ('merge', 'overwrite'): raise InvalidConfig('mode must be "merge" or "overwrite"') config = get_config_by_file_name(body.file_name) @@ -124,7 +126,7 @@ def post_config_backup_import(_: Request, body: config_schema.ConfigBackupImport if not backup_file.is_file(): raise InvalidConfig(f'Backup file not found for date {body.backup_date}') try: - config.import_backup(body.backup_date, body.mode, send_events=True) + await asyncio.to_thread(config.import_backup, body.backup_date, body.mode, send_events=True) except Exception as e: logger.error(f'Failed to import backup: {body.file_name} {body.backup_date}', exc_info=e) raise InvalidConfig(f'Failed to import backup {body.file_name}') diff --git a/wrolpi/config_version.py b/wrolpi/config_version.py index c56e371e..acb4139c 100644 --- a/wrolpi/config_version.py +++ b/wrolpi/config_version.py @@ -5,7 +5,7 @@ A database config (tags.yaml, channels.yaml, ...) saves rows the database owns. config's database version, in the same transaction (`_count_config_changes`). `dump_config` writes the database's version into the file it saves, and an import records the file's version once it has applied it. -So an import can tell which side is newer by comparing two integers (see `ConfigFile.import_decision`): +So an import can tell which side is newer by comparing two integers (see `ConfigFile.version_decision`): * file == database: in step. The file is current; import it, deleting what it no longer lists. * file > database: the file is newer (a restored or wiped database, a copied config); import it the same way. @@ -153,10 +153,6 @@ def get_config_version(session: Session, name: str) -> Optional[int]: return row[0] if row else None -def delete_config_version(session: Session, name: str): - session.connection().execute(text('DELETE FROM config_version WHERE name = :name'), {'name': name}) - - def create_config_version(session: Session, name: str, version: int): """Record `version` unless the database already has one.""" session.connection().execute( diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py index 4a344734..4323b259 100644 --- a/wrolpi/downloader.py +++ b/wrolpi/downloader.py @@ -2431,14 +2431,6 @@ class DownloadCacheConfig(ConfigFile): ) validator = DownloadCacheConfigValidator - def import_config(self, file: pathlib.Path = None, send_events=False): - file = file or self.get_file() - if not file.is_file(): - self.successful_import = True - return - super().import_config(file, send_events) - self.successful_import = True - @property def video_durations(self) -> List[list]: return self._config['video_durations'] or list() diff --git a/wrolpi/test/test_file_config_import.py b/wrolpi/test/test_file_config_import.py new file mode 100644 index 00000000..e3cd11eb --- /dev/null +++ b/wrolpi/test/test_file_config_import.py @@ -0,0 +1,175 @@ +"""A config that keeps no database rows imports by reading and validating its file, nothing more. The Import button +and the startup imports read files and may write the database, so they run off the event loop.""" +import asyncio +from http import HTTPStatus + +import pytest + +from modules.archive.lib import ArchiveDownloaderConfig +from modules.map.pins import MapPinsConfig +from modules.videos.lib import VideoDownloaderConfig +from wrolpi.common import ConfigFile, WROLPiConfig +from wrolpi import common +from wrolpi.downloader import DownloadCacheConfig +from wrolpi.errors import InvalidConfig +from wrolpi.events import Events + + +@pytest.fixture +def pins_config(async_client): + config = MapPinsConfig() + config.get_file().parent.mkdir(parents=True, exist_ok=True) + return config + + +def on_event_loop() -> bool: + try: + asyncio.get_running_loop() + return True + except RuntimeError: + return False + + +@pytest.mark.asyncio +async def test_import(pins_config): + """A valid file is applied, and the import succeeds without the subclass saying so.""" + pins_config.get_file().write_text('version: 3\npins: [one, two]\n') + + pins_config.import_config() + + assert pins_config.successful_import is True + assert pins_config._config['pins'] == ['one', 'two'] + + +@pytest.mark.asyncio +async def test_import_reads_once(pins_config, monkeypatch): + """The file is parsed once, not once to validate and again to apply.""" + pins_config.get_file().write_text('version: 3\npins: [one]\n') + reads = [] + read = ConfigFile.read_config_file + monkeypatch.setattr(MapPinsConfig, 'read_config_file', lambda self, *a: reads.append(1) or read(self, *a)) + + pins_config.import_config() + + assert len(reads) == 1 + + +@pytest.mark.asyncio +async def test_import_missing(pins_config): + """A missing file is imported as the defaults; the first save creates it.""" + pins_config.import_config() + + assert pins_config.successful_import is True + assert pins_config._config['pins'] == [] + + +@pytest.mark.asyncio +async def test_import_empty(pins_config): + """A 0-byte file is reported as empty, and never saved over.""" + pins_config.get_file().write_text('') + + with pytest.raises(InvalidConfig, match='empty'): + pins_config.import_config() + + assert pins_config.successful_import is False + + +@pytest.mark.asyncio +async def test_import_invalid(pins_config, monkeypatch): + """An invalid file raises, is never saved over, and the Import button reports it.""" + pins_config.get_file().write_text('- not\n- a mapping\n') + sent = [] + monkeypatch.setattr(Events, 'send_config_import_failed', lambda message: sent.append(message)) + + with pytest.raises(InvalidConfig): + pins_config.import_config(send_events=True) + + assert pins_config.successful_import is False + assert sent == ['Failed to import config/map_pins.yaml'] + with pytest.raises(RuntimeError, match='never successfully imported'): + pins_config.save() + + +@pytest.mark.asyncio +async def test_startup_imports_off_event_loop(test_wrolpi_config, monkeypatch): + """wrolpi.yaml and the downloader settings are imported in a thread at startup.""" + imported = [] + + def record(self, *args, **kwargs): + imported.append((self.file_name, on_event_loop())) + self.successful_import = True + + for cls in (WROLPiConfig, VideoDownloaderConfig, ArchiveDownloaderConfig, DownloadCacheConfig): + monkeypatch.setattr(cls, 'import_config', record) + + await common.import_file_configs() + + assert imported == [ + ('wrolpi.yaml', False), + ('videos_downloader.yaml', False), + ('archives_downloader.yaml', False), + ('download_cache.yaml', False), + ] + + +@pytest.mark.asyncio +async def test_startup_imports_wrol_mode(test_wrolpi_config, monkeypatch): + """No other config is imported when wrolpi.yaml failed, or while WROL mode is enabled.""" + imported = [] + + def record(self, *args, **kwargs): + imported.append(self.file_name) + + for cls in (VideoDownloaderConfig, ArchiveDownloaderConfig, DownloadCacheConfig): + monkeypatch.setattr(cls, 'import_config', record) + + def fail(self, *args, **kwargs): + self.successful_import = False + raise InvalidConfig('invalid') + + monkeypatch.setattr(WROLPiConfig, 'import_config', fail) + await common.import_file_configs() + assert imported == [] + + def wrol_mode(self, *args, **kwargs): + self.successful_import = True + self._config['wrol_mode'] = True + + monkeypatch.setattr(WROLPiConfig, 'import_config', wrol_mode) + await common.import_file_configs() + assert imported == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('path,method,body', [ + ('/api/config/import', 'import_forced', dict(file_name='tags.yaml')), + ('/api/config/dump', 'dump_config', dict(file_name='tags.yaml')), +]) +async def test_api_off_event_loop(async_client, test_tags_config, monkeypatch, path, method, body): + """The Import and Save buttons read the database and the file in a thread, not on the event loop.""" + from wrolpi.tags import TagsConfig + calls = [] + monkeypatch.setattr(TagsConfig, method, lambda self, *args, **kwargs: calls.append(on_event_loop())) + + request, response = await async_client.post(path, json=body) + + assert response.status_code == HTTPStatus.NO_CONTENT + assert calls == [False] + + +@pytest.mark.asyncio +async def test_backup_import_off_event_loop(async_client, test_tags_config, monkeypatch): + """Importing a backup runs in a thread, not on the event loop.""" + from wrolpi.tags import TagsConfig, get_tags_config + config = get_tags_config() + backup = config._get_backup_file('20260101') + backup.parent.mkdir(parents=True, exist_ok=True) + backup.write_text('version: 1\ntags: {}\n') + calls = [] + monkeypatch.setattr(TagsConfig, 'import_backup', lambda self, *args, **kwargs: calls.append(on_event_loop())) + + body = dict(file_name='tags.yaml', backup_date='20260101', mode='merge') + request, response = await async_client.post('/api/config/backup/import', json=body) + + assert response.status_code == HTTPStatus.NO_CONTENT + assert calls == [False]