fix: handle missing keyring backend in CI
CI / Tests (macOS) (push) Has been cancelled
CI / Tests (Windows) (push) Has been cancelled
CI / Tests (Linux, Python 3.11) (push) Has been cancelled
CI / Tests (Linux, Python 3.12) (push) Has been cancelled
CI / Tests (Linux, Python 3.9) (push) Has been cancelled

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.
This commit is contained in:
2026-04-20 14:34:17 -04:00
parent e363020080
commit c7705b9599
2 changed files with 39 additions and 19 deletions
+30 -10
View File
@@ -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 = _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
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:
+4 -4
View File
@@ -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.