solrctl 0.5.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.
- solrctl/__init__.py +0 -0
- solrctl/cli.py +231 -0
- solrctl/client.py +473 -0
- solrctl/flows/__init__.py +0 -0
- solrctl/flows/swap_flow.py +136 -0
- solrctl/tasks/__init__.py +0 -0
- solrctl/tasks/alias_swap.py +82 -0
- solrctl/tasks/cleanup.py +133 -0
- solrctl/tasks/core_swap.py +76 -0
- solrctl/utils/__init__.py +1 -0
- solrctl/utils/setup_test_cores.py +197 -0
- solrctl-0.5.0.dist-info/METADATA +164 -0
- solrctl-0.5.0.dist-info/RECORD +17 -0
- solrctl-0.5.0.dist-info/WHEEL +5 -0
- solrctl-0.5.0.dist-info/entry_points.txt +2 -0
- solrctl-0.5.0.dist-info/licenses/LICENSE +21 -0
- solrctl-0.5.0.dist-info/top_level.txt +1 -0
solrctl/__init__.py
ADDED
|
File without changes
|
solrctl/cli.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""solrctl CLI – Verwaltung von Solr-Cores und Aliases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from solrctl.client import SolrClient, SolrClientError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SolrContext:
|
|
12
|
+
"""Hält den SolrClient für die Laufzeit des CLI-Aufrufs."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, solr_url: str, auth: tuple[str, str] | None) -> None:
|
|
15
|
+
self.client = SolrClient(solr_url, auth=auth)
|
|
16
|
+
|
|
17
|
+
def close(self) -> None:
|
|
18
|
+
self.client.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
pass_solr_ctx = click.make_pass_decorator(SolrContext)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _handle_error(exc: SolrClientError) -> None:
|
|
25
|
+
click.secho(f"Fehler: {exc}", fg="red", err=True)
|
|
26
|
+
raise SystemExit(1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group()
|
|
30
|
+
@click.option(
|
|
31
|
+
"--solr-url",
|
|
32
|
+
envvar="SOLR_URL",
|
|
33
|
+
default="http://localhost:8983",
|
|
34
|
+
show_default=True,
|
|
35
|
+
help="Basis-URL der Solr-Instanz.",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--user", envvar="SOLR_USER", default=None, help="Basic-Auth Benutzername."
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--password", envvar="SOLR_PASSWORD", default=None, help="Basic-Auth Passwort."
|
|
42
|
+
)
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def cli(
|
|
45
|
+
ctx: click.Context, solr_url: str, user: str | None, password: str | None
|
|
46
|
+
) -> None:
|
|
47
|
+
"""solrctl – Verwaltung von Solr-Cores und Aliases."""
|
|
48
|
+
auth = (user, password) if user and password else None
|
|
49
|
+
ctx.obj = SolrContext(solr_url, auth)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command()
|
|
53
|
+
@pass_solr_ctx
|
|
54
|
+
def ping(ctx: SolrContext) -> None:
|
|
55
|
+
"""Prüft, ob Solr erreichbar ist."""
|
|
56
|
+
if ctx.client.ping():
|
|
57
|
+
click.echo("Solr erreichbar.")
|
|
58
|
+
else:
|
|
59
|
+
click.secho("Solr NICHT erreichbar.", fg="red", err=True)
|
|
60
|
+
raise SystemExit(1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── core ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cli.group()
|
|
67
|
+
def core() -> None:
|
|
68
|
+
"""Standalone Core-Operationen."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@core.command("list")
|
|
72
|
+
@pass_solr_ctx
|
|
73
|
+
def core_list(ctx: SolrContext) -> None:
|
|
74
|
+
"""Listet alle vorhandenen Cores auf."""
|
|
75
|
+
try:
|
|
76
|
+
cores = ctx.client.list_cores()
|
|
77
|
+
except SolrClientError as exc:
|
|
78
|
+
_handle_error(exc)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if not cores:
|
|
82
|
+
click.echo("Keine Cores gefunden.")
|
|
83
|
+
return
|
|
84
|
+
for name in cores:
|
|
85
|
+
click.echo(name)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@core.command()
|
|
89
|
+
@click.option("--source", required=True, help="Name des neuen Cores (frische Daten).")
|
|
90
|
+
@click.option(
|
|
91
|
+
"--target", required=True, help="Name des aktiven Cores (wird getauscht)."
|
|
92
|
+
)
|
|
93
|
+
@pass_solr_ctx
|
|
94
|
+
def swap(ctx: SolrContext, source: str, target: str) -> None:
|
|
95
|
+
"""Tauscht zwei Solr-Cores (CoreAdmin SWAP-Action)."""
|
|
96
|
+
logger.info("Swap: '{}' <-> '{}'", target, source)
|
|
97
|
+
try:
|
|
98
|
+
ctx.client.swap_cores(target, source)
|
|
99
|
+
click.secho(f"Swap erfolgreich: '{target}' <-> '{source}'.", fg="green")
|
|
100
|
+
except SolrClientError as exc:
|
|
101
|
+
_handle_error(exc)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@core.command()
|
|
105
|
+
@click.argument("name")
|
|
106
|
+
@click.option("--delete-index", is_flag=True, help="Index-Daten auf Disk löschen.")
|
|
107
|
+
@pass_solr_ctx
|
|
108
|
+
def delete(ctx: SolrContext, name: str, delete_index: bool) -> None:
|
|
109
|
+
"""Löscht (unload) einen Solr-Core."""
|
|
110
|
+
try:
|
|
111
|
+
ctx.client.unload_core(name, delete_index=delete_index)
|
|
112
|
+
click.secho(f"Core '{name}' gelöscht.", fg="green")
|
|
113
|
+
except SolrClientError as exc:
|
|
114
|
+
_handle_error(exc)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@core.command("info")
|
|
118
|
+
@click.argument("name")
|
|
119
|
+
@pass_solr_ctx
|
|
120
|
+
def core_info(ctx: SolrContext, name: str) -> None:
|
|
121
|
+
"""Zeigt detaillierte Informationen zu einem Core an."""
|
|
122
|
+
try:
|
|
123
|
+
info = ctx.client.get_core_info(name)
|
|
124
|
+
except SolrClientError as exc:
|
|
125
|
+
_handle_error(exc)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
click.echo(f"Core: {info.get('name', name)}")
|
|
129
|
+
click.echo(f"InstanceDir: {info.get('instanceDir')}")
|
|
130
|
+
click.echo(f"DataDir: {info.get('dataDir')}")
|
|
131
|
+
click.echo(f"Config: {info.get('config')}")
|
|
132
|
+
click.echo(f"Schema: {info.get('schema')}")
|
|
133
|
+
click.echo(f"StartTime: {info.get('startTime')}")
|
|
134
|
+
uptime = info.get("uptime", 0)
|
|
135
|
+
click.echo(f"Uptime: {uptime // 1000}s")
|
|
136
|
+
|
|
137
|
+
index = info.get("index", {})
|
|
138
|
+
if index:
|
|
139
|
+
click.echo()
|
|
140
|
+
click.secho("Index:", bold=True)
|
|
141
|
+
click.echo(f" numDocs: {index.get('numDocs')}")
|
|
142
|
+
click.echo(f" maxDoc: {index.get('maxDoc')}")
|
|
143
|
+
click.echo(f" deletedDocs: {index.get('deletedDocs')}")
|
|
144
|
+
click.echo(f" segments: {index.get('segmentCount')}")
|
|
145
|
+
click.echo(f" size: {index.get('size')}")
|
|
146
|
+
click.echo(f" lastModified: {index.get('lastModified')}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@core.command("config")
|
|
150
|
+
@click.argument("name")
|
|
151
|
+
@pass_solr_ctx
|
|
152
|
+
def core_config(ctx: SolrContext, name: str) -> None:
|
|
153
|
+
"""Gibt die solrconfig.xml eines Cores als XML aus."""
|
|
154
|
+
try:
|
|
155
|
+
config_xml = ctx.client.get_core_config(name)
|
|
156
|
+
except SolrClientError as exc:
|
|
157
|
+
_handle_error(exc)
|
|
158
|
+
return
|
|
159
|
+
click.echo(config_xml)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@core.command("schema")
|
|
163
|
+
@click.argument("name")
|
|
164
|
+
@pass_solr_ctx
|
|
165
|
+
def core_schema(ctx: SolrContext, name: str) -> None:
|
|
166
|
+
"""Gibt das Schema eines Cores als XML aus."""
|
|
167
|
+
try:
|
|
168
|
+
schema_xml = ctx.client.get_core_schema(name)
|
|
169
|
+
except SolrClientError as exc:
|
|
170
|
+
_handle_error(exc)
|
|
171
|
+
return
|
|
172
|
+
click.echo(schema_xml)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── alias ─────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@cli.group()
|
|
179
|
+
def alias() -> None:
|
|
180
|
+
"""SolrCloud Alias-Operationen."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@alias.command("list")
|
|
184
|
+
@pass_solr_ctx
|
|
185
|
+
def alias_list(ctx: SolrContext) -> None:
|
|
186
|
+
"""Listet alle Aliases auf."""
|
|
187
|
+
try:
|
|
188
|
+
aliases = ctx.client.list_aliases()
|
|
189
|
+
except SolrClientError as exc:
|
|
190
|
+
_handle_error(exc)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
if not aliases:
|
|
194
|
+
click.echo("Keine Aliases gefunden.")
|
|
195
|
+
return
|
|
196
|
+
for alias_name, collection in aliases.items():
|
|
197
|
+
click.echo(f"{alias_name} -> {collection}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@alias.command()
|
|
201
|
+
@click.option("--alias", required=True, help="Name des Alias.")
|
|
202
|
+
@click.option("--collection", required=True, help="Ziel-Collection.")
|
|
203
|
+
@pass_solr_ctx
|
|
204
|
+
def swap(ctx: SolrContext, alias: str, collection: str) -> None:
|
|
205
|
+
"""Setzt einen Alias auf eine neue Collection um."""
|
|
206
|
+
try:
|
|
207
|
+
ctx.client.create_or_update_alias(alias, collection)
|
|
208
|
+
click.secho(f"Alias '{alias}' -> '{collection}' gesetzt.", fg="green")
|
|
209
|
+
except SolrClientError as exc:
|
|
210
|
+
_handle_error(exc)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@alias.command()
|
|
214
|
+
@click.argument("name")
|
|
215
|
+
@pass_solr_ctx
|
|
216
|
+
def delete(ctx: SolrContext, name: str) -> None:
|
|
217
|
+
"""Löscht einen Alias."""
|
|
218
|
+
try:
|
|
219
|
+
ctx.client.delete_alias(name)
|
|
220
|
+
click.secho(f"Alias '{name}' gelöscht.", fg="green")
|
|
221
|
+
except SolrClientError as exc:
|
|
222
|
+
_handle_error(exc)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main() -> None:
|
|
226
|
+
"""Einstiegspunkt für die CLI."""
|
|
227
|
+
cli()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
main()
|
solrctl/client.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Solr HTTP-Client auf Basis von httpx.
|
|
2
|
+
|
|
3
|
+
Kapselt alle API-Aufrufe gegen Solr 9.x:
|
|
4
|
+
- Collections API (SolrCloud): Alias-Verwaltung
|
|
5
|
+
- CoreAdmin API (Standalone): Core-Verwaltung
|
|
6
|
+
- Health-Check
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SolrClientError(Exception):
|
|
20
|
+
"""Wird bei Fehlern der Solr-API geworfen."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SolrClient:
|
|
24
|
+
"""HTTP-Client für Solr 9.x.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
base_url: Basis-URL der Solr-Instanz (z. B. ``http://localhost:8983``).
|
|
28
|
+
timeout: HTTP-Timeout in Sekunden.
|
|
29
|
+
auth: Optionales ``(user, password)``-Tupel für Basic Auth.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
base_url: str,
|
|
35
|
+
timeout: float = 30.0,
|
|
36
|
+
auth: tuple[str, str] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._base_url = base_url.rstrip("/")
|
|
39
|
+
self._client = httpx.Client(
|
|
40
|
+
base_url=self._base_url,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
auth=auth,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Health
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def ping(self) -> bool:
|
|
50
|
+
"""Prüft, ob die Solr-Instanz erreichbar ist.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
``True`` wenn Solr antwortet, sonst ``False``.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
response = self._client.get(
|
|
57
|
+
"/solr/admin/info/system", params={"wt": "json"}
|
|
58
|
+
)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
logger.debug("Solr erreichbar: %s", self._base_url)
|
|
61
|
+
return True
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
logger.warning("Solr nicht erreichbar: %s", exc)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Collections API – Aliases (SolrCloud)
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def list_aliases(self) -> dict[str, str]:
|
|
71
|
+
"""Gibt alle bekannten Aliases zurück.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dict ``{alias_name: collection_name}``.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
SolrClientError: Bei API-Fehler.
|
|
78
|
+
"""
|
|
79
|
+
response = self._request(
|
|
80
|
+
"GET",
|
|
81
|
+
"/solr/admin/collections",
|
|
82
|
+
params={"action": "LISTALIASES", "wt": "json"},
|
|
83
|
+
)
|
|
84
|
+
# Antwort: {"aliases": {"alias1": "col1", ...}, ...}
|
|
85
|
+
return response.get("aliases", {})
|
|
86
|
+
|
|
87
|
+
def create_or_update_alias(self, alias: str, collection: str) -> None:
|
|
88
|
+
"""Erstellt einen neuen Alias oder setzt einen bestehenden um.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
alias: Name des Alias.
|
|
92
|
+
collection: Ziel-Collection, auf die der Alias zeigen soll.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
SolrClientError: Bei API-Fehler.
|
|
96
|
+
"""
|
|
97
|
+
logger.info("Setze Alias '%s' auf Collection '%s'", alias, collection)
|
|
98
|
+
self._request(
|
|
99
|
+
"GET",
|
|
100
|
+
"/solr/admin/collections",
|
|
101
|
+
params={
|
|
102
|
+
"action": "CREATEALIAS",
|
|
103
|
+
"name": alias,
|
|
104
|
+
"collections": collection,
|
|
105
|
+
"wt": "json",
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def delete_alias(self, alias: str) -> None:
|
|
110
|
+
"""Löscht einen Alias.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
alias: Name des zu löschenden Alias.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
SolrClientError: Bei API-Fehler.
|
|
117
|
+
"""
|
|
118
|
+
logger.info("Lösche Alias '%s'", alias)
|
|
119
|
+
self._request(
|
|
120
|
+
"GET",
|
|
121
|
+
"/solr/admin/collections",
|
|
122
|
+
params={"action": "DELETEALIAS", "name": alias, "wt": "json"},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def delete_collection(self, collection: str) -> None:
|
|
126
|
+
"""Löscht eine SolrCloud Collection.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
collection: Name der zu löschenden Collection.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
SolrClientError: Bei API-Fehler.
|
|
133
|
+
"""
|
|
134
|
+
logger.info("Lösche Collection '%s'", collection)
|
|
135
|
+
self._request(
|
|
136
|
+
"GET",
|
|
137
|
+
"/solr/admin/collections",
|
|
138
|
+
params={"action": "DELETE", "name": collection, "wt": "json"},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# CoreAdmin API – Cores (Standalone)
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def list_cores(self) -> list[str]:
|
|
146
|
+
"""Gibt die Namen aller vorhandenen Cores zurück。
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Liste der Core-Namen。
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
SolrClientError: Bei API-Fehler。
|
|
153
|
+
"""
|
|
154
|
+
response = self._request(
|
|
155
|
+
"GET",
|
|
156
|
+
"/solr/admin/cores",
|
|
157
|
+
params={"action": "STATUS", "wt": "json"},
|
|
158
|
+
)
|
|
159
|
+
return list(response.get("status", {}).keys())
|
|
160
|
+
|
|
161
|
+
def get_core_info(self, core: str) -> dict[str, Any]:
|
|
162
|
+
"""Liefert detaillierte Informationen zu einem Core。
|
|
163
|
+
|
|
164
|
+
Enthält Name, Pfade, Startzeit, Uptime und Index-Statistiken
|
|
165
|
+
(Dokumentanzahl, Segmentanzahl, Größe usw.)。
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
core: Name des Core。
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Dict mit den Core-Informationen aus der STATUS-Antwort。
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
SolrClientError: Bei API-Fehler oder wenn der Core nicht existiert。
|
|
175
|
+
"""
|
|
176
|
+
response = self._request(
|
|
177
|
+
"GET",
|
|
178
|
+
"/solr/admin/cores",
|
|
179
|
+
params={"action": "STATUS", "core": core, "wt": "json"},
|
|
180
|
+
)
|
|
181
|
+
status = response.get("status", {})
|
|
182
|
+
if core not in status:
|
|
183
|
+
raise SolrClientError(f"Core '{core}' nicht gefunden")
|
|
184
|
+
return status[core]
|
|
185
|
+
|
|
186
|
+
def swap_cores(self, core1: str, core2: str) -> None:
|
|
187
|
+
"""Tauscht zwei Cores via CoreAdmin SWAP-Action.
|
|
188
|
+
|
|
189
|
+
Nach dem Swap übernimmt ``core1`` alle Daten von ``core2`` und umgekehrt.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
core1: Name des ersten Core (wird zum neuen aktiven Core).
|
|
193
|
+
core2: Name des zweiten Core.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
SolrClientError: Bei API-Fehler.
|
|
197
|
+
"""
|
|
198
|
+
logger.info("Swap Cores: '%s' <-> '%s'", core1, core2)
|
|
199
|
+
self._request(
|
|
200
|
+
"GET",
|
|
201
|
+
"/solr/admin/cores",
|
|
202
|
+
params={"action": "SWAP", "core": core1, "other": core2, "wt": "json"},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def unload_core(self, core: str, *, delete_index: bool = False) -> None:
|
|
206
|
+
"""Entlädt (und löscht optional) einen Core.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
core: Name des Core.
|
|
210
|
+
delete_index: Wenn ``True``, werden Index-Daten auf Disk gelöscht.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
SolrClientError: Bei API-Fehler.
|
|
214
|
+
"""
|
|
215
|
+
logger.info("Unload Core '%s' (delete_index=%s)", core, delete_index)
|
|
216
|
+
self._request(
|
|
217
|
+
"GET",
|
|
218
|
+
"/solr/admin/cores",
|
|
219
|
+
params={
|
|
220
|
+
"action": "UNLOAD",
|
|
221
|
+
"core": core,
|
|
222
|
+
"deleteIndex": str(delete_index).lower(),
|
|
223
|
+
"deleteDataDir": str(delete_index).lower(),
|
|
224
|
+
"deleteInstanceDir": str(delete_index).lower(),
|
|
225
|
+
"wt": "json",
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def create_collection(
|
|
230
|
+
self,
|
|
231
|
+
collection: str,
|
|
232
|
+
num_shards: int = 1,
|
|
233
|
+
replication_factor: int = 1,
|
|
234
|
+
config_set: str = "_default",
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Erstellt eine neue SolrCloud Collection.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
collection: Name der Collection.
|
|
240
|
+
num_shards: Anzahl Shards (default: 1).
|
|
241
|
+
replication_factor: Replikationsfaktor (default: 1).
|
|
242
|
+
config_set: Zu verwendendes ConfigSet (default: "_default").
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
SolrClientError: Bei API-Fehler.
|
|
246
|
+
"""
|
|
247
|
+
logger.info(
|
|
248
|
+
"Erstelle Collection '%s' (shards=%d, replicas=%d)",
|
|
249
|
+
collection,
|
|
250
|
+
num_shards,
|
|
251
|
+
replication_factor,
|
|
252
|
+
)
|
|
253
|
+
self._request(
|
|
254
|
+
"GET",
|
|
255
|
+
"/solr/admin/collections",
|
|
256
|
+
params={
|
|
257
|
+
"action": "CREATE",
|
|
258
|
+
"name": collection,
|
|
259
|
+
"numShards": num_shards,
|
|
260
|
+
"replicationFactor": replication_factor,
|
|
261
|
+
"collection.configName": config_set,
|
|
262
|
+
"wt": "json",
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def create_core(self, core: str, config_set: str = "_default") -> None:
|
|
267
|
+
"""Erstellt einen neuen Standalone Core.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
core: Name des Core.
|
|
271
|
+
config_set: Zu verwendendes ConfigSet (default: "_default").
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
SolrClientError: Bei API-Fehler.
|
|
275
|
+
"""
|
|
276
|
+
logger.info("Erstelle Core '%s'", core)
|
|
277
|
+
self._request(
|
|
278
|
+
"GET",
|
|
279
|
+
"/solr/admin/cores",
|
|
280
|
+
params={
|
|
281
|
+
"action": "CREATE",
|
|
282
|
+
"name": core,
|
|
283
|
+
"configSet": config_set,
|
|
284
|
+
"wt": "json",
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def index_documents(
|
|
289
|
+
self,
|
|
290
|
+
core_or_collection: str,
|
|
291
|
+
documents: list[dict],
|
|
292
|
+
commit: bool = True,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Indexiert Dokumente in einen Core oder Collection.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
core_or_collection: Name des Ziel-Core/Collection.
|
|
298
|
+
documents: Liste von Dokumenten (Dicts).
|
|
299
|
+
commit: Wenn True, wird sofort committed.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
SolrClientError: Bei API-Fehler.
|
|
303
|
+
"""
|
|
304
|
+
if not documents:
|
|
305
|
+
logger.debug("Keine Dokumente zu indexieren")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
logger.info(
|
|
309
|
+
"Indexiere %d Dokumente in '%s'", len(documents), core_or_collection
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Solr Update API verwendet POST mit JSON-Body
|
|
313
|
+
url = f"/solr/{core_or_collection}/update"
|
|
314
|
+
params: dict[str, Any] = {"wt": "json"}
|
|
315
|
+
if commit:
|
|
316
|
+
params["commit"] = "true"
|
|
317
|
+
|
|
318
|
+
response = self._client.post(url, params=params, json=documents)
|
|
319
|
+
response.raise_for_status()
|
|
320
|
+
|
|
321
|
+
data: dict[str, Any] = response.json()
|
|
322
|
+
header = data.get("responseHeader", {})
|
|
323
|
+
if header.get("status", 0) != 0:
|
|
324
|
+
raise SolrClientError(f"Solr-Fehler beim Indexieren: {data}")
|
|
325
|
+
|
|
326
|
+
logger.debug("Indexierung erfolgreich: %s", data)
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# Konfigurationsdateien
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def get_core_config(self, core: str) -> str:
|
|
333
|
+
"""Liefert die solrconfig.xml eines Cores als XML-String.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
core: Name des Core.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Inhalt der solrconfig.xml als String.
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
SolrClientError: Bei API-Fehler.
|
|
343
|
+
"""
|
|
344
|
+
logger.info("Lese solrconfig.xml für Core '%s'", core)
|
|
345
|
+
return self._get_file(core, "solrconfig.xml")
|
|
346
|
+
|
|
347
|
+
def get_core_schema(self, core: str) -> str:
|
|
348
|
+
"""Liefert das Schema eines Cores als XML-String.
|
|
349
|
+
|
|
350
|
+
Bei managed-schema Cores wird das Schema über die Schema-API
|
|
351
|
+
im schema.xml-Format abgerufen, da die Datei auf Disk
|
|
352
|
+
``managed-schema.xml`` heißt und automatisch verwaltet wird.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
core: Name des Core.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Schema-Inhalt als XML-String.
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
SolrClientError: Bei API-Fehler.
|
|
362
|
+
"""
|
|
363
|
+
logger.info("Lese Schema für Core '%s'", core)
|
|
364
|
+
info = self.get_core_info(core)
|
|
365
|
+
schema_name = info.get("schema", "managed-schema.xml")
|
|
366
|
+
|
|
367
|
+
if schema_name == "managed-schema.xml":
|
|
368
|
+
return self._get_schema_api(core)
|
|
369
|
+
|
|
370
|
+
return self._get_file(core, schema_name)
|
|
371
|
+
|
|
372
|
+
def _get_file(self, core: str, filename: str) -> str:
|
|
373
|
+
"""Liest eine Konfigurationsdatei über die Admin-File-API.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
core: Name des Core.
|
|
377
|
+
filename: Dateiname (z. B. ``solrconfig.xml``).
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Dateiinhalt als String.
|
|
381
|
+
|
|
382
|
+
Raises:
|
|
383
|
+
SolrClientError: Bei API-Fehler.
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
response = self._client.get(
|
|
387
|
+
f"/solr/{core}/admin/file",
|
|
388
|
+
params={"file": filename},
|
|
389
|
+
)
|
|
390
|
+
response.raise_for_status()
|
|
391
|
+
except httpx.HTTPStatusError as exc:
|
|
392
|
+
raise SolrClientError(
|
|
393
|
+
f"HTTP {exc.response.status_code} von Solr: {exc.response.text}"
|
|
394
|
+
) from exc
|
|
395
|
+
except httpx.HTTPError as exc:
|
|
396
|
+
raise SolrClientError(f"Verbindungsfehler zu Solr: {exc}") from exc
|
|
397
|
+
|
|
398
|
+
return response.text
|
|
399
|
+
|
|
400
|
+
def _get_schema_api(self, core: str) -> str:
|
|
401
|
+
"""Liest das Schema über die Schema-API im schema.xml-Format.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
core: Name des Core.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Schema als XML-String.
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
SolrClientError: Bei API-Fehler.
|
|
411
|
+
"""
|
|
412
|
+
try:
|
|
413
|
+
response = self._client.get(
|
|
414
|
+
f"/solr/{core}/schema",
|
|
415
|
+
params={"wt": "schema.xml"},
|
|
416
|
+
)
|
|
417
|
+
response.raise_for_status()
|
|
418
|
+
except httpx.HTTPStatusError as exc:
|
|
419
|
+
raise SolrClientError(
|
|
420
|
+
f"HTTP {exc.response.status_code} von Solr: {exc.response.text}"
|
|
421
|
+
) from exc
|
|
422
|
+
except httpx.HTTPError as exc:
|
|
423
|
+
raise SolrClientError(f"Verbindungsfehler zu Solr: {exc}") from exc
|
|
424
|
+
|
|
425
|
+
return response.text
|
|
426
|
+
|
|
427
|
+
# ------------------------------------------------------------------
|
|
428
|
+
# Interner Hilfsmethoden
|
|
429
|
+
# ------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
432
|
+
"""Sendet eine HTTP-Anfrage und wertet die JSON-Antwort aus.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
method: HTTP-Methode (z. B. ``"GET"``).
|
|
436
|
+
path: URL-Pfad relativ zur Basis-URL.
|
|
437
|
+
**kwargs: Werden direkt an ``httpx.Client.request`` weitergegeben.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Geparste JSON-Antwort als Dict.
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
SolrClientError: Bei HTTP-Fehler oder Solr-Fehler in der Antwort.
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
response = self._client.request(method, path, **kwargs)
|
|
447
|
+
response.raise_for_status()
|
|
448
|
+
except httpx.HTTPStatusError as exc:
|
|
449
|
+
raise SolrClientError(
|
|
450
|
+
f"HTTP {exc.response.status_code} von Solr: {exc.response.text}"
|
|
451
|
+
) from exc
|
|
452
|
+
except httpx.HTTPError as exc:
|
|
453
|
+
raise SolrClientError(f"Verbindungsfehler zu Solr: {exc}") from exc
|
|
454
|
+
|
|
455
|
+
data: dict[str, Any] = response.json()
|
|
456
|
+
|
|
457
|
+
# Solr signalisiert Fehler manchmal im Body mit status != 0
|
|
458
|
+
header = data.get("responseHeader", {})
|
|
459
|
+
if header.get("status", 0) != 0:
|
|
460
|
+
raise SolrClientError(f"Solr-Fehler in Antwort: {data}")
|
|
461
|
+
|
|
462
|
+
logger.debug("Solr-Antwort: %s", data)
|
|
463
|
+
return data
|
|
464
|
+
|
|
465
|
+
def close(self) -> None:
|
|
466
|
+
"""Schließt den HTTP-Client."""
|
|
467
|
+
self._client.close()
|
|
468
|
+
|
|
469
|
+
def __enter__(self) -> "SolrClient":
|
|
470
|
+
return self
|
|
471
|
+
|
|
472
|
+
def __exit__(self, *_: object) -> None:
|
|
473
|
+
self.close()
|
|
File without changes
|