From c7705b95992aa29d92b4bb0a2b4ed34a079301b8 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 20 Apr 2026 14:34:17 -0400 Subject: [PATCH] fix: handle missing keyring backend in CI The keyring package imports successfully on headless Linux but uses a fail.Keyring backend that raises NoKeyringError on any operation. Detect this case in _get_keyring() and fall back to the file-based token store. --- tagsync/config.py | 50 +++++++++++++++++++++++++++++++------------- tests/test_config.py | 8 +++---- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/tagsync/config.py b/tagsync/config.py index 69be8f0..b5fc42e 100644 --- a/tagsync/config.py +++ b/tagsync/config.py @@ -49,20 +49,37 @@ class Settings: # import fails for some reason. -def _keyring_available() -> bool: +def _get_keyring(): + """Return the keyring module if a usable backend is available, else None. + + Importing `keyring` can succeed even on systems where no backend is + installed (e.g., headless Linux CI runners). In that case the backend is + `keyring.backends.fail.Keyring`, which raises `NoKeyringError` on any + operation. Detect that case up front and fall back to the file store. + """ try: - import keyring # noqa: F401 - return True + import keyring + from keyring.backends import fail except ImportError: - return False + return None + try: + backend = keyring.get_keyring() + except Exception: + return None + if isinstance(backend, fail.Keyring): + return None + return keyring def save_token(token: str) -> str: """Persist token. Returns a human-readable location description.""" - if _keyring_available(): - import keyring - keyring.set_password(KEYRING_SERVICE, KEYRING_USER, token) - return "system keyring" + keyring = _get_keyring() + if keyring is not None: + try: + keyring.set_password(KEYRING_SERVICE, KEYRING_USER, token) + return "system keyring" + except Exception: + pass TOKEN_FALLBACK_FILE.parent.mkdir(parents=True, exist_ok=True) TOKEN_FALLBACK_FILE.write_text(token) try: @@ -73,19 +90,22 @@ def save_token(token: str) -> str: def load_token() -> str | None: - if _keyring_available(): - import keyring - tok = keyring.get_password(KEYRING_SERVICE, KEYRING_USER) - if tok: - return tok + keyring = _get_keyring() + if keyring is not None: + try: + tok = keyring.get_password(KEYRING_SERVICE, KEYRING_USER) + if tok: + return tok + except Exception: + pass if TOKEN_FALLBACK_FILE.exists(): return TOKEN_FALLBACK_FILE.read_text().strip() or None return None def clear_token() -> None: - if _keyring_available(): - import keyring + keyring = _get_keyring() + if keyring is not None: try: keyring.delete_password(KEYRING_SERVICE, KEYRING_USER) except Exception: diff --git a/tests/test_config.py b/tests/test_config.py index bbd2736..e1214df 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -64,7 +64,7 @@ def test_settings_load_ignores_unknown_keys(config_module, tmp_path): def test_token_fallback_roundtrip(config_module, monkeypatch): """Force the non-keyring fallback path and verify round-trip.""" - monkeypatch.setattr(config_module, "_keyring_available", lambda: False) + monkeypatch.setattr(config_module, "_get_keyring", lambda: None) assert config_module.load_token() is None @@ -82,7 +82,7 @@ def test_token_fallback_file_permissions(config_module, monkeypatch): import os import stat - monkeypatch.setattr(config_module, "_keyring_available", lambda: False) + monkeypatch.setattr(config_module, "_get_keyring", lambda: None) config_module.save_token("sl.perm-test") mode = os.stat(config_module.TOKEN_FALLBACK_FILE).st_mode @@ -96,8 +96,8 @@ def test_token_keyring_roundtrip(config_module): """If keyring is installed, verify we actually use it.""" pytest.importorskip("keyring") - if not config_module._keyring_available(): - pytest.skip("keyring not importable") + if config_module._get_keyring() is None: + pytest.skip("keyring not usable") import keyring # Use the in-memory fallback backend to avoid touching real Keychain.