local-embed 0.2.1__tar.gz → 0.2.2__tar.gz

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.

Potentially problematic release.


This version of local-embed might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: local-embed
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Standalone local embedding server — GGUF / transformer models as an OpenAI-compatible /v1/embeddings service
5
5
  Project-URL: Homepage, https://github.com/juzcn/slife
6
6
  Project-URL: Repository, https://github.com/juzcn/slife
@@ -60,11 +60,22 @@ OpenAI-compatible client works.
60
60
 
61
61
  ## Install
62
62
 
63
- Requires [uv](https://docs.astral.sh/uv) (it manages the Python 3.13+
64
- runtime). The `transformer` backend installs the same everywhere:
63
+ Requires [uv](https://docs.astral.sh/uv). All installs below pin
64
+ **Python 3.13** (`requires-python` is `>=3.13`, but 3.13 is what CI tests,
65
+ and uv would otherwise pick the newest 3.14 it finds). Re-running any of
66
+ them over an **existing** install requires `--reinstall` (`uv tool install`
67
+ is a no-op on an already-installed tool otherwise), e.g. to upgrade to a new
68
+ release or pick up the `json5` dependency fix:
65
69
 
66
70
  ```bash
67
- uv tool install 'local-embed[transformer]' # sentence-transformers
71
+ uv tool install --python 3.13 --reinstall 'local-embed[gguf,transformer]' \
72
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
73
+ ```
74
+
75
+ The `transformer` backend installs the same everywhere:
76
+
77
+ ```bash
78
+ uv tool install --python 3.13 'local-embed[transformer]' # sentence-transformers
68
79
  ```
69
80
 
70
81
  `gguf` (llama-cpp-python) is the platform-sensitive one: PyPI ships
@@ -75,10 +86,10 @@ for your platform:
75
86
 
76
87
  | Platform | Command |
77
88
  |---|---|
78
- | Linux / macOS, CPU | `uv tool install 'local-embed[gguf]'` (PyPI wheel) |
79
- | **Windows, CPU** | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu` |
80
- | macOS arm64 (Metal) | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal` |
81
- | CUDA (Linux or Windows) | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu122` — pick `cu118` / `cu121` / `cu122` / `cu123` / `cu124` / `cu125` / `cu130` / `cu132` to match your CUDA version |
89
+ | Linux / macOS, CPU | `uv tool install --python 3.13 'local-embed[gguf]'` (PyPI wheel) |
90
+ | **Windows, CPU** | `uv tool install --python 3.13 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu` |
91
+ | macOS arm64 (Metal) | `uv tool install --python 3.13 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal` |
92
+ | CUDA (Linux or Windows) | `uv tool install --python 3.13 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu122` — pick `cu118` / `cu121` / `cu122` / `cu123` / `cu124` / `cu125` / `cu130` / `cu132` to match your CUDA version |
82
93
 
83
94
  When the index is an *extra*, uv uses its wheel only when PyPI has no
84
95
  compatible wheel for the platform (the Windows / Metal cases); on Linux
@@ -94,9 +105,9 @@ environment with the second, so they would not coexist:
94
105
 
95
106
  ```bash
96
107
  # both backends, Windows CPU:
97
- uv tool install 'local-embed[gguf,transformer]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
108
+ uv tool install --python 3.13 'local-embed[gguf,transformer]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
98
109
  # both backends, Linux / macOS CPU:
99
- uv tool install 'local-embed[gguf,transformer]'
110
+ uv tool install --python 3.13 'local-embed[gguf,transformer]'
100
111
  ```
101
112
 
102
113
  Core deps are `fastmcp` + `starlette`; the model backends are optional
@@ -194,14 +205,15 @@ source of truth.
194
205
 
195
206
  `local-embed set` (transformer) and `local-embed set-gguf` (gguf) upsert a
196
207
  model in the config, make it **active**, and pin port (and, for `set`, the
197
- HF cache). Both are **idempotent** — re-running yields the same config —
198
- and leave other models untouched.
208
+ HF cache + offline flag). Both are **idempotent** — re-running yields the
209
+ same config — and leave other models untouched.
199
210
 
200
211
  | | `set` (transformer) | `set-gguf` |
201
212
  |---|---|---|
202
213
  | `<model_name>` | required — HF repo id (key + repo loaded) | required — config key |
203
214
  | weight ref | `--HF_HUB_CACHE <dir>` — cache must contain the repo | `--path <PATH>` required — existing `.gguf` file |
204
215
  | cache fallback | env `HF_HUB_CACHE`, else error | — |
216
+ | env written | `HF_HUB_CACHE` + `HF_HUB_OFFLINE: "1"` (offline server) | — |
205
217
  | `--port <n>` | default `8000` | default `8000` |
206
218
 
207
219
  ```bash
@@ -34,11 +34,22 @@ OpenAI-compatible client works.
34
34
 
35
35
  ## Install
36
36
 
37
- Requires [uv](https://docs.astral.sh/uv) (it manages the Python 3.13+
38
- runtime). The `transformer` backend installs the same everywhere:
37
+ Requires [uv](https://docs.astral.sh/uv). All installs below pin
38
+ **Python 3.13** (`requires-python` is `>=3.13`, but 3.13 is what CI tests,
39
+ and uv would otherwise pick the newest 3.14 it finds). Re-running any of
40
+ them over an **existing** install requires `--reinstall` (`uv tool install`
41
+ is a no-op on an already-installed tool otherwise), e.g. to upgrade to a new
42
+ release or pick up the `json5` dependency fix:
39
43
 
40
44
  ```bash
41
- uv tool install 'local-embed[transformer]' # sentence-transformers
45
+ uv tool install --python 3.13 --reinstall 'local-embed[gguf,transformer]' \
46
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
47
+ ```
48
+
49
+ The `transformer` backend installs the same everywhere:
50
+
51
+ ```bash
52
+ uv tool install --python 3.13 'local-embed[transformer]' # sentence-transformers
42
53
  ```
43
54
 
44
55
  `gguf` (llama-cpp-python) is the platform-sensitive one: PyPI ships
@@ -49,10 +60,10 @@ for your platform:
49
60
 
50
61
  | Platform | Command |
51
62
  |---|---|
52
- | Linux / macOS, CPU | `uv tool install 'local-embed[gguf]'` (PyPI wheel) |
53
- | **Windows, CPU** | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu` |
54
- | macOS arm64 (Metal) | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal` |
55
- | CUDA (Linux or Windows) | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu122` — pick `cu118` / `cu121` / `cu122` / `cu123` / `cu124` / `cu125` / `cu130` / `cu132` to match your CUDA version |
63
+ | Linux / macOS, CPU | `uv tool install --python 3.13 'local-embed[gguf]'` (PyPI wheel) |
64
+ | **Windows, CPU** | `uv tool install --python 3.13 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu` |
65
+ | macOS arm64 (Metal) | `uv tool install --python 3.13 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal` |
66
+ | CUDA (Linux or Windows) | `uv tool install --python 3.13 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu122` — pick `cu118` / `cu121` / `cu122` / `cu123` / `cu124` / `cu125` / `cu130` / `cu132` to match your CUDA version |
56
67
 
57
68
  When the index is an *extra*, uv uses its wheel only when PyPI has no
58
69
  compatible wheel for the platform (the Windows / Metal cases); on Linux
@@ -68,9 +79,9 @@ environment with the second, so they would not coexist:
68
79
 
69
80
  ```bash
70
81
  # both backends, Windows CPU:
71
- uv tool install 'local-embed[gguf,transformer]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
82
+ uv tool install --python 3.13 'local-embed[gguf,transformer]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
72
83
  # both backends, Linux / macOS CPU:
73
- uv tool install 'local-embed[gguf,transformer]'
84
+ uv tool install --python 3.13 'local-embed[gguf,transformer]'
74
85
  ```
75
86
 
76
87
  Core deps are `fastmcp` + `starlette`; the model backends are optional
@@ -168,14 +179,15 @@ source of truth.
168
179
 
169
180
  `local-embed set` (transformer) and `local-embed set-gguf` (gguf) upsert a
170
181
  model in the config, make it **active**, and pin port (and, for `set`, the
171
- HF cache). Both are **idempotent** — re-running yields the same config —
172
- and leave other models untouched.
182
+ HF cache + offline flag). Both are **idempotent** — re-running yields the
183
+ same config — and leave other models untouched.
173
184
 
174
185
  | | `set` (transformer) | `set-gguf` |
175
186
  |---|---|---|
176
187
  | `<model_name>` | required — HF repo id (key + repo loaded) | required — config key |
177
188
  | weight ref | `--HF_HUB_CACHE <dir>` — cache must contain the repo | `--path <PATH>` required — existing `.gguf` file |
178
189
  | cache fallback | env `HF_HUB_CACHE`, else error | — |
190
+ | env written | `HF_HUB_CACHE` + `HF_HUB_OFFLINE: "1"` (offline server) | — |
179
191
  | `--port <n>` | default `8000` | default `8000` |
180
192
 
181
193
  ```bash
@@ -123,23 +123,31 @@ def main(argv: "list[str] | None" = None) -> int:
123
123
 
124
124
  engine = Engine(specs=settings["specs"], active=settings["active"])
125
125
 
126
- # Validate the models can actually run. `resolve_backend_runtime` (not
127
- # `check_backend_runtime`, which never imports) so the answer reflects
128
- # reality. Only the ACTIVE model blocks startup — the server can serve
129
- # it without the others; a non-active model with a missing backend or
130
- # gguf_path is a warning (it fails at load time if requested).
126
+ # Validate the active model can actually run. `resolve_backend_runtime`
127
+ # (not `check_backend_runtime`, which never imports) so the answer
128
+ # reflects reality. Only the ACTIVE model is import-checked at startup:
129
+ # a non-active model's backend import cost (torch!) is only worth paying
130
+ # when the model is actually requested — it fails at load time then,
131
+ # and this CLI must not stall on it (or hang an impatient Ctrl-C during
132
+ # the import). gguf_path is still checked for every model.
131
133
  active_name = engine.active_model
132
134
  for spec in settings["specs"]:
133
135
  problems: list[str] = []
134
136
  if spec.backend == "gguf" and not spec.gguf_path:
135
137
  problems.append("no gguf_path (set gguf_path in local_embed.json5)")
136
- if not resolve_backend_runtime(spec.backend):
137
- from local_embed.cmd_set import backend_install_hint
138
-
139
- problems.append(
140
- f"{spec.backend} backend not installed "
141
- f"({backend_install_hint(spec.backend)})"
142
- )
138
+ if spec.name == active_name:
139
+ try:
140
+ installed = resolve_backend_runtime(spec.backend)
141
+ except KeyboardInterrupt:
142
+ print("Interrupted — local-embed not started.", file=sys.stderr)
143
+ return 130
144
+ if not installed:
145
+ from local_embed.cmd_set import backend_install_hint
146
+
147
+ problems.append(
148
+ f"{spec.backend} backend not installed "
149
+ f"({backend_install_hint(spec.backend)})"
150
+ )
143
151
  if spec.name == active_name and problems:
144
152
  print(
145
153
  f"Error: active model '{spec.name}' cannot start: {'; '.join(problems)}",
@@ -24,6 +24,7 @@ from pathlib import Path
24
24
  from local_embed.config import load_config, write_config
25
25
 
26
26
  ENV_CACHE_KEY = "HF_HUB_CACHE"
27
+ ENV_OFFLINE_KEY = "HF_HUB_OFFLINE"
27
28
 
28
29
  # Official prebuilt-wheel indexes for llama-cpp-python (the upstream llama.cpp
29
30
  # project). PyPI carries no Windows wheels for llama-cpp-python, so on
@@ -42,16 +43,19 @@ def backend_install_hint(backend: str, platform: "str | None" = None) -> str:
42
43
  must be added or uv falls back to a source build (MSVC + CMake). On
43
44
  Linux/macOS the plain PyPI wheel is fine. (CUDA / Metal need their own
44
45
  indexes — see the README install matrix.)
46
+
47
+ The command always pins ``--python 3.13``: it is the version CI tests,
48
+ and without it uv would silently pick the newest Python it finds.
45
49
  """
46
50
  platform = platform or sys.platform
47
51
  if backend == "gguf":
48
52
  if platform == "win32":
49
53
  return (
50
- "uv tool install 'local-embed[gguf]' "
54
+ "uv tool install --python 3.13 'local-embed[gguf]' "
51
55
  f"--extra-index-url {LLAMA_CPP_INDEXES['cpu']}"
52
56
  )
53
- return "uv tool install 'local-embed[gguf]'"
54
- return f"uv tool install 'local-embed[{backend}]'"
57
+ return "uv tool install --python 3.13 'local-embed[gguf]'"
58
+ return f"uv tool install --python 3.13 'local-embed[{backend}]'"
55
59
 
56
60
 
57
61
  def resolve_cache(raw: "str | None", environ: "dict[str, str] | None" = None) -> str:
@@ -92,11 +96,18 @@ def _upsert_model(cfg: dict, model: str, entry: dict, port: int) -> dict:
92
96
 
93
97
 
94
98
  def set_transformer_model(cfg: dict, model: str, cache: str, port: int) -> dict:
95
- """Upsert ``model`` as a transformer model, pin the cache it loads from."""
99
+ """Upsert ``model`` as a transformer model, pin the pre-downloaded cache.
100
+
101
+ The server is intended to run **offline** — the model is pre-downloaded
102
+ into ``cache`` and ``set`` requires it to already be there — so
103
+ ``HF_HUB_OFFLINE=1`` is written alongside the cache (the server resolves
104
+ the repo against this cache and never hits the network).
105
+ """
96
106
  env = dict(cfg.get("env") or {})
97
107
  env[ENV_CACHE_KEY] = cache
108
+ env[ENV_OFFLINE_KEY] = "1"
98
109
  out = _upsert_model(cfg, model, {"backend": "transformer", "model": model}, port)
99
- out["env"] = env # existing env keys preserved; HF_HUB_CACHE (re)written
110
+ out["env"] = env # existing env keys preserved; cache + offline (re)written
100
111
  return out
101
112
 
102
113
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "local-embed"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Standalone local embedding server — GGUF / transformer models as an OpenAI-compatible /v1/embeddings service"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -10,15 +10,18 @@ import pytest
10
10
  pytestmark = pytest.mark.unit
11
11
 
12
12
  from local_embed.cli import main
13
+
14
+ import local_embed.cli as cli
13
15
  from local_embed.cmd_set import (
14
16
  ENV_CACHE_KEY,
17
+ ENV_OFFLINE_KEY,
15
18
  backend_install_hint,
16
19
  model_in_cache,
17
20
  resolve_cache,
18
21
  set_gguf_model,
19
22
  set_transformer_model,
20
23
  )
21
- from local_embed.config import load_config
24
+ from local_embed.config import load_config, write_config
22
25
 
23
26
 
24
27
  class TestResolveCache:
@@ -41,9 +44,15 @@ class TestSetTransformerModel:
41
44
  "models": {"BAAI/bge-m3": {"backend": "transformer", "model": "BAAI/bge-m3"}},
42
45
  "active_model": "BAAI/bge-m3",
43
46
  "port": 8000,
44
- "env": {ENV_CACHE_KEY: "C:\\hub"},
47
+ "env": {ENV_CACHE_KEY: "C:\\hub", ENV_OFFLINE_KEY: "1"},
45
48
  }
46
49
 
50
+ def test_offline_flag_defaults_on(self):
51
+ # the server should never silently refetch a missing repo — pin
52
+ # HF_HUB_OFFLINE even when the user only supplied a cache dir
53
+ out = set_transformer_model({}, "BAAI/bge-m3", "C:\\hub", 8000)
54
+ assert out["env"][ENV_OFFLINE_KEY] == "1"
55
+
47
56
  def test_preserves_existing_models_and_env(self):
48
57
  cfg = {
49
58
  "models": {"old": {"backend": "gguf", "gguf_path": "/a.gguf"}},
@@ -111,18 +120,22 @@ def config_path(tmp_path, monkeypatch):
111
120
 
112
121
  class TestBackendInstallHint:
113
122
  def test_gguf_linux_macos_plain(self):
114
- assert backend_install_hint("gguf", "linux") == "uv tool install 'local-embed[gguf]'"
115
- assert backend_install_hint("gguf", "darwin") == "uv tool install 'local-embed[gguf]'"
123
+ assert backend_install_hint("gguf", "linux") == (
124
+ "uv tool install --python 3.13 'local-embed[gguf]'"
125
+ )
126
+ assert backend_install_hint("gguf", "darwin") == (
127
+ "uv tool install --python 3.13 'local-embed[gguf]'"
128
+ )
116
129
 
117
130
  def test_gguf_windows_adds_cpu_index(self):
118
131
  assert backend_install_hint("gguf", "win32") == (
119
- "uv tool install 'local-embed[gguf]' "
132
+ "uv tool install --python 3.13 'local-embed[gguf]' "
120
133
  "--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu"
121
134
  )
122
135
 
123
136
  def test_transformer_plain(self):
124
137
  assert backend_install_hint("transformer", "win32") == (
125
- "uv tool install 'local-embed[transformer]'"
138
+ "uv tool install --python 3.13 'local-embed[transformer]'"
126
139
  )
127
140
 
128
141
 
@@ -206,4 +219,70 @@ class TestRunSetGguf:
206
219
  assert main(["set-gguf", "bge-m3", "--path", str(gguf)]) == 0
207
220
  first = config_path.read_text(encoding="utf-8")
208
221
  assert main(["set-gguf", "bge-m3", "--path", str(gguf)]) == 0
209
- assert config_path.read_text(encoding="utf-8") == first
222
+ assert config_path.read_text(encoding="utf-8") == first
223
+
224
+
225
+ class TestCliCtrlC:
226
+ """Ctrl-C during the active backend check must exit 130, never traceback.
227
+
228
+ Regression: the startup validation calls ``resolve_backend_runtime``
229
+ (a REAL import — torch takes seconds), and a Ctrl-C landing mid-import
230
+ used to propagate as a raw KeyboardInterrupt traceback.
231
+ """
232
+
233
+ def _write_active_transformer(self, config_path):
234
+ write_config(
235
+ {
236
+ "active_model": "bge-m3-transformer",
237
+ "models": {
238
+ "bge-m3-transformer": {
239
+ "backend": "transformer",
240
+ "model": "BAAI/bge-m3",
241
+ }
242
+ },
243
+ },
244
+ config_path,
245
+ )
246
+
247
+ def test_interrupt_during_active_check_exits_130(self, monkeypatch, config_path, capsys):
248
+ self._write_active_transformer(config_path)
249
+
250
+ def interrupt(_backend):
251
+ raise KeyboardInterrupt()
252
+
253
+ monkeypatch.setattr(cli, "resolve_backend_runtime", interrupt)
254
+ code = cli.main([])
255
+ assert code == 130
256
+ assert "Interrupted" in capsys.readouterr().err
257
+
258
+ def test_non_active_backend_not_resolved(self, monkeypatch, config_path):
259
+ # active = gguf; the non-active transformer must NOT trigger the
260
+ # torch import at startup (it is only checked when actually loaded).
261
+ write_config(
262
+ {
263
+ "active_model": "bge-m3",
264
+ "models": {
265
+ "bge-m3": {"backend": "gguf", "gguf_path": "/x.gguf"},
266
+ "bge-m3-transformer": {
267
+ "backend": "transformer",
268
+ "model": "BAAI/bge-m3",
269
+ },
270
+ },
271
+ },
272
+ config_path,
273
+ )
274
+ resolved = []
275
+
276
+ def tracking(backend):
277
+ resolved.append(backend)
278
+ return True
279
+
280
+ monkeypatch.setattr(cli, "resolve_backend_runtime", tracking)
281
+ # stop after the validation loop instead of serving (serve_standalone
282
+ # is imported lazily inside main, so patch its source module)
283
+ monkeypatch.setattr(
284
+ "local_embed.server.serve_standalone", lambda *a, **k: 0
285
+ )
286
+ code = cli.main([])
287
+ assert code == 0
288
+ assert resolved == ["gguf"] # only the active backend was imported
File without changes