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
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:
+35
-15
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user