eve-memory-client 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. eve_client/__init__.py +24 -0
  2. eve_client/__main__.py +4 -0
  3. eve_client/_version.py +28 -0
  4. eve_client/apply.py +324 -0
  5. eve_client/atomic.py +58 -0
  6. eve_client/auth/__init__.py +12 -0
  7. eve_client/auth/base.py +59 -0
  8. eve_client/auth/file_store.py +34 -0
  9. eve_client/auth/keyring_store.py +34 -0
  10. eve_client/auth/local_store.py +162 -0
  11. eve_client/backup.py +68 -0
  12. eve_client/claude_hook_entry.py +4 -0
  13. eve_client/claude_hooks.py +498 -0
  14. eve_client/cli.py +2028 -0
  15. eve_client/config.py +335 -0
  16. eve_client/detect/__init__.py +5 -0
  17. eve_client/detect/base.py +98 -0
  18. eve_client/gemini_hook_entry.py +4 -0
  19. eve_client/gemini_hooks.py +454 -0
  20. eve_client/importer/__init__.py +47 -0
  21. eve_client/importer/adapters.py +685 -0
  22. eve_client/importer/ledger.py +712 -0
  23. eve_client/importer/models.py +185 -0
  24. eve_client/importer/upload.py +790 -0
  25. eve_client/integrations/__init__.py +5 -0
  26. eve_client/integrations/claude_code.py +112 -0
  27. eve_client/integrations/claude_desktop.py +36 -0
  28. eve_client/integrations/codex_cli.py +86 -0
  29. eve_client/integrations/gemini_cli.py +128 -0
  30. eve_client/integrations/provider.py +56 -0
  31. eve_client/integrations/registry.py +21 -0
  32. eve_client/integrity.py +89 -0
  33. eve_client/interactive.py +312 -0
  34. eve_client/lock.py +116 -0
  35. eve_client/manifest.py +173 -0
  36. eve_client/memory_cli.py +240 -0
  37. eve_client/merge.py +815 -0
  38. eve_client/models.py +144 -0
  39. eve_client/oauth_device.py +188 -0
  40. eve_client/operation_policy.py +99 -0
  41. eve_client/operations.py +140 -0
  42. eve_client/path_policy.py +76 -0
  43. eve_client/plan.py +67 -0
  44. eve_client/recovery.py +27 -0
  45. eve_client/safe_fs.py +92 -0
  46. eve_client/scope.py +263 -0
  47. eve_client/server.py +574 -0
  48. eve_client/state_binding.py +167 -0
  49. eve_client/state_dir.py +72 -0
  50. eve_client/tool_state.py +79 -0
  51. eve_client/transaction_state.py +57 -0
  52. eve_client/tty.py +13 -0
  53. eve_client/uninstall.py +185 -0
  54. eve_client/verify.py +302 -0
  55. eve_memory_client-0.3.0.dist-info/METADATA +488 -0
  56. eve_memory_client-0.3.0.dist-info/RECORD +59 -0
  57. eve_memory_client-0.3.0.dist-info/WHEEL +4 -0
  58. eve_memory_client-0.3.0.dist-info/entry_points.txt +5 -0
  59. eve_memory_client-0.3.0.dist-info/licenses/LICENSE +200 -0
eve_client/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Eve client installer package."""
2
+
3
+ from ._version import __version__
4
+ from .auth import CredentialRecord, CredentialStore, LocalCredentialStore
5
+ from .cli import app, main
6
+ from .config import ResolvedConfig
7
+ from .integrations.provider import ToolProvider
8
+ from .models import ApplyResult, InstallPlan, PlannedAction, RollbackResult, ToolPlan
9
+
10
+ __all__ = [
11
+ "ApplyResult",
12
+ "CredentialRecord",
13
+ "CredentialStore",
14
+ "InstallPlan",
15
+ "LocalCredentialStore",
16
+ "PlannedAction",
17
+ "ResolvedConfig",
18
+ "RollbackResult",
19
+ "ToolPlan",
20
+ "ToolProvider",
21
+ "__version__",
22
+ "app",
23
+ "main",
24
+ ]
eve_client/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from eve_client.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
eve_client/_version.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tomllib
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as installed_version
7
+ from pathlib import Path
8
+
9
+
10
+ def _version_from_pyproject() -> str | None:
11
+ pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
12
+ if not pyproject_path.exists():
13
+ return None
14
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
15
+ return str(data["project"]["version"])
16
+
17
+
18
+ def resolve_version() -> str:
19
+ build_version = os.environ.get("EVE_CLIENT_BUILD_VERSION")
20
+ if build_version:
21
+ return build_version
22
+ try:
23
+ return installed_version("eve-memory-client")
24
+ except PackageNotFoundError:
25
+ return _version_from_pyproject() or "0.0.0+unknown"
26
+
27
+
28
+ __version__ = resolve_version()
eve_client/apply.py ADDED
@@ -0,0 +1,324 @@
1
+ """Install plan execution with transaction-aware rollback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ from eve_client.auth import CredentialStore
10
+ from eve_client.backup import create_backup, restore_backup, sha256_file, validate_config
11
+ from eve_client.config import ResolvedConfig
12
+ from eve_client.lock import installer_lock
13
+ from eve_client.manifest import load_manifest, write_manifest
14
+ from eve_client.models import (
15
+ ApplyResult,
16
+ InstallPlan,
17
+ ManifestRecord,
18
+ PlannedAction,
19
+ RollbackResult,
20
+ )
21
+ from eve_client.operation_policy import OperationPolicyError, validate_action_policy
22
+ from eve_client.operations import OperationContext, OperationError, execute_operation
23
+ from eve_client.plan import feature_enabled_for_tool
24
+ from eve_client.safe_fs import SafeFS
25
+ from eve_client.transaction_state import clear_transaction_state, write_transaction_state
26
+
27
+
28
+ class ApplyPlanError(RuntimeError):
29
+ """Raised when an install plan cannot be applied safely."""
30
+
31
+
32
+ class RollbackConflictError(ApplyPlanError):
33
+ """Raised when rollback would overwrite newer local changes."""
34
+
35
+ def __init__(self, message: str, *, conflicted_paths: list[str] | None = None) -> None:
36
+ super().__init__(message)
37
+ self.conflicted_paths = conflicted_paths or []
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class AppliedWrite:
42
+ action: PlannedAction
43
+ path: Path
44
+ backup_path: Path | None
45
+ backup_sha256: str | None
46
+ created_new_file: bool
47
+
48
+
49
+ def _allowed_roots_for_action(action: PlannedAction, config: ResolvedConfig) -> list[Path]:
50
+ if action.scope == "state":
51
+ return [config.state_dir]
52
+ if action.scope == "global-config" and action.path:
53
+ return [action.path.parent]
54
+ if action.scope == "project" and action.path:
55
+ return [config.project_root]
56
+ return [config.state_dir]
57
+
58
+
59
+ def _apply_action(
60
+ action: PlannedAction,
61
+ config: ResolvedConfig,
62
+ credential_store: CredentialStore,
63
+ secret: str | None,
64
+ auth_mode: str | None,
65
+ transaction_id: str,
66
+ ) -> AppliedWrite | None:
67
+ try:
68
+ validate_action_policy(action, config)
69
+ except OperationPolicyError as exc:
70
+ raise ApplyPlanError(str(exc)) from exc
71
+ try:
72
+ rendered = execute_operation(
73
+ OperationContext(
74
+ config=config,
75
+ credentials=credential_store,
76
+ action=action,
77
+ secret=secret,
78
+ auth_mode=auth_mode,
79
+ )
80
+ )
81
+ except OperationError as exc:
82
+ raise ApplyPlanError(str(exc)) from exc
83
+ if rendered.content is None:
84
+ return None
85
+
86
+ if action.path is None:
87
+ raise ApplyPlanError(f"{action.action_id} has no path")
88
+
89
+ path = action.path
90
+ created_new_file = not path.exists()
91
+ backup_path, backup_sha256 = (
92
+ create_backup(
93
+ path,
94
+ state_dir=config.state_dir,
95
+ transaction_id=transaction_id,
96
+ action_id=action.action_id,
97
+ )
98
+ if action.requires_backup and path.exists()
99
+ else (None, None)
100
+ )
101
+
102
+ SafeFS.from_roots(_allowed_roots_for_action(action, config)).write_text_atomic(
103
+ path,
104
+ rendered.content,
105
+ permissions=rendered.permissions or 0o600,
106
+ )
107
+ if action.action_type == "write_config" and not validate_config(
108
+ path, action.details["config_format"]
109
+ ):
110
+ raise ApplyPlanError(f"Rendered config for {action.tool} failed validation")
111
+
112
+ return AppliedWrite(
113
+ action=action,
114
+ path=path,
115
+ backup_path=backup_path,
116
+ backup_sha256=backup_sha256,
117
+ created_new_file=created_new_file,
118
+ )
119
+
120
+
121
+ def _rollback_applied_writes(applied_writes: list[AppliedWrite], config: ResolvedConfig) -> None:
122
+ for applied in reversed(applied_writes):
123
+ if applied.backup_path:
124
+ restore_backup(
125
+ applied.backup_path,
126
+ applied.path,
127
+ allowed_roots=_allowed_roots_for_action(applied.action, config),
128
+ )
129
+ elif applied.created_new_file and applied.path.exists():
130
+ SafeFS.from_roots(_allowed_roots_for_action(applied.action, config)).delete_file(
131
+ applied.path
132
+ )
133
+
134
+
135
+ def _ensure_record_matches_current_file(record: ManifestRecord) -> None:
136
+ path = Path(record.path)
137
+ if record.sha256 is None:
138
+ return
139
+ if not path.exists():
140
+ return
141
+ current_sha = sha256_file(path)
142
+ if current_sha != record.sha256:
143
+ raise RollbackConflictError(
144
+ f"Refusing to rollback {path}; file changed since Eve wrote it."
145
+ )
146
+
147
+
148
+ def _ensure_backup_integrity(record: ManifestRecord) -> None:
149
+ if not record.backup_path or not record.backup_sha256:
150
+ return
151
+ backup_path = Path(record.backup_path)
152
+ if not backup_path.exists():
153
+ raise RollbackConflictError(f"Backup missing for rollback: {backup_path}")
154
+ current_sha = sha256_file(backup_path)
155
+ if current_sha != record.backup_sha256:
156
+ raise RollbackConflictError(f"Backup integrity check failed for {backup_path}")
157
+
158
+
159
+ def _verify_restored_target(record: ManifestRecord) -> None:
160
+ if not record.backup_sha256 or not record.path:
161
+ return
162
+ path = Path(record.path)
163
+ if not path.exists():
164
+ raise RollbackConflictError(f"Rollback target missing after restore: {path}")
165
+ restored_sha = sha256_file(path)
166
+ if restored_sha != record.backup_sha256:
167
+ raise RollbackConflictError(f"Rollback restore hash mismatch for {path}")
168
+
169
+
170
+ def _preflight_rollback(records: list[ManifestRecord]) -> None:
171
+ conflicts: list[str] = []
172
+ for record in reversed(records):
173
+ try:
174
+ _ensure_backup_integrity(record)
175
+ _ensure_record_matches_current_file(record)
176
+ except RollbackConflictError as exc:
177
+ conflicts.append(str(exc))
178
+ if conflicts:
179
+ raise RollbackConflictError(
180
+ "Rollback blocked by file conflicts or backup integrity failures.",
181
+ conflicted_paths=conflicts,
182
+ )
183
+
184
+
185
+ def apply_install_plan(
186
+ plan: InstallPlan,
187
+ config: ResolvedConfig,
188
+ credential_store: CredentialStore,
189
+ provided_secrets: dict[str, str] | None = None,
190
+ provided_api_keys: dict[str, str] | None = None,
191
+ auth_overrides: dict[str, str] | None = None,
192
+ allowed_tools: list[str] | None = None,
193
+ ) -> ApplyResult:
194
+ transaction_id = str(uuid4())
195
+ provided_secrets = provided_secrets or provided_api_keys or {}
196
+ auth_overrides = auth_overrides or {}
197
+ with installer_lock(config.state_dir):
198
+ for tool_plan in plan.tool_plans:
199
+ if allowed_tools and tool_plan.tool not in allowed_tools:
200
+ continue
201
+ if (
202
+ tool_plan.tool == "codex-cli"
203
+ and tool_plan.supported
204
+ and bool(tool_plan.actions)
205
+ and not feature_enabled_for_tool(tool_plan.tool, config)
206
+ ):
207
+ raise ApplyPlanError(
208
+ "Codex CLI steps are present in this plan, but Codex is disabled at execution time."
209
+ )
210
+ all_records = load_manifest(
211
+ config.state_dir, allow_file_fallback=config.allow_file_secret_fallback
212
+ )
213
+ write_transaction_state(
214
+ config.state_dir,
215
+ {
216
+ "transaction_id": transaction_id,
217
+ "phase": "applying",
218
+ "environment": plan.environment,
219
+ "tools": [tool_plan.tool for tool_plan in plan.tool_plans],
220
+ },
221
+ )
222
+
223
+ applied_tools: list[str] = []
224
+ applied_actions = 0
225
+ for tool_plan in plan.tool_plans:
226
+ if allowed_tools and tool_plan.tool not in allowed_tools:
227
+ continue
228
+ if not feature_enabled_for_tool(tool_plan.tool, config):
229
+ continue
230
+ if not tool_plan.supported:
231
+ continue
232
+ applied_writes: list[AppliedWrite] = []
233
+ try:
234
+ for action in tool_plan.actions:
235
+ write_transaction_state(
236
+ config.state_dir,
237
+ {
238
+ "transaction_id": transaction_id,
239
+ "phase": "applying",
240
+ "environment": plan.environment,
241
+ "tool": tool_plan.tool,
242
+ "action_id": action.action_id,
243
+ "action_type": action.action_type,
244
+ },
245
+ )
246
+ applied = _apply_action(
247
+ action,
248
+ config,
249
+ credential_store,
250
+ provided_secrets.get(tool_plan.tool),
251
+ auth_overrides.get(tool_plan.tool) or tool_plan.auth_mode,
252
+ transaction_id,
253
+ )
254
+ if applied:
255
+ applied_writes.append(applied)
256
+ applied_actions += 1
257
+ except Exception:
258
+ _rollback_applied_writes(applied_writes, config)
259
+ raise
260
+
261
+ for applied in applied_writes:
262
+ all_records.append(
263
+ ManifestRecord(
264
+ transaction_id=transaction_id,
265
+ tool=applied.action.tool,
266
+ action_id=applied.action.action_id,
267
+ action_type=applied.action.action_type,
268
+ path=str(applied.path),
269
+ backup_path=str(applied.backup_path) if applied.backup_path else None,
270
+ sha256=sha256_file(applied.path),
271
+ backup_sha256=applied.backup_sha256,
272
+ scope=applied.action.scope,
273
+ environment=plan.environment,
274
+ )
275
+ )
276
+ if tool_plan.actions:
277
+ applied_tools.append(tool_plan.tool)
278
+
279
+ write_manifest(
280
+ config.state_dir, all_records, allow_file_fallback=config.allow_file_secret_fallback
281
+ )
282
+ clear_transaction_state(config.state_dir)
283
+ return ApplyResult(
284
+ transaction_id=transaction_id,
285
+ applied_actions=applied_actions,
286
+ applied_tools=applied_tools,
287
+ )
288
+
289
+
290
+ def rollback_transaction(config: ResolvedConfig, transaction_id: str) -> RollbackResult:
291
+ with installer_lock(config.state_dir):
292
+ write_transaction_state(
293
+ config.state_dir,
294
+ {"transaction_id": transaction_id, "phase": "rollback"},
295
+ )
296
+ records = load_manifest(
297
+ config.state_dir, allow_file_fallback=config.allow_file_secret_fallback
298
+ )
299
+ target = [record for record in records if record.transaction_id == transaction_id]
300
+ _preflight_rollback(target)
301
+ restored = 0
302
+ for record in reversed(target):
303
+ path = Path(record.path)
304
+ if record.backup_path:
305
+ if record.scope == "state":
306
+ allowed_roots = [config.state_dir]
307
+ elif record.scope == "project":
308
+ allowed_roots = [config.project_root]
309
+ else:
310
+ allowed_roots = [path.parent]
311
+ restore_backup(Path(record.backup_path), path, allowed_roots=allowed_roots)
312
+ _verify_restored_target(record)
313
+ elif path.exists():
314
+ SafeFS.from_roots(
315
+ [config.project_root] if record.scope == "project" else [path.parent]
316
+ ).delete_file(path)
317
+ restored += 1
318
+ write_manifest(
319
+ config.state_dir,
320
+ [record for record in records if record.transaction_id != transaction_id],
321
+ allow_file_fallback=config.allow_file_secret_fallback,
322
+ )
323
+ clear_transaction_state(config.state_dir)
324
+ return RollbackResult(transaction_id=transaction_id, restored_actions=restored)
eve_client/atomic.py ADDED
@@ -0,0 +1,58 @@
1
+ """Atomic write helpers for Eve client installer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import secrets
8
+ import stat
9
+ from pathlib import Path
10
+
11
+ from eve_client.path_policy import PathPolicy, ensure_path_is_safe
12
+
13
+
14
+ def _validate_existing_target(dir_fd: int, target_name: str, target_path: Path) -> None:
15
+ try:
16
+ stats = os.stat(target_name, dir_fd=dir_fd, follow_symlinks=False)
17
+ except FileNotFoundError:
18
+ return
19
+ if not stat.S_ISREG(stats.st_mode):
20
+ raise OSError(f"Refusing to operate on non-regular file: {target_path}")
21
+ if stats.st_nlink > 1:
22
+ raise OSError(f"Refusing to operate on multiply-linked file: {target_path}")
23
+
24
+
25
+ def atomic_write(
26
+ path: Path,
27
+ content: str,
28
+ permissions: int = 0o600,
29
+ allowed_roots: list[Path] | None = None,
30
+ ) -> None:
31
+ policy = PathPolicy.from_roots(allowed_roots or [path.parent])
32
+ target = ensure_path_is_safe(path, policy)
33
+ target.parent.mkdir(parents=True, exist_ok=True)
34
+ dir_flags = os.O_RDONLY
35
+ if hasattr(os, "O_DIRECTORY"):
36
+ dir_flags |= os.O_DIRECTORY
37
+ if hasattr(os, "O_NOFOLLOW"):
38
+ dir_flags |= os.O_NOFOLLOW
39
+ dir_fd = os.open(target.parent, dir_flags)
40
+ _validate_existing_target(dir_fd, target.name, target)
41
+ tmp_name = f".{target.name}.{secrets.token_hex(8)}.tmp"
42
+ file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
43
+ if hasattr(os, "O_NOFOLLOW"):
44
+ file_flags |= os.O_NOFOLLOW
45
+ fd = os.open(tmp_name, file_flags, permissions, dir_fd=dir_fd)
46
+ try:
47
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
48
+ handle.write(content)
49
+ handle.flush()
50
+ os.fsync(handle.fileno())
51
+ os.replace(tmp_name, target.name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
52
+ os.fsync(dir_fd)
53
+ except BaseException:
54
+ with contextlib.suppress(OSError):
55
+ os.unlink(tmp_name, dir_fd=dir_fd)
56
+ raise
57
+ finally:
58
+ os.close(dir_fd)
@@ -0,0 +1,12 @@
1
+ """Credential storage for Eve client."""
2
+
3
+ from .base import CredentialRecord, CredentialStore, CredentialStoreUnavailableError, OAuthSession
4
+ from .local_store import LocalCredentialStore
5
+
6
+ __all__ = [
7
+ "CredentialRecord",
8
+ "CredentialStore",
9
+ "CredentialStoreUnavailableError",
10
+ "LocalCredentialStore",
11
+ "OAuthSession",
12
+ ]
@@ -0,0 +1,59 @@
1
+ """Credential store interfaces for the Eve client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Protocol
7
+
8
+ from eve_client.models import ToolName
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class CredentialRecord:
13
+ tool: ToolName
14
+ auth_mode: str
15
+ source: str
16
+ value_masked: str
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class OAuthSession:
21
+ tool: ToolName
22
+ access_token: str
23
+ refresh_token: str | None = None
24
+ expires_at: int | None = None
25
+ scope: str | None = None
26
+ token_type: str = "Bearer"
27
+
28
+
29
+ class CredentialStore(Protocol):
30
+ def set_api_key(self, tool: ToolName, api_key: str) -> CredentialRecord:
31
+ """Persist an API key for a supported tool."""
32
+
33
+ def get_api_key(self, tool: ToolName) -> tuple[str | None, str | None]:
34
+ """Load an API key for a supported tool."""
35
+
36
+ def delete_api_key(self, tool: ToolName) -> None:
37
+ """Delete any stored API key for a supported tool."""
38
+
39
+ def set_bearer_token(self, tool: ToolName, token: str) -> CredentialRecord:
40
+ """Persist a bearer token for a supported tool."""
41
+
42
+ def get_bearer_token(self, tool: ToolName) -> tuple[str | None, str | None]:
43
+ """Load a bearer token for a supported tool."""
44
+
45
+ def delete_bearer_token(self, tool: ToolName) -> None:
46
+ """Delete any stored bearer token for a supported tool."""
47
+
48
+ def set_oauth_session(self, session: OAuthSession) -> CredentialRecord:
49
+ """Persist a structured OAuth session for a supported tool."""
50
+
51
+ def get_oauth_session(self, tool: ToolName) -> tuple[OAuthSession | None, str | None]:
52
+ """Load a structured OAuth session for a supported tool."""
53
+
54
+ def delete_oauth_session(self, tool: ToolName) -> None:
55
+ """Delete any stored structured OAuth session for a supported tool."""
56
+
57
+
58
+ class CredentialStoreUnavailableError(RuntimeError):
59
+ """Raised when no approved credential backend is available."""
@@ -0,0 +1,34 @@
1
+ """File-based credential storage fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from eve_client.safe_fs import SafeFS
9
+ from eve_client.state_dir import ensure_private_state_dir
10
+
11
+
12
+ class FileCredentialStore:
13
+ def __init__(self, path: Path, state_dir: Path) -> None:
14
+ self.path = path
15
+ self.state_dir = state_dir
16
+
17
+ def load(self) -> dict[str, str]:
18
+ if not self.path.exists():
19
+ return {}
20
+ try:
21
+ ensure_private_state_dir(self.state_dir)
22
+ return json.loads(
23
+ SafeFS.from_roots([self.state_dir]).read_text(self.path, encoding="utf-8")
24
+ )
25
+ except (OSError, json.JSONDecodeError):
26
+ return {}
27
+
28
+ def write(self, payload: dict[str, str]) -> None:
29
+ ensure_private_state_dir(self.state_dir)
30
+ SafeFS.from_roots([self.state_dir]).write_text_atomic(
31
+ self.path,
32
+ json.dumps(payload, indent=2) + "\n",
33
+ permissions=0o600,
34
+ )
@@ -0,0 +1,34 @@
1
+ """Keyring-backed credential storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyring
6
+ from keyring.errors import KeyringError, PasswordDeleteError
7
+
8
+ SERVICE_NAME = "eve-client"
9
+ WEAK_BACKEND_MARKERS = ("fail", "plaintext", "chainer", "null")
10
+
11
+
12
+ class KeyringCredentialStore:
13
+ def get(self, key_name: str) -> str | None:
14
+ return keyring.get_password(SERVICE_NAME, key_name)
15
+
16
+ def set(self, key_name: str, secret: str) -> None:
17
+ keyring.set_password(SERVICE_NAME, key_name, secret)
18
+
19
+ def delete(self, key_name: str) -> None:
20
+ try:
21
+ keyring.delete_password(SERVICE_NAME, key_name)
22
+ except PasswordDeleteError:
23
+ return
24
+
25
+ def backend_name(self) -> str:
26
+ backend = keyring.get_keyring()
27
+ return f"{backend.__class__.__module__}.{backend.__class__.__name__}"
28
+
29
+ def backend_is_low_assurance(self) -> bool:
30
+ name = self.backend_name().lower()
31
+ return any(marker in name for marker in WEAK_BACKEND_MARKERS)
32
+
33
+
34
+ __all__ = ["KeyringCredentialStore", "KeyringError"]