Coverage for src / lexigram / admin / cli / commands / search.py: 0%
120 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1from __future__ import annotations
3import asyncio
4import importlib
5from pathlib import Path
6from typing import Annotated, Any
8import typer
11def create_app():
12 """Create the search CLI command group (Typer app).
14 Called by ``lexigram-cli``'s command loader.
15 """
17 app = typer.Typer(name="search", help="Search index management")
19 @app.command()
20 def reindex(
21 config_path: Annotated[
22 Path,
23 typer.Argument(
24 help="Path to application.yaml (default: ./application.yaml)",
25 ),
26 ] = Path("application.yaml"),
27 app_module: Annotated[
28 str | None,
29 typer.Option(
30 "--app-module",
31 "-m",
32 help="Module path to root module, e.g. 'mypackage.root:RootModule'",
33 ),
34 ] = None,
35 ) -> None:
36 """Reindex all searchable resources.
38 Boots the application from *config_path*, discovers all admin resources
39 with a ``searchable`` spec, queries every record from each resource's
40 data source, and indexes them through the configured search engine.
41 """
42 asyncio.run(_run_reindex(config_path=config_path, app_module=app_module))
44 return app
47async def _run_reindex(config_path: Path, app_module: str | None) -> None:
48 from typer import Exit as TyperExit
49 from typer import echo
51 from lexigram.app import Application
52 from lexigram.config.loader import ConfigLoader # type: ignore[import-untyped]
53 from lexigram.config.main import LexigramConfig
55 if app_module is None:
56 echo("Error: --app-module is required", err=True)
57 raise TyperExit(1)
59 config = ConfigLoader().load_sync(LexigramConfig, config_path)
61 module_path, _, class_name = app_module.rpartition(":")
62 try:
63 mod = importlib.import_module(module_path)
64 except ImportError as exc:
65 echo(f"Error: cannot import module '{module_path}': {exc}", err=True)
66 raise TyperExit(1) from None
67 root_cls = getattr(mod, class_name, None)
68 if root_cls is None:
69 echo(f"Error: class '{class_name}' not found in '{module_path}'", err=True)
70 raise TyperExit(1)
72 echo("Booting application...")
73 dm = root_cls.configure(config=config)
75 try:
76 async with Application.boot(modules=[dm], config=config) as app:
77 echo("Collecting searchable resources...")
78 searchable = await _collect_searchable(app.container)
79 if not searchable:
80 echo("No searchable resources found.")
81 return
83 _log_banner(searchable)
85 from lexigram.search.engine import SearchEngine
87 search_engine = await app.container.resolve(
88 SearchEngine,
89 bypass_visibility=True,
90 )
92 total = 0
93 for resource_cls, searchable_spec in searchable:
94 dsc = getattr(resource_cls, "_data_source_class", None)
95 if dsc is None:
96 _log_warn(resource_cls, "no _data_source_class")
97 continue
98 try:
99 ds = await app.container.resolve(dsc, bypass_visibility=True)
100 except Exception as exc:
101 _log_warn(resource_cls, f"resolve failed: {exc}")
102 continue
104 repo = getattr(ds, "_repo", None)
105 if repo is None:
106 _log_warn(resource_cls, "no _repo on data source")
107 continue
109 echo(f" Fetching {searchable_spec.index_name}...")
110 try:
111 records = await repo.find(limit=None)
112 except Exception as exc:
113 _log_warn(resource_cls, f"find failed: {exc}")
114 continue
116 if not records:
117 echo(" -> 0 records")
118 continue
120 docs = _build_documents(records, searchable_spec)
121 try:
122 await search_engine.index(searchable_spec.index_name, docs)
123 except Exception as exc:
124 _log_warn(resource_cls, f"index failed: {exc}")
125 continue
127 echo(f" -> indexed {len(docs)} document(s)")
128 total += len(docs)
130 echo(f"\nReindex complete. {total} document(s) indexed.")
131 except Exception as exc:
132 echo(f"Error during reindex: {exc}", err=True)
133 raise TyperExit(1) from None
136async def _collect_searchable(
137 container: object,
138) -> list[tuple[type, Any]]:
139 """Return (resource_class, SearchableSpec) pairs from the admin registry."""
140 from lexigram.admin.contributors.registry import ContributorRegistry
141 from lexigram.admin.contributors.resource_collector import ResourceCollector
142 from lexigram.admin.dashboard.naming_policy import NamingPolicy
144 try:
145 registry = await container.resolve( # type: ignore[attr-defined]
146 ContributorRegistry,
147 bypass_visibility=True,
148 )
149 except Exception:
150 return []
152 naming = NamingPolicy(mode="warn")
153 collector = ResourceCollector(naming_policy=naming)
154 all_contributors = list(registry.get_all())
155 resource_classes = collector.collect(all_contributors)
157 result: list[tuple[type, object]] = []
158 for rc in resource_classes:
159 spec = getattr(rc, "searchable", None)
160 if spec is not None and getattr(spec, "index_name", None):
161 result.append((rc, spec))
162 return result
165def _build_documents(
166 records: list[object],
167 searchable: object,
168) -> list[dict]:
169 """Build search document dicts from *records*.
171 The spec is expected to have a ``fields`` iterable. Each record is
172 converted using ``getattr`` (object) or ``get`` (dict) style access.
173 """
174 fields: tuple[str, ...] = getattr(searchable, "fields", ())
175 docs: list[dict] = []
176 for record in records:
177 if isinstance(record, dict):
178 doc_id = record.get("id")
179 doc = {f: record.get(f) for f in fields}
180 else:
181 doc_id = getattr(record, "id", None)
182 doc = {f: getattr(record, f, None) for f in fields}
183 if doc_id is None:
184 continue
185 doc["id"] = str(doc_id)
186 docs.append(doc)
187 return docs
190def _log_banner(searchable: list[tuple[type, object]]) -> None:
191 from typer import echo
193 echo(f" Found {len(searchable)} searchable resource(s):")
194 for rc, spec in searchable:
195 echo(f" - {rc.__name__} -> {getattr(spec, 'index_name', '?')}")
198def _log_warn(resource_cls: type, reason: str) -> None:
199 from typer import echo
201 echo(f" Skipping {resource_cls.__name__}: {reason}")