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
+35 -15
View File
@@ -49,20 +49,37 @@ class Settings:
# import fails for some reason. # 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: try:
import keyring # noqa: F401 import keyring
return True from keyring.backends import fail
except ImportError: 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: def save_token(token: str) -> str:
"""Persist token. Returns a human-readable location description.""" """Persist token. Returns a human-readable location description."""
if _keyring_available(): keyring = _get_keyring()
import keyring if keyring is not None:
keyring.set_password(KEYRING_SERVICE, KEYRING_USER, token) try:
return "system keyring" 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.parent.mkdir(parents=True, exist_ok=True)
TOKEN_FALLBACK_FILE.write_text(token) TOKEN_FALLBACK_FILE.write_text(token)
try: try:
@@ -73,19 +90,22 @@ def save_token(token: str) -> str:
def load_token() -> str | None: def load_token() -> str | None:
if _keyring_available(): keyring = _get_keyring()
import keyring if keyring is not None:
tok = keyring.get_password(KEYRING_SERVICE, KEYRING_USER) try:
if tok: tok = keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
return tok if tok:
return tok
except Exception:
pass
if TOKEN_FALLBACK_FILE.exists(): if TOKEN_FALLBACK_FILE.exists():
return TOKEN_FALLBACK_FILE.read_text().strip() or None return TOKEN_FALLBACK_FILE.read_text().strip() or None
return None return None
def clear_token() -> None: def clear_token() -> None:
if _keyring_available(): keyring = _get_keyring()
import keyring if keyring is not None:
try: try:
keyring.delete_password(KEYRING_SERVICE, KEYRING_USER) keyring.delete_password(KEYRING_SERVICE, KEYRING_USER)
except Exception: 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): def test_token_fallback_roundtrip(config_module, monkeypatch):
"""Force the non-keyring fallback path and verify round-trip.""" """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 assert config_module.load_token() is None
@@ -82,7 +82,7 @@ def test_token_fallback_file_permissions(config_module, monkeypatch):
import os import os
import stat 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") config_module.save_token("sl.perm-test")
mode = os.stat(config_module.TOKEN_FALLBACK_FILE).st_mode 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.""" """If keyring is installed, verify we actually use it."""
pytest.importorskip("keyring") pytest.importorskip("keyring")
if not config_module._keyring_available(): if config_module._get_keyring() is None:
pytest.skip("keyring not importable") pytest.skip("keyring not usable")
import keyring import keyring
# Use the in-memory fallback backend to avoid touching real Keychain. # Use the in-memory fallback backend to avoid touching real Keychain.