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/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