local-embed 0.2.0__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.0
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
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Terminals
17
17
  Requires-Python: >=3.13
18
18
  Requires-Dist: fastmcp>=2.0.0
19
+ Requires-Dist: json5>=0.15.0
19
20
  Requires-Dist: starlette>=0.37.0
20
21
  Provides-Extra: gguf
21
22
  Requires-Dist: llama-cpp-python; extra == 'gguf'
@@ -59,11 +60,22 @@ OpenAI-compatible client works.
59
60
 
60
61
  ## Install
61
62
 
62
- Requires [uv](https://docs.astral.sh/uv) (it manages the Python 3.13+
63
- 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:
64
69
 
65
70
  ```bash
66
- 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
67
79
  ```
68
80
 
69
81
  `gguf` (llama-cpp-python) is the platform-sensitive one: PyPI ships
@@ -74,10 +86,10 @@ for your platform:
74
86
 
75
87
  | Platform | Command |
76
88
  |---|---|
77
- | Linux / macOS, CPU | `uv tool install 'local-embed[gguf]'` (PyPI wheel) |
78
- | **Windows, CPU** | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu` |
79
- | macOS arm64 (Metal) | `uv tool install 'local-embed[gguf]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal` |
80
- | 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 |
81
93
 
82
94
  When the index is an *extra*, uv uses its wheel only when PyPI has no
83
95
  compatible wheel for the platform (the Windows / Metal cases); on Linux
@@ -87,6 +99,17 @@ CUDA index. Note the CUDA Linux wheels need **glibc ≥ 2.35** (built for
87
99
  If the `gguf` backend is missing, the server errors at startup with the
88
100
  exact command for your platform (the Windows CPU one on Windows).
89
101
 
102
+ **Want both backends in one environment?** Install the two extras together
103
+ in a single command — running `uv tool install` twice replaces the first
104
+ environment with the second, so they would not coexist:
105
+
106
+ ```bash
107
+ # both backends, Windows CPU:
108
+ uv tool install --python 3.13 'local-embed[gguf,transformer]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
109
+ # both backends, Linux / macOS CPU:
110
+ uv tool install --python 3.13 'local-embed[gguf,transformer]'
111
+ ```
112
+
90
113
  Core deps are `fastmcp` + `starlette`; the model backends are optional
91
114
  extras. Installing does **not** fetch a model — get the weights first.
92
115
 
@@ -182,14 +205,15 @@ source of truth.
182
205
 
183
206
  `local-embed set` (transformer) and `local-embed set-gguf` (gguf) upsert a
184
207
  model in the config, make it **active**, and pin port (and, for `set`, the
185
- HF cache). Both are **idempotent** — re-running yields the same config —
186
- 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.
187
210
 
188
211
  | | `set` (transformer) | `set-gguf` |
189
212
  |---|---|---|
190
213
  | `<model_name>` | required — HF repo id (key + repo loaded) | required — config key |
191
214
  | weight ref | `--HF_HUB_CACHE <dir>` — cache must contain the repo | `--path <PATH>` required — existing `.gguf` file |
192
215
  | cache fallback | env `HF_HUB_CACHE`, else error | — |
216
+ | env written | `HF_HUB_CACHE` + `HF_HUB_OFFLINE: "1"` (offline server) | — |
193
217
  | `--port <n>` | default `8000` | default `8000` |
194
218
 
195
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
@@ -62,6 +73,17 @@ CUDA index. Note the CUDA Linux wheels need **glibc ≥ 2.35** (built for
62
73
  If the `gguf` backend is missing, the server errors at startup with the
63
74
  exact command for your platform (the Windows CPU one on Windows).
64
75
 
76
+ **Want both backends in one environment?** Install the two extras together
77
+ in a single command — running `uv tool install` twice replaces the first
78
+ environment with the second, so they would not coexist:
79
+
80
+ ```bash
81
+ # both backends, Windows CPU:
82
+ uv tool install --python 3.13 'local-embed[gguf,transformer]' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
83
+ # both backends, Linux / macOS CPU:
84
+ uv tool install --python 3.13 'local-embed[gguf,transformer]'
85
+ ```
86
+
65
87
  Core deps are `fastmcp` + `starlette`; the model backends are optional
66
88
  extras. Installing does **not** fetch a model — get the weights first.
67
89
 
@@ -157,14 +179,15 @@ source of truth.
157
179
 
158
180
  `local-embed set` (transformer) and `local-embed set-gguf` (gguf) upsert a
159
181
  model in the config, make it **active**, and pin port (and, for `set`, the
160
- HF cache). Both are **idempotent** — re-running yields the same config —
161
- 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.
162
184
 
163
185
  | | `set` (transformer) | `set-gguf` |
164
186
  |---|---|---|
165
187
  | `<model_name>` | required — HF repo id (key + repo loaded) | required — config key |
166
188
  | weight ref | `--HF_HUB_CACHE <dir>` — cache must contain the repo | `--path <PATH>` required — existing `.gguf` file |
167
189
  | cache fallback | env `HF_HUB_CACHE`, else error | — |
190
+ | env written | `HF_HUB_CACHE` + `HF_HUB_OFFLINE: "1"` (offline server) | — |
168
191
  | `--port <n>` | default `8000` | default `8000` |
169
192
 
170
193
  ```bash
@@ -108,26 +108,46 @@ def main(argv: "list[str] | None" = None) -> int:
108
108
 
109
109
  # Everything (host/port/backend/model/gguf_path/device) comes from
110
110
  # local_embed.json5 — the CLI deliberately takes no model/endpoint flags.
111
- settings = resolve_engine_settings()
111
+ # A missing file is fine (defaults); a file that exists but won't parse
112
+ # must fail cleanly, not traceback.
113
+ try:
114
+ settings = resolve_engine_settings()
115
+ except ValueError as e:
116
+ print(f"Error: {e}", file=sys.stderr)
117
+ print("Fix local_embed.json5, or run `local-embed set` to rewrite it.",
118
+ file=sys.stderr)
119
+ return 2
120
+ except OSError as e:
121
+ print(f"Error: cannot read config: {e}", file=sys.stderr)
122
+ return 2
123
+
112
124
  engine = Engine(specs=settings["specs"], active=settings["active"])
113
125
 
114
- # Validate the models can actually run. `resolve_backend_runtime` (not
115
- # `check_backend_runtime`, which never imports) so the answer reflects
116
- # reality. Only the ACTIVE model blocks startup — the server can serve
117
- # it without the others; a non-active model with a missing backend or
118
- # 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.
119
133
  active_name = engine.active_model
120
134
  for spec in settings["specs"]:
121
135
  problems: list[str] = []
122
136
  if spec.backend == "gguf" and not spec.gguf_path:
123
137
  problems.append("no gguf_path (set gguf_path in local_embed.json5)")
124
- if not resolve_backend_runtime(spec.backend):
125
- from local_embed.cmd_set import backend_install_hint
126
-
127
- problems.append(
128
- f"{spec.backend} backend not installed "
129
- f"({backend_install_hint(spec.backend)})"
130
- )
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
+ )
131
151
  if spec.name == active_name and problems:
132
152
  print(
133
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.0"
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" }
@@ -11,6 +11,7 @@ requires-python = ">=3.13"
11
11
  dependencies = [
12
12
  "fastmcp>=2.0.0",
13
13
  "starlette>=0.37.0",
14
+ "json5>=0.15.0",
14
15
  ]
15
16
  classifiers = [
16
17
  "Development Status :: 3 - Alpha",
@@ -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