zimantic 0.1.5__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.
zimantic/__init__.py ADDED
@@ -0,0 +1 @@
1
+
zimantic/__main__.py ADDED
@@ -0,0 +1,408 @@
1
+ import argparse
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+ import logging
4
+ import os
5
+ import signal
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _graceful_interrupt(_signum: int, _frame) -> None:
14
+ """Turn SIGTERM (a systemd stop) into KeyboardInterrupt.
15
+
16
+ Ctrl-C already interrupts the build; systemd stops a unit with SIGTERM, which
17
+ would otherwise kill the process without running its ``finally`` blocks. With
18
+ this handler, a stopped build closes its database and removes temp files
19
+ exactly like an interrupted batch, and stays resumable from the last
20
+ committed batch.
21
+ """
22
+ raise KeyboardInterrupt
23
+
24
+
25
+ # The systemd.path unit can fire the moment a file is created. Poll until its
26
+ # size stops changing so a truncated download is not indexed.
27
+ STABLE_POLL_SECONDS = 5
28
+
29
+
30
+ def _add_verbosity_argument(parser: argparse.ArgumentParser, default=argparse.SUPPRESS) -> None:
31
+ parser.add_argument(
32
+ "--verbose",
33
+ "-v",
34
+ action="count",
35
+ default=default,
36
+ help="increase logging verbosity (-v for info, -vv for debug)",
37
+ )
38
+
39
+
40
+ def _get_log_level(command: str, verbosity: int) -> int:
41
+ if command == "serve" and verbosity == 0 and sys.stderr.isatty():
42
+ return logging.INFO
43
+ return (logging.WARNING, logging.INFO, logging.DEBUG)[min(verbosity, 2)]
44
+
45
+
46
+ def _configure_logging(log_level: int) -> None:
47
+ logging.basicConfig(level=log_level, format="%(message)s")
48
+ logging.getLogger("faiss").setLevel(logging.WARNING)
49
+
50
+
51
+ def _wait_for_stable_sizes(paths: set[Path]) -> None:
52
+ """Wait for a batch of ZIMs to finish being written before indexing them."""
53
+ sizes: dict[Path, int] = {}
54
+ for path in paths:
55
+ try:
56
+ size = path.stat().st_size
57
+ except OSError:
58
+ continue
59
+ sizes[path] = size
60
+
61
+ while sizes:
62
+ time.sleep(STABLE_POLL_SECONDS)
63
+ downloading: dict[Path, int] = {}
64
+ for path, size in sizes.items():
65
+ try:
66
+ current = path.stat().st_size
67
+ except OSError:
68
+ continue
69
+ if current != size:
70
+ downloading[path] = current
71
+ logger.info(
72
+ "zimantic: %s is now %s bytes; waiting for it to finish downloading",
73
+ path.name,
74
+ current,
75
+ )
76
+ sizes = downloading
77
+
78
+
79
+ def _wait_for_stable_size(path: Path) -> None:
80
+ """Wait for one ZIM to finish being written before indexing it."""
81
+ _wait_for_stable_sizes({path})
82
+
83
+
84
+ def _find_downloading_sizes(zim_sizes: dict[Path, int]) -> set[Path]:
85
+ """Find ZIMs whose sizes changed during the initial polling interval."""
86
+ time.sleep(STABLE_POLL_SECONDS)
87
+
88
+ downloading: set[Path] = set()
89
+ for path, size in zim_sizes.items():
90
+ try:
91
+ current = path.stat().st_size
92
+ except OSError:
93
+ continue
94
+ if current != size:
95
+ downloading.add(path)
96
+ return downloading
97
+
98
+
99
+ def _make_parser() -> argparse.ArgumentParser:
100
+ parser = argparse.ArgumentParser(prog="zimantic")
101
+ _add_verbosity_argument(parser, default=0)
102
+
103
+ # subcommands: build (files/folders, default zim_dir), serve, reload
104
+ commands = parser.add_subparsers(dest="command", required=True)
105
+
106
+ serve_cmd = commands.add_parser("serve", help="run the search page and JSON API")
107
+ _add_verbosity_argument(serve_cmd)
108
+ serve_cmd.add_argument(
109
+ "--fast",
110
+ action="store_true",
111
+ help=(
112
+ "start immediately without the model or FAISS vectors "
113
+ "(title and ZIM full-text results only)"
114
+ ),
115
+ )
116
+ serve_cmd.add_argument(
117
+ "--debug",
118
+ "--dev",
119
+ dest="debug",
120
+ action="store_true",
121
+ help="include ranking scores and explanations; --dev is an alias and SIGHUP restarts",
122
+ )
123
+
124
+ build = commands.add_parser("build", help="index ZIM files")
125
+ _add_verbosity_argument(build)
126
+ build.add_argument(
127
+ "paths",
128
+ nargs="*",
129
+ type=Path,
130
+ metavar="PATH",
131
+ help=(
132
+ "ZIM files or folders containing *.zim; a folder means every .zim "
133
+ "under it (recursively). Default: zim_dir"
134
+ ),
135
+ )
136
+ build.add_argument(
137
+ "--fast",
138
+ action="store_true",
139
+ help="title + ZIM full-text only: skip article bodies, the model and FAISS (much faster)",
140
+ )
141
+ build.add_argument(
142
+ "--force",
143
+ action="store_true",
144
+ help="rebuild the selected ZIM indexes even if they are already complete",
145
+ )
146
+ build.add_argument(
147
+ "-j",
148
+ "--workers",
149
+ type=int,
150
+ default=None,
151
+ metavar="N",
152
+ help="build up to N independent ZIMs concurrently (default: config build_workers)",
153
+ )
154
+
155
+ reload_cmd = commands.add_parser(
156
+ "reload",
157
+ help=(
158
+ "ask a running serve process to rescan indexes and ZIMs (sends SIGHUP; "
159
+ "suitable for systemd.path or cron)"
160
+ ),
161
+ )
162
+ _add_verbosity_argument(reload_cmd)
163
+ reload_cmd.add_argument(
164
+ "--pid",
165
+ type=Path,
166
+ default=Path("zimantic.pid"),
167
+ help="file holding the server's PID (default: ./zimantic.pid)",
168
+ )
169
+ return parser
170
+
171
+
172
+ def _collect_zims(paths: list[Path], default_dir: str | Path) -> list[Path]:
173
+ """Expand CLI files and folders into unique ZIM paths."""
174
+ zims: list[Path] = []
175
+ seen: set[Path] = set()
176
+ for source in (paths or [Path(default_dir)]):
177
+ if source.is_dir():
178
+ matches = sorted(source.rglob("*.zim"))
179
+ elif source.is_file():
180
+ matches = [source]
181
+ else:
182
+ sys.exit(f"zimantic: path not found: {source}")
183
+ for zim in matches:
184
+ resolved = zim.resolve()
185
+ if resolved not in seen:
186
+ seen.add(resolved)
187
+ zims.append(zim)
188
+ return zims
189
+
190
+
191
+ def _run_build(args, cfg: dict, parser: argparse.ArgumentParser) -> None:
192
+ from .build import build as build_zim
193
+ from .content.reader import (
194
+ DEFAULT_MAX_ENTRY_BYTES,
195
+ DEFAULT_MAX_HTML_BYTES,
196
+ DEFAULT_PREVIEW_CHARS,
197
+ )
198
+ from tqdm import tqdm
199
+
200
+ sigterm = getattr(signal, "SIGTERM", None)
201
+ if sigterm is not None:
202
+ signal.signal(sigterm, _graceful_interrupt)
203
+
204
+ zims = _collect_zims(args.paths, cfg["zim_dir"])
205
+ if not zims:
206
+ logger.info("zimantic: no .zim files to index")
207
+ return
208
+
209
+ workers = args.workers
210
+ if workers is None:
211
+ workers = cfg.get("build_workers", 1)
212
+ if workers < 1:
213
+ parser.error("--workers must be at least 1")
214
+
215
+ zim_sizes = {zim: zim.stat().st_size for zim in zims}
216
+ downloading = _find_downloading_sizes(zim_sizes)
217
+
218
+ by_stem: dict[str, list[Path]] = {}
219
+ for zim in zims:
220
+ by_stem.setdefault(zim.stem, []).append(zim)
221
+ accepted: list[Path] = []
222
+ for stem, paths in sorted(by_stem.items()):
223
+ sizes = {zim_sizes[path] for path in paths}
224
+ if len(sizes) > 1:
225
+ logger.warning(
226
+ "zimantic: ignoring ZIM files with conflicting index identity %r: %s",
227
+ stem,
228
+ ", ".join(f"{path} ({zim_sizes[path]} bytes)" for path in paths),
229
+ )
230
+ continue
231
+ if len(paths) > 1:
232
+ logger.warning(
233
+ "zimantic: ignoring duplicate ZIM files with index identity %r: %s",
234
+ stem,
235
+ ", ".join(str(path) for path in paths[1:]),
236
+ )
237
+ accepted.append(paths[0])
238
+ zims = sorted(accepted, key=zim_sizes.__getitem__)
239
+ if not zims:
240
+ logger.info("zimantic: no non-conflicting .zim files to index")
241
+ return
242
+
243
+ if downloading:
244
+ _wait_for_stable_sizes(downloading)
245
+
246
+ embedder = None
247
+ embedding_pipeline = None
248
+ if not args.fast:
249
+ from .build import EmbeddingPipeline
250
+ from .embed import Embedder
251
+
252
+ embedder = Embedder(
253
+ cfg["model_dir"],
254
+ threads=cfg.get("embed_threads"),
255
+ )
256
+ if workers > 1:
257
+ embedding_workers = int(cfg.get("embedding_workers", 2))
258
+ if embedding_workers < 1:
259
+ parser.error("embedding_workers must be at least 1")
260
+ embedding_pipeline = EmbeddingPipeline(
261
+ embedder,
262
+ workers=min(embedding_workers, workers),
263
+ queue_size=max(2, workers * 2),
264
+ )
265
+ global_progress = None
266
+ if len(zims) > 1 and sys.stderr.isatty():
267
+ global_progress = tqdm(
268
+ total=sum(zim_sizes[zim] for zim in zims),
269
+ desc="all ZIMs",
270
+ unit="B",
271
+ unit_scale=True,
272
+ file=sys.stderr,
273
+ )
274
+ failures: list[tuple[Path, BaseException]] = []
275
+
276
+ def run_build(zim: Path) -> None:
277
+ build_zim(
278
+ zim,
279
+ cfg["index_dir"],
280
+ embedder,
281
+ cfg["batch_size"],
282
+ fast=args.fast,
283
+ max_html_bytes=cfg.get("max_html_bytes", DEFAULT_MAX_HTML_BYTES),
284
+ max_entry_bytes=cfg.get("max_entry_bytes", DEFAULT_MAX_ENTRY_BYTES),
285
+ max_preview_chars=cfg.get("max_preview_chars", DEFAULT_PREVIEW_CHARS),
286
+ force=args.force,
287
+ embedding_pipeline=embedding_pipeline,
288
+ )
289
+
290
+ try:
291
+ if workers == 1 or len(zims) == 1:
292
+ for zim in zims:
293
+ try:
294
+ run_build(zim)
295
+ except Exception as error:
296
+ logger.error("zimantic: failed to index %s: %s", zim, error)
297
+ failures.append((zim, error))
298
+ if global_progress is not None:
299
+ global_progress.update(zim_sizes[zim])
300
+ else:
301
+ pool = ThreadPoolExecutor(
302
+ max_workers=min(workers, len(zims)),
303
+ thread_name_prefix="zimantic-build",
304
+ )
305
+ try:
306
+ futures = {pool.submit(run_build, zim): zim for zim in zims}
307
+ for future in as_completed(futures):
308
+ zim = futures[future]
309
+ try:
310
+ future.result()
311
+ except Exception as error:
312
+ logger.error("zimantic: failed to index %s: %s", zim, error)
313
+ failures.append((zim, error))
314
+ if global_progress is not None:
315
+ global_progress.update(zim_sizes[zim])
316
+ finally:
317
+ # On Ctrl-C/SIGTERM drop builds that have not started yet
318
+ # instead of running every queued ZIM to completion.
319
+ pool.shutdown(wait=True, cancel_futures=True)
320
+ finally:
321
+ if embedding_pipeline is not None:
322
+ embedding_pipeline.close()
323
+ if global_progress is not None:
324
+ global_progress.close()
325
+ if failures:
326
+ sys.exit(1)
327
+
328
+
329
+ def _run_serve(args, cfg: dict) -> None:
330
+ from .search import Search
331
+ from .web.app import serve
332
+
333
+ search = Search(cfg, semantic=not args.fast)
334
+ if not search.local_names():
335
+ logger.warning(
336
+ "zimantic: no local ZIM sources are available; "
337
+ "add a .zim to zim_dir or run `zimantic build --fast`"
338
+ )
339
+ if not args.fast:
340
+ # Load the model off the critical path: the server answers title and
341
+ # full-text searches immediately and gains meaning search once the
342
+ # ONNX model and vocabulary finish loading in the background.
343
+ def _make_embedder():
344
+ from .embed import Embedder
345
+
346
+ return Embedder(
347
+ cfg["model_dir"],
348
+ threads=cfg.get("embed_threads"),
349
+ )
350
+
351
+ search.start_embedder(_make_embedder)
352
+
353
+ if args.fast:
354
+ logger.info("zimantic: fast mode: title and ZIM full-text search only")
355
+ serve(search, cfg["port"], debug=args.debug)
356
+
357
+
358
+ def _run_reload(pid_file: Path) -> None:
359
+ try:
360
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
361
+ except (OSError, ValueError):
362
+ sys.exit(
363
+ f"zimantic: no server PID in {pid_file}; "
364
+ f"Is `zimantic serve` running from this folder (or pass --pid)?"
365
+ )
366
+ sighup = getattr(signal, "SIGHUP", None)
367
+ if sighup is None:
368
+ sys.exit("zimantic: reload via signals is not supported on this platform")
369
+ try:
370
+ os.kill(pid, sighup)
371
+ except ProcessLookupError:
372
+ sys.exit(
373
+ f"zimantic: no running process with PID {pid}; "
374
+ f"is the PID file {pid_file} stale?"
375
+ )
376
+ except PermissionError:
377
+ sys.exit(
378
+ f"zimantic: permission denied signalling PID {pid}; "
379
+ "run the reload command as the same user as the server"
380
+ )
381
+ logger.info("zimantic: reload signal (SIGHUP) sent to PID %s", pid)
382
+
383
+
384
+ def main() -> None:
385
+ parser = _make_parser()
386
+
387
+ args = parser.parse_args()
388
+ log_level = _get_log_level(args.command, args.verbose)
389
+ _configure_logging(log_level)
390
+
391
+ # `reload` only needs the PID file.
392
+ if args.command in {"build", "serve"}:
393
+ from .settings import load_config
394
+
395
+ cfg = load_config()
396
+ else:
397
+ cfg = {}
398
+
399
+ if args.command == "build":
400
+ _run_build(args, cfg, parser)
401
+ elif args.command == "serve":
402
+ _run_serve(args, cfg)
403
+ elif args.command == "reload":
404
+ _run_reload(args.pid)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()