guard-cli 0.1.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.
- guard/__init__.py +2 -0
- guard/agent_integration.py +122 -0
- guard/api.py +397 -0
- guard/backend.py +475 -0
- guard/cli.py +320 -0
- guard/db.py +271 -0
- guard/decision.py +316 -0
- guard/doctor_cmd.py +475 -0
- guard/events.py +77 -0
- guard/feedback.py +86 -0
- guard/hook.py +281 -0
- guard/ingest/__init__.py +1 -0
- guard/ingest/coverage.py +403 -0
- guard/ingest/static.py +290 -0
- guard/init_cmd.py +437 -0
- guard/integrations/__init__.py +1 -0
- guard/integrations/claude.py +195 -0
- guard/state.py +104 -0
- guard/thin_slice.py +278 -0
- guard_cli/__init__.py +37 -0
- guard_cli/api.py +2 -0
- guard_cli/backend.py +2 -0
- guard_cli/cli.py +3 -0
- guard_cli/db.py +2 -0
- guard_cli/decision.py +2 -0
- guard_cli/doctor_cmd.py +2 -0
- guard_cli/events.py +2 -0
- guard_cli/feedback.py +2 -0
- guard_cli/hook.py +2 -0
- guard_cli/ingest/__init__.py +1 -0
- guard_cli/ingest/coverage.py +1 -0
- guard_cli/ingest/static.py +1 -0
- guard_cli/init_cmd.py +2 -0
- guard_cli/integrations/__init__.py +1 -0
- guard_cli/integrations/claude.py +1 -0
- guard_cli/state.py +2 -0
- guard_cli/thin_slice.py +2 -0
- guard_cli-0.1.0.dist-info/METADATA +241 -0
- guard_cli-0.1.0.dist-info/RECORD +43 -0
- guard_cli-0.1.0.dist-info/WHEEL +5 -0
- guard_cli-0.1.0.dist-info/entry_points.txt +3 -0
- guard_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- guard_cli-0.1.0.dist-info/top_level.txt +2 -0
guard/backend.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Backend abstraction layer for Guard (Work Package B).
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- GraphBackend interface (ABC)
|
|
5
|
+
- ServerBackend: connects to existing FalkorDB server (e.g. localhost:6379)
|
|
6
|
+
- DockerBackend: manages dedicated docker container 'guard-falkordb' on port 6390
|
|
7
|
+
- EmbeddedBackend: manages embedded FalkorDBLite in ~/.guard/data/ (Python 3.12+)
|
|
8
|
+
- Backend resolution and fallback chain
|
|
9
|
+
- Configuration storage in .guard/config.toml
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import abc
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import tomllib
|
|
25
|
+
except ImportError:
|
|
26
|
+
tomllib = None # type: ignore
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_SERVER_HOST = "localhost"
|
|
30
|
+
DEFAULT_SERVER_PORT = 6379
|
|
31
|
+
DEFAULT_DOCKER_PORT = 6390
|
|
32
|
+
DEFAULT_DOCKER_CONTAINER = "guard-falkordb"
|
|
33
|
+
DEFAULT_DOCKER_IMAGE = "falkordb/falkordb:latest"
|
|
34
|
+
DEFAULT_DOCKER_VOLUME = "guard-data:/data"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_repo_graph_name(repo_path: str | Path | None = None) -> str:
|
|
38
|
+
"""Derive a unique, safe FalkorDB graph name from the repository path."""
|
|
39
|
+
if repo_path is None:
|
|
40
|
+
p = Path.cwd()
|
|
41
|
+
else:
|
|
42
|
+
p = Path(repo_path).resolve()
|
|
43
|
+
clean_name = re.sub(r"[^a-zA-Z0-9_]", "_", p.name.lower()).strip("_")
|
|
44
|
+
if not clean_name:
|
|
45
|
+
clean_name = "repo"
|
|
46
|
+
path_hash = hashlib.sha256(str(p).encode("utf-8")).hexdigest()[:8]
|
|
47
|
+
return f"guard_{clean_name}_{path_hash}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_simple_toml(content: str) -> dict[str, Any]:
|
|
51
|
+
"""Fallback simple TOML parser for basic [section] key=val configs."""
|
|
52
|
+
result: dict[str, Any] = {}
|
|
53
|
+
current_section = result
|
|
54
|
+
for line in content.splitlines():
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line or line.startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
if line.startswith("[") and line.endswith("]"):
|
|
59
|
+
sec = line[1:-1].strip()
|
|
60
|
+
result[sec] = {}
|
|
61
|
+
current_section = result[sec]
|
|
62
|
+
continue
|
|
63
|
+
if "=" in line:
|
|
64
|
+
key, val = line.split("=", 1)
|
|
65
|
+
key = key.strip()
|
|
66
|
+
val = val.strip()
|
|
67
|
+
# Parse primitives
|
|
68
|
+
if val.startswith('"') and val.endswith('"'):
|
|
69
|
+
val = val[1:-1]
|
|
70
|
+
elif val.startswith("'") and val.endswith("'"):
|
|
71
|
+
val = val[1:-1]
|
|
72
|
+
elif val.lower() == "true":
|
|
73
|
+
val = True
|
|
74
|
+
elif val.lower() == "false":
|
|
75
|
+
val = False
|
|
76
|
+
elif val.isdigit():
|
|
77
|
+
val = int(val)
|
|
78
|
+
current_section[key] = val
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(repo_path: str | Path | None = None) -> dict[str, Any]:
|
|
83
|
+
"""Loads configuration from .guard/config.toml if present."""
|
|
84
|
+
base = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
85
|
+
cfg_file = base / ".guard" / "config.toml"
|
|
86
|
+
if not cfg_file.exists():
|
|
87
|
+
return {}
|
|
88
|
+
content = cfg_file.read_text(encoding="utf-8")
|
|
89
|
+
if tomllib is not None:
|
|
90
|
+
try:
|
|
91
|
+
return tomllib.loads(content)
|
|
92
|
+
except Exception:
|
|
93
|
+
return _parse_simple_toml(content)
|
|
94
|
+
return _parse_simple_toml(content)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_config(repo_path: str | Path, config: dict[str, Any]) -> Path:
|
|
98
|
+
"""Saves configuration to .guard/config.toml."""
|
|
99
|
+
base = Path(repo_path).resolve()
|
|
100
|
+
guard_dir = base / ".guard"
|
|
101
|
+
guard_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
cfg_file = guard_dir / "config.toml"
|
|
103
|
+
|
|
104
|
+
lines: list[str] = [
|
|
105
|
+
"# Guard configuration file - automatically generated",
|
|
106
|
+
"",
|
|
107
|
+
]
|
|
108
|
+
for section, values in config.items():
|
|
109
|
+
if isinstance(values, dict):
|
|
110
|
+
lines.append(f"[{section}]")
|
|
111
|
+
for k, v in values.items():
|
|
112
|
+
if isinstance(v, bool):
|
|
113
|
+
lines.append(f"{k} = {str(v).lower()}")
|
|
114
|
+
elif isinstance(v, (int, float)):
|
|
115
|
+
lines.append(f"{k} = {v}")
|
|
116
|
+
else:
|
|
117
|
+
lines.append(f'{k} = "{v}"')
|
|
118
|
+
lines.append("")
|
|
119
|
+
else:
|
|
120
|
+
if isinstance(values, bool):
|
|
121
|
+
lines.append(f"{section} = {str(values).lower()}")
|
|
122
|
+
elif isinstance(values, (int, float)):
|
|
123
|
+
lines.append(f"{section} = {values}")
|
|
124
|
+
else:
|
|
125
|
+
lines.append(f'{section} = "{values}"')
|
|
126
|
+
|
|
127
|
+
cfg_file.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
|
|
128
|
+
return cfg_file
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class GraphBackend(abc.ABC):
|
|
132
|
+
"""Abstract interface for FalkorDB backends."""
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
@abc.abstractmethod
|
|
136
|
+
def name(self) -> str:
|
|
137
|
+
"""Name of backend: 'server', 'docker', or 'embedded'."""
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
@abc.abstractmethod
|
|
141
|
+
def connect(self) -> Any:
|
|
142
|
+
"""Return connected FalkorDB client."""
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
@abc.abstractmethod
|
|
146
|
+
def is_healthy(self) -> bool:
|
|
147
|
+
"""Check if backend is reachable and responsive to queries."""
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
@abc.abstractmethod
|
|
151
|
+
def describe(self) -> dict[str, Any]:
|
|
152
|
+
"""Return diagnostic description dictionary."""
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
@abc.abstractmethod
|
|
156
|
+
def get_graph(self, graph_name: str) -> Any:
|
|
157
|
+
"""Return FalkorDB graph object for given name."""
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
"""Release any held resources."""
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ServerBackend(GraphBackend):
|
|
166
|
+
"""Connects to an existing FalkorDB server instance."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, host: str = DEFAULT_SERVER_HOST, port: int = DEFAULT_SERVER_PORT):
|
|
169
|
+
self.host = host
|
|
170
|
+
self.port = int(port)
|
|
171
|
+
self._client: Any = None
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def name(self) -> str:
|
|
175
|
+
return "server"
|
|
176
|
+
|
|
177
|
+
def connect(self) -> Any:
|
|
178
|
+
if self._client is None:
|
|
179
|
+
from falkordb import FalkorDB
|
|
180
|
+
self._client = FalkorDB(host=self.host, port=self.port)
|
|
181
|
+
return self._client
|
|
182
|
+
|
|
183
|
+
def is_healthy(self) -> bool:
|
|
184
|
+
try:
|
|
185
|
+
client = self.connect()
|
|
186
|
+
g = client.select_graph("guard_healthcheck")
|
|
187
|
+
res = g.query("RETURN 1 AS n")
|
|
188
|
+
return bool(res and res.result_set)
|
|
189
|
+
except Exception:
|
|
190
|
+
self._client = None
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def describe(self) -> dict[str, Any]:
|
|
194
|
+
healthy = self.is_healthy()
|
|
195
|
+
return {
|
|
196
|
+
"type": "server",
|
|
197
|
+
"host": self.host,
|
|
198
|
+
"port": self.port,
|
|
199
|
+
"healthy": healthy,
|
|
200
|
+
"connection_str": f"{self.host}:{self.port}",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
def get_graph(self, graph_name: str) -> Any:
|
|
204
|
+
return self.connect().select_graph(graph_name)
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
self._client = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class DockerBackend(GraphBackend):
|
|
211
|
+
"""Manages a dedicated FalkorDB container running via Docker on port 6390."""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
port: int = DEFAULT_DOCKER_PORT,
|
|
216
|
+
container_name: str = DEFAULT_DOCKER_CONTAINER,
|
|
217
|
+
image: str = DEFAULT_DOCKER_IMAGE,
|
|
218
|
+
volume: str = DEFAULT_DOCKER_VOLUME,
|
|
219
|
+
):
|
|
220
|
+
self.port = int(port)
|
|
221
|
+
self.container_name = container_name
|
|
222
|
+
self.image = image
|
|
223
|
+
self.volume = volume
|
|
224
|
+
self._client: Any = None
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def name(self) -> str:
|
|
228
|
+
return "docker"
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def docker_available() -> bool:
|
|
232
|
+
"""Check if docker CLI is available and docker daemon is reachable."""
|
|
233
|
+
if not shutil.which("docker"):
|
|
234
|
+
return False
|
|
235
|
+
try:
|
|
236
|
+
res = subprocess.run(
|
|
237
|
+
["docker", "info"],
|
|
238
|
+
capture_output=True,
|
|
239
|
+
text=True,
|
|
240
|
+
timeout=5,
|
|
241
|
+
)
|
|
242
|
+
return res.returncode == 0
|
|
243
|
+
except Exception:
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def container_status(self) -> str | None:
|
|
247
|
+
"""Returns 'running', 'exited', or None if container does not exist."""
|
|
248
|
+
if not shutil.which("docker"):
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
res = subprocess.run(
|
|
252
|
+
["docker", "ps", "-a", "--filter", f"name=^/{self.container_name}$", "--format", "{{.Status}}"],
|
|
253
|
+
capture_output=True,
|
|
254
|
+
text=True,
|
|
255
|
+
timeout=5,
|
|
256
|
+
)
|
|
257
|
+
out = res.stdout.strip()
|
|
258
|
+
if not out:
|
|
259
|
+
return None
|
|
260
|
+
if out.lower().startswith("up"):
|
|
261
|
+
return "running"
|
|
262
|
+
return "exited"
|
|
263
|
+
except Exception:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
def start_container(self) -> bool:
|
|
267
|
+
"""Starts existing container or launches new container."""
|
|
268
|
+
if not self.docker_available():
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
status = self.container_status()
|
|
272
|
+
if status == "running":
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
if status == "exited":
|
|
276
|
+
# Restart existing container
|
|
277
|
+
res = subprocess.run(
|
|
278
|
+
["docker", "start", self.container_name],
|
|
279
|
+
capture_output=True,
|
|
280
|
+
text=True,
|
|
281
|
+
timeout=15,
|
|
282
|
+
)
|
|
283
|
+
if res.returncode != 0:
|
|
284
|
+
return False
|
|
285
|
+
else:
|
|
286
|
+
# Run new container
|
|
287
|
+
cmd = [
|
|
288
|
+
"docker", "run", "-d",
|
|
289
|
+
"--name", self.container_name,
|
|
290
|
+
"-v", self.volume,
|
|
291
|
+
"-p", f"{self.port}:6379",
|
|
292
|
+
self.image,
|
|
293
|
+
]
|
|
294
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
295
|
+
if res.returncode != 0:
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
# Wait up to 10 seconds for container to answer healthcheck
|
|
299
|
+
for _ in range(20):
|
|
300
|
+
time.sleep(0.5)
|
|
301
|
+
if self.is_healthy():
|
|
302
|
+
return True
|
|
303
|
+
return self.is_healthy()
|
|
304
|
+
|
|
305
|
+
def connect(self) -> Any:
|
|
306
|
+
if self._client is None:
|
|
307
|
+
from falkordb import FalkorDB
|
|
308
|
+
self._client = FalkorDB(host="localhost", port=self.port)
|
|
309
|
+
return self._client
|
|
310
|
+
|
|
311
|
+
def is_healthy(self) -> bool:
|
|
312
|
+
try:
|
|
313
|
+
client = self.connect()
|
|
314
|
+
g = client.select_graph("guard_healthcheck")
|
|
315
|
+
res = g.query("RETURN 1 AS n")
|
|
316
|
+
return bool(res and res.result_set)
|
|
317
|
+
except Exception:
|
|
318
|
+
self._client = None
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
def describe(self) -> dict[str, Any]:
|
|
322
|
+
status = self.container_status()
|
|
323
|
+
healthy = self.is_healthy()
|
|
324
|
+
return {
|
|
325
|
+
"type": "docker",
|
|
326
|
+
"container": self.container_name,
|
|
327
|
+
"port": self.port,
|
|
328
|
+
"image": self.image,
|
|
329
|
+
"volume": self.volume,
|
|
330
|
+
"status": status or "not_found",
|
|
331
|
+
"healthy": healthy,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
def get_graph(self, graph_name: str) -> Any:
|
|
335
|
+
return self.connect().select_graph(graph_name)
|
|
336
|
+
|
|
337
|
+
def close(self) -> None:
|
|
338
|
+
self._client = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class EmbeddedBackend(GraphBackend):
|
|
342
|
+
"""Manages an embedded FalkorDBLite instance (Python 3.12+)."""
|
|
343
|
+
|
|
344
|
+
def __init__(self, data_path: str | Path | None = None):
|
|
345
|
+
if sys.platform not in ("darwin", "linux") and not sys.platform.startswith("linux"):
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
f"Embedded backend (FalkorDBLite) is not supported on platform '{sys.platform}'. "
|
|
348
|
+
f"FalkorDBLite only supports Linux and macOS. "
|
|
349
|
+
f"Please use Docker backend ('guard init --backend docker') or Server backend ('guard init --backend server')."
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if sys.version_info < (3, 12):
|
|
353
|
+
raise RuntimeError(
|
|
354
|
+
f"Embedded backend (FalkorDBLite) requires Python 3.12 or newer. "
|
|
355
|
+
f"Current interpreter is Python {sys.version.split()[0]}. "
|
|
356
|
+
f"Try: uv tool install --python 3.12 guard-cli, or use Docker/Server backend."
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if data_path is None:
|
|
360
|
+
base_dir = Path.home() / ".guard" / "data"
|
|
361
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
self.db_path = base_dir / "falkordb.rdb"
|
|
363
|
+
else:
|
|
364
|
+
self.db_path = Path(data_path).resolve()
|
|
365
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
|
|
367
|
+
self._client: Any = None
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def name(self) -> str:
|
|
371
|
+
return "embedded"
|
|
372
|
+
|
|
373
|
+
def connect(self) -> Any:
|
|
374
|
+
if self._client is None:
|
|
375
|
+
# Check import paths (redislite vs falkordblite)
|
|
376
|
+
try:
|
|
377
|
+
from redislite import FalkorDB
|
|
378
|
+
except ImportError:
|
|
379
|
+
try:
|
|
380
|
+
from falkordblite import FalkorDB # type: ignore
|
|
381
|
+
except ImportError as err:
|
|
382
|
+
raise ImportError(
|
|
383
|
+
"falkordblite is not installed. Install with: pip install 'guard-cli[embedded]'"
|
|
384
|
+
) from err
|
|
385
|
+
|
|
386
|
+
self._client = FalkorDB(dbfilename=str(self.db_path))
|
|
387
|
+
return self._client
|
|
388
|
+
|
|
389
|
+
def is_healthy(self) -> bool:
|
|
390
|
+
try:
|
|
391
|
+
client = self.connect()
|
|
392
|
+
g = client.select_graph("guard_healthcheck")
|
|
393
|
+
res = g.query("RETURN 1 AS n")
|
|
394
|
+
return bool(res and res.result_set)
|
|
395
|
+
except Exception:
|
|
396
|
+
self._client = None
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
def describe(self) -> dict[str, Any]:
|
|
400
|
+
healthy = self.is_healthy()
|
|
401
|
+
return {
|
|
402
|
+
"type": "embedded",
|
|
403
|
+
"data_path": str(self.db_path),
|
|
404
|
+
"python_version": sys.version.split()[0],
|
|
405
|
+
"healthy": healthy,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
def get_graph(self, graph_name: str) -> Any:
|
|
409
|
+
return self.connect().select_graph(graph_name)
|
|
410
|
+
|
|
411
|
+
def close(self) -> None:
|
|
412
|
+
self._client = None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def resolve_backend(
|
|
416
|
+
preference: str | None = None,
|
|
417
|
+
repo_path: str | Path | None = None,
|
|
418
|
+
autostart: bool = True,
|
|
419
|
+
) -> GraphBackend:
|
|
420
|
+
"""Resolve backend using preference, repository config, or fallback chain.
|
|
421
|
+
|
|
422
|
+
Fallback chain order:
|
|
423
|
+
1. Explicit preference or .guard/config.toml preference
|
|
424
|
+
2. Existing server (localhost:6379 or configured FALKORDB_PORT)
|
|
425
|
+
3. Docker container (guard-falkordb on port 6390)
|
|
426
|
+
4. Embedded (FalkorDBLite, Python 3.12+)
|
|
427
|
+
"""
|
|
428
|
+
cfg = load_config(repo_path)
|
|
429
|
+
b_cfg = cfg.get("backend", {})
|
|
430
|
+
|
|
431
|
+
pref = preference or b_cfg.get("type")
|
|
432
|
+
host = b_cfg.get("host") or os.getenv("FALKORDB_HOST", DEFAULT_SERVER_HOST)
|
|
433
|
+
port = int(b_cfg.get("port") or os.getenv("FALKORDB_PORT", DEFAULT_SERVER_PORT))
|
|
434
|
+
|
|
435
|
+
# 1. Explicit preference
|
|
436
|
+
if pref:
|
|
437
|
+
pref = pref.lower().strip()
|
|
438
|
+
if pref == "server":
|
|
439
|
+
return ServerBackend(host=host, port=port)
|
|
440
|
+
elif pref == "docker":
|
|
441
|
+
d_port = int(b_cfg.get("port", DEFAULT_DOCKER_PORT))
|
|
442
|
+
b = DockerBackend(port=d_port)
|
|
443
|
+
if autostart and not b.is_healthy():
|
|
444
|
+
b.start_container()
|
|
445
|
+
return b
|
|
446
|
+
elif pref == "embedded":
|
|
447
|
+
return EmbeddedBackend()
|
|
448
|
+
else:
|
|
449
|
+
raise ValueError(f"Unknown backend preference: '{pref}'. Choose 'server', 'docker', or 'embedded'.")
|
|
450
|
+
|
|
451
|
+
# 2. Existing server check
|
|
452
|
+
srv = ServerBackend(host=host, port=port)
|
|
453
|
+
if srv.is_healthy():
|
|
454
|
+
return srv
|
|
455
|
+
|
|
456
|
+
# 3. Docker check
|
|
457
|
+
if DockerBackend.docker_available():
|
|
458
|
+
dock = DockerBackend(port=DEFAULT_DOCKER_PORT)
|
|
459
|
+
if autostart:
|
|
460
|
+
if dock.start_container() and dock.is_healthy():
|
|
461
|
+
return dock
|
|
462
|
+
elif dock.is_healthy():
|
|
463
|
+
return dock
|
|
464
|
+
|
|
465
|
+
# 4. Embedded fallback check
|
|
466
|
+
if sys.version_info >= (3, 12):
|
|
467
|
+
try:
|
|
468
|
+
emb = EmbeddedBackend()
|
|
469
|
+
if emb.is_healthy():
|
|
470
|
+
return emb
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
|
|
474
|
+
# Default fallback: return ServerBackend so caller receives connection details/errors
|
|
475
|
+
return srv
|