mllm-annotator 0.1.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.
@@ -0,0 +1,33 @@
1
+ """mllm-annotator: resumable multimodal-LLM media annotation and embedding.
2
+
3
+ Gemini is the first (and currently only) backend; the public surface is kept
4
+ backend-agnostic so additional providers can be added later.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .config import (
12
+ BACKENDS,
13
+ Backend,
14
+ ConfigError,
15
+ clear_api_key,
16
+ get_api_key,
17
+ has_api_key,
18
+ store_api_key,
19
+ )
20
+ from .core import MediaItem, RateLimitReached
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ "BACKENDS",
25
+ "Backend",
26
+ "ConfigError",
27
+ "MediaItem",
28
+ "RateLimitReached",
29
+ "get_api_key",
30
+ "has_api_key",
31
+ "store_api_key",
32
+ "clear_api_key",
33
+ ]
@@ -0,0 +1,151 @@
1
+ """API key resolution and secure storage, per backend.
2
+
3
+ For each backend, a key is resolved in order from:
4
+
5
+ 1. an environment variable (e.g. ``GEMINI_API_KEY`` / ``GOOGLE_API_KEY``);
6
+ 2. a ``.env`` file in the current working directory;
7
+ 3. the OS keyring (Windows Credential Manager / macOS Keychain / Secret
8
+ Service), where the GUI stores keys the user enters.
9
+
10
+ The keyring is optional at runtime: if the ``keyring`` package or its backend
11
+ is unavailable, resolution silently falls back to the environment/``.env``.
12
+
13
+ Backends are registered in ``BACKENDS``. Gemini is the only one today; adding
14
+ another is just a new ``Backend`` entry plus its client wiring — key storage,
15
+ resolution, and the GUI dialog all pick it up automatically.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ KEYRING_SERVICE = "mllm-annotator"
26
+
27
+
28
+ class ConfigError(Exception):
29
+ """Raised when user-provided inputs are inconsistent or incomplete."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Backend:
34
+ """A model provider that needs an API key."""
35
+
36
+ id: str
37
+ label: str
38
+ env_vars: tuple[str, ...]
39
+
40
+
41
+ GEMINI = Backend(
42
+ id="gemini",
43
+ label="Google Gemini",
44
+ env_vars=("GEMINI_API_KEY", "GOOGLE_API_KEY"),
45
+ )
46
+
47
+ BACKENDS: dict[str, Backend] = {GEMINI.id: GEMINI}
48
+ DEFAULT_BACKEND = GEMINI.id
49
+
50
+
51
+ def _backend(backend_id: str) -> Backend:
52
+ try:
53
+ return BACKENDS[backend_id]
54
+ except KeyError:
55
+ raise ConfigError(f"Unknown backend: {backend_id}") from None
56
+
57
+
58
+ def load_env_file(path: Path) -> None:
59
+ """Load ``KEY=VALUE`` lines from *path* into os.environ (real env wins)."""
60
+ if not path.exists() or not path.is_file():
61
+ return
62
+ for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
63
+ line = raw_line.strip()
64
+ if not line or line.startswith("#") or "=" not in line:
65
+ continue
66
+ key, value = line.split("=", 1)
67
+ key = key.strip()
68
+ value = value.strip().strip('"').strip("'")
69
+ if key:
70
+ os.environ.setdefault(key, value)
71
+
72
+
73
+ def _keyring() -> Any | None:
74
+ """Return the keyring module, or None if it (or its backend) is unusable."""
75
+ try:
76
+ import keyring
77
+ except Exception:
78
+ return None
79
+ return keyring
80
+
81
+
82
+ def _keyring_get(backend_id: str) -> str | None:
83
+ kr = _keyring()
84
+ if kr is None:
85
+ return None
86
+ try:
87
+ return kr.get_password(KEYRING_SERVICE, backend_id)
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def _env_key(backend: Backend) -> str | None:
93
+ load_env_file(Path.cwd() / ".env")
94
+ for var in backend.env_vars:
95
+ value = os.environ.get(var)
96
+ if value:
97
+ return value
98
+ return None
99
+
100
+
101
+ def get_api_key(backend_id: str = DEFAULT_BACKEND) -> str:
102
+ """Return the API key for *backend_id*, or raise ConfigError with guidance."""
103
+ backend = _backend(backend_id)
104
+ key = _env_key(backend) or _keyring_get(backend.id)
105
+ if key:
106
+ return key
107
+ raise ConfigError(
108
+ f"No API key set for {backend.label}. Set {' or '.join(backend.env_vars)}, "
109
+ "add it to a .env file, or save it in the app (the 'Set API Key' button, "
110
+ "top-left)."
111
+ )
112
+
113
+
114
+ def key_source(backend_id: str = DEFAULT_BACKEND) -> str | None:
115
+ """Where *backend_id*'s key comes from: 'environment', 'keyring', or None."""
116
+ backend = _backend(backend_id)
117
+ if _env_key(backend):
118
+ return "environment"
119
+ if _keyring_get(backend.id):
120
+ return "keyring"
121
+ return None
122
+
123
+
124
+ def has_api_key(backend_id: str = DEFAULT_BACKEND) -> bool:
125
+ return key_source(backend_id) is not None
126
+
127
+
128
+ def store_api_key(key: str, backend_id: str = DEFAULT_BACKEND) -> None:
129
+ """Persist *key* for *backend_id* in the OS keyring."""
130
+ _backend(backend_id) # validate
131
+ kr = _keyring()
132
+ if kr is None:
133
+ raise ConfigError(
134
+ "Cannot save the API key: the 'keyring' package or its OS backend is "
135
+ "unavailable. Set an environment variable or use a .env file instead."
136
+ )
137
+ try:
138
+ kr.set_password(KEYRING_SERVICE, backend_id, key)
139
+ except Exception as exc: # noqa: BLE001 - surface backend failures clearly.
140
+ raise ConfigError(f"Could not save the API key to the keyring: {exc}") from exc
141
+
142
+
143
+ def clear_api_key(backend_id: str = DEFAULT_BACKEND) -> None:
144
+ """Remove *backend_id*'s key from the OS keyring (best effort)."""
145
+ kr = _keyring()
146
+ if kr is None:
147
+ return
148
+ try:
149
+ kr.delete_password(KEYRING_SERVICE, backend_id)
150
+ except Exception:
151
+ pass