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.
- mllm_annotator/__init__.py +33 -0
- mllm_annotator/config.py +151 -0
- mllm_annotator/core.py +786 -0
- mllm_annotator/embedder.py +375 -0
- mllm_annotator/ui.py +1524 -0
- mllm_annotator-0.1.0.dist-info/METADATA +170 -0
- mllm_annotator-0.1.0.dist-info/RECORD +11 -0
- mllm_annotator-0.1.0.dist-info/WHEEL +5 -0
- mllm_annotator-0.1.0.dist-info/entry_points.txt +5 -0
- mllm_annotator-0.1.0.dist-info/licenses/LICENSE +21 -0
- mllm_annotator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
mllm_annotator/config.py
ADDED
|
@@ -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
|