qql-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.
qql/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("qql-cli")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0+unknown"
7
+
8
+ from .config import DEFAULT_MODEL, QQLConfig, load_config
9
+ from .exceptions import QQLError, QQLRuntimeError, QQLSyntaxError
10
+ from .executor import ExecutionResult, Executor
11
+ from .lexer import Lexer
12
+ from .parser import Parser
13
+
14
+ __all__ = [
15
+ "__version__",
16
+ "QQLConfig",
17
+ "QQLError",
18
+ "QQLRuntimeError",
19
+ "QQLSyntaxError",
20
+ "ExecutionResult",
21
+ "Executor",
22
+ "Lexer",
23
+ "Parser",
24
+ "run_query",
25
+ ]
26
+
27
+
28
+ def run_query(
29
+ query: str,
30
+ url: str = "http://localhost:6333",
31
+ secret: str | None = None,
32
+ default_model: str | None = None,
33
+ ) -> ExecutionResult:
34
+ """Convenience function for programmatic use."""
35
+ from qdrant_client import QdrantClient
36
+
37
+ cfg = QQLConfig(
38
+ url=url,
39
+ secret=secret,
40
+ default_model=default_model or DEFAULT_MODEL,
41
+ )
42
+ client = QdrantClient(url=url, api_key=secret)
43
+ tokens = Lexer().tokenize(query)
44
+ node = Parser(tokens).parse()
45
+ return Executor(client, cfg).execute(node)
qql/ast_nodes.py ADDED
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class InsertStmt:
7
+ collection: str
8
+ values: dict[str, Any] # must contain "text" key
9
+ model: str | None # None → use default
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CreateCollectionStmt:
14
+ collection: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DropCollectionStmt:
19
+ collection: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ShowCollectionsStmt:
24
+ pass
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SearchStmt:
29
+ collection: str
30
+ query_text: str
31
+ limit: int
32
+ model: str | None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class DeleteStmt:
37
+ collection: str
38
+ point_id: str | int
39
+
40
+
41
+ # Union type for all statement nodes
42
+ ASTNode = (
43
+ InsertStmt
44
+ | CreateCollectionStmt
45
+ | DropCollectionStmt
46
+ | ShowCollectionsStmt
47
+ | SearchStmt
48
+ | DeleteStmt
49
+ )
qql/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.prompt import Prompt
8
+ from rich.table import Table
9
+
10
+ from .config import delete_config, load_config, save_config, QQLConfig
11
+ from .exceptions import QQLError
12
+ from .executor import Executor
13
+ from .lexer import Lexer
14
+ from .parser import Parser
15
+
16
+ console = Console()
17
+ err_console = Console(stderr=True)
18
+
19
+ HELP_TEXT = """
20
+ [bold cyan]QQL — Qdrant Query Language[/bold cyan]
21
+
22
+ Available statements:
23
+
24
+ [yellow]INSERT INTO COLLECTION[/yellow] <name> [yellow]VALUES[/yellow] {[yellow]'text'[/yellow]: '...', ...}
25
+ Insert a point. 'text' is required and auto-vectorized.
26
+ Optional: [yellow]USING MODEL[/yellow] '<model>'
27
+
28
+ [yellow]CREATE COLLECTION[/yellow] <name>
29
+ Create a new collection (uses default model dimensions).
30
+
31
+ [yellow]DROP COLLECTION[/yellow] <name>
32
+ Delete a collection and all its points.
33
+
34
+ [yellow]SHOW COLLECTIONS[/yellow]
35
+ List all collections in the connected Qdrant instance.
36
+
37
+ [yellow]SEARCH[/yellow] <name> [yellow]SIMILAR TO[/yellow] '<text>' [yellow]LIMIT[/yellow] <n>
38
+ Semantic search by vector similarity.
39
+ Optional: [yellow]USING MODEL[/yellow] '<model>'
40
+
41
+ [yellow]DELETE FROM[/yellow] <name> [yellow]WHERE id =[/yellow] '<id>'
42
+ Delete a point by its ID.
43
+
44
+ Type [bold]exit[/bold] or [bold]quit[/bold] to leave the shell.
45
+ """
46
+
47
+
48
+ # ── CLI entry point ────────────────────────────────────────────────────────────
49
+
50
+ @click.group(invoke_without_command=True)
51
+ @click.pass_context
52
+ def main(ctx: click.Context) -> None:
53
+ """QQL — Qdrant Query Language CLI."""
54
+ ctx.ensure_object(dict)
55
+ if ctx.invoked_subcommand is None:
56
+ # No subcommand → load saved config and launch REPL
57
+ cfg = load_config()
58
+ if cfg is None:
59
+ err_console.print(
60
+ "[bold red]Not connected.[/bold red] "
61
+ "Run: [bold]qql connect --url <url>[/bold]"
62
+ )
63
+ sys.exit(1)
64
+ _launch_repl(cfg)
65
+
66
+
67
+ # ── connect ────────────────────────────────────────────────────────────────────
68
+
69
+ @main.command()
70
+ @click.option("--url", required=True, help="Qdrant instance URL, e.g. http://localhost:6333")
71
+ @click.option("--secret", default=None, help="API key / secret (optional)")
72
+ def connect(url: str, secret: str | None) -> None:
73
+ """Connect to a Qdrant instance and launch the QQL shell."""
74
+ from qdrant_client import QdrantClient
75
+
76
+ console.print(f"Connecting to [bold]{url}[/bold]...")
77
+
78
+ try:
79
+ client = QdrantClient(url=url, api_key=secret)
80
+ client.get_collections() # validate connection
81
+ except Exception as e:
82
+ err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
83
+ sys.exit(1)
84
+
85
+ cfg = QQLConfig(url=url, secret=secret)
86
+ save_config(cfg)
87
+ console.print(f"[bold green]Connected.[/bold green] Config saved to ~/.qql/config.json\n")
88
+ _launch_repl(cfg)
89
+
90
+
91
+ # ── disconnect ─────────────────────────────────────────────────────────────────
92
+
93
+ @main.command()
94
+ def disconnect() -> None:
95
+ """Remove saved connection config."""
96
+ delete_config()
97
+ console.print("Disconnected. Config removed.")
98
+
99
+
100
+ # ── REPL ───────────────────────────────────────────────────────────────────────
101
+
102
+ def _launch_repl(cfg: QQLConfig) -> None:
103
+ from qdrant_client import QdrantClient
104
+
105
+ try:
106
+ client = QdrantClient(url=cfg.url, api_key=cfg.secret)
107
+ client.get_collections()
108
+ except Exception as e:
109
+ err_console.print(f"[bold red]Could not connect to {cfg.url}:[/bold red] {e}")
110
+ err_console.print("Run [bold]qql connect --url <url>[/bold] to update your connection.")
111
+ sys.exit(1)
112
+
113
+ executor = Executor(client, cfg)
114
+
115
+ console.print(f"[bold cyan]QQL Interactive Shell[/bold cyan] • {cfg.url}")
116
+ console.print("Type [bold]help[/bold] for available commands or [bold]exit[/bold] to quit.\n")
117
+
118
+ while True:
119
+ try:
120
+ query = Prompt.ask("[bold green]qql>[/bold green]").strip()
121
+ except (EOFError, KeyboardInterrupt):
122
+ console.print("\nBye.")
123
+ break
124
+
125
+ if not query:
126
+ continue
127
+
128
+ low = query.lower()
129
+ if low in ("exit", "quit", "\\q", ":q"):
130
+ console.print("Bye.")
131
+ break
132
+ if low in ("help", "\\h", "?"):
133
+ console.print(HELP_TEXT)
134
+ continue
135
+
136
+ _run_and_print(executor, query)
137
+
138
+
139
+ def _run_and_print(executor: Executor, query: str) -> None:
140
+ try:
141
+ tokens = Lexer().tokenize(query)
142
+ node = Parser(tokens).parse()
143
+ result = executor.execute(node)
144
+ except QQLError as e:
145
+ err_console.print(f"[bold red]Error:[/bold red] {e}")
146
+ return
147
+ except Exception as e:
148
+ err_console.print(f"[bold red]Unexpected error:[/bold red] {e}")
149
+ return
150
+
151
+ if not result.success:
152
+ err_console.print(f"[bold red]Failed:[/bold red] {result.message}")
153
+ return
154
+
155
+ console.print(f"[bold green]✓[/bold green] {result.message}")
156
+
157
+ if result.data is None:
158
+ return
159
+
160
+ # Pretty-print collections list
161
+ if isinstance(result.data, list) and all(isinstance(x, str) for x in result.data):
162
+ if result.data:
163
+ table = Table(show_header=True, header_style="bold cyan")
164
+ table.add_column("Collection")
165
+ for name in result.data:
166
+ table.add_row(name)
167
+ console.print(table)
168
+ return
169
+
170
+ # Pretty-print search results
171
+ if isinstance(result.data, list) and result.data and isinstance(result.data[0], dict) and "score" in result.data[0]:
172
+ table = Table(show_header=True, header_style="bold cyan")
173
+ table.add_column("Score", justify="right")
174
+ table.add_column("ID")
175
+ table.add_column("Payload")
176
+ for hit in result.data:
177
+ table.add_row(
178
+ str(hit["score"]),
179
+ hit["id"],
180
+ str(hit["payload"]),
181
+ )
182
+ console.print(table)
183
+ return
184
+
185
+ # Fallback: print data as-is
186
+ console.print(result.data)
qql/config.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ CONFIG_DIR = Path.home() / ".qql"
9
+ CONFIG_PATH = CONFIG_DIR / "config.json"
10
+
11
+ DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
12
+
13
+
14
+ @dataclass
15
+ class QQLConfig:
16
+ url: str
17
+ secret: str | None = None
18
+ default_model: str = DEFAULT_MODEL
19
+
20
+
21
+ def save_config(cfg: QQLConfig) -> None:
22
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
23
+ with CONFIG_PATH.open("w") as f:
24
+ json.dump(asdict(cfg), f, indent=2)
25
+
26
+
27
+ def load_config() -> QQLConfig | None:
28
+ """Return saved config, or None if not yet connected."""
29
+ if not CONFIG_PATH.exists():
30
+ return None
31
+ with CONFIG_PATH.open() as f:
32
+ data = json.load(f)
33
+ return QQLConfig(
34
+ url=data["url"],
35
+ secret=data.get("secret"),
36
+ default_model=data.get("default_model", DEFAULT_MODEL),
37
+ )
38
+
39
+
40
+ def delete_config() -> None:
41
+ if CONFIG_PATH.exists():
42
+ CONFIG_PATH.unlink()
qql/embedder.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class Embedder:
5
+ DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
6
+
7
+ # Class-level cache: model_name → TextEmbedding instance
8
+ # Avoids reloading the same model across multiple Embedder() calls in one session.
9
+ _cache: dict[str, object] = {}
10
+
11
+ def __init__(self, model_name: str = DEFAULT_MODEL) -> None:
12
+ self._model_name = model_name
13
+ if model_name not in Embedder._cache:
14
+ # Import here so the module loads even without fastembed installed
15
+ # (the error surfaces only when embedding is actually attempted)
16
+ from fastembed import TextEmbedding
17
+
18
+ Embedder._cache[model_name] = TextEmbedding(model_name)
19
+ self._model = Embedder._cache[model_name]
20
+
21
+ def embed(self, text: str) -> list[float]:
22
+ """Embed a single string and return a plain list[float]."""
23
+ result = next(iter(self._model.embed([text]))) # type: ignore[attr-defined]
24
+ return result.tolist()
25
+
26
+ def embed_batch(self, texts: list[str]) -> list[list[float]]:
27
+ """Embed multiple strings."""
28
+ return [v.tolist() for v in self._model.embed(texts)] # type: ignore[attr-defined]
29
+
30
+ @property
31
+ def dimensions(self) -> int:
32
+ """Return the vector dimensionality by embedding a dummy string."""
33
+ return len(self.embed("probe"))
qql/exceptions.py ADDED
@@ -0,0 +1,15 @@
1
+ class QQLError(Exception):
2
+ """Base for all QQL errors."""
3
+
4
+
5
+ class QQLSyntaxError(QQLError):
6
+ """Raised by Lexer or Parser for malformed input."""
7
+
8
+ def __init__(self, message: str, pos: int | None = None) -> None:
9
+ self.pos = pos
10
+ suffix = f" (at position {pos})" if pos is not None else ""
11
+ super().__init__(f"{message}{suffix}")
12
+
13
+
14
+ class QQLRuntimeError(QQLError):
15
+ """Raised by Executor when the operation fails."""
qql/executor.py ADDED
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from qdrant_client import QdrantClient
8
+ from qdrant_client.http.exceptions import UnexpectedResponse
9
+ from qdrant_client.models import Distance, PointStruct, VectorParams
10
+
11
+ from .ast_nodes import (
12
+ ASTNode,
13
+ CreateCollectionStmt,
14
+ DeleteStmt,
15
+ DropCollectionStmt,
16
+ InsertStmt,
17
+ SearchStmt,
18
+ ShowCollectionsStmt,
19
+ )
20
+ from .config import QQLConfig
21
+ from .embedder import Embedder
22
+ from .exceptions import QQLRuntimeError
23
+
24
+
25
+ @dataclass
26
+ class ExecutionResult:
27
+ success: bool
28
+ message: str
29
+ data: Any = None
30
+
31
+
32
+ class Executor:
33
+ def __init__(self, client: QdrantClient, config: QQLConfig) -> None:
34
+ self._client = client
35
+ self._config = config
36
+
37
+ def execute(self, node: ASTNode) -> ExecutionResult:
38
+ if isinstance(node, InsertStmt):
39
+ return self._execute_insert(node)
40
+ if isinstance(node, CreateCollectionStmt):
41
+ return self._execute_create(node)
42
+ if isinstance(node, DropCollectionStmt):
43
+ return self._execute_drop(node)
44
+ if isinstance(node, ShowCollectionsStmt):
45
+ return self._execute_show(node)
46
+ if isinstance(node, SearchStmt):
47
+ return self._execute_search(node)
48
+ if isinstance(node, DeleteStmt):
49
+ return self._execute_delete(node)
50
+ raise QQLRuntimeError(f"Unknown AST node type: {type(node)}")
51
+
52
+ # ── Statement executors ───────────────────────────────────────────────
53
+
54
+ def _execute_insert(self, node: InsertStmt) -> ExecutionResult:
55
+ if "text" not in node.values:
56
+ raise QQLRuntimeError("INSERT requires a 'text' field in VALUES")
57
+
58
+ model_name = node.model or self._config.default_model
59
+ embedder = Embedder(model_name)
60
+ vector = embedder.embed(node.values["text"])
61
+
62
+ self._ensure_collection(node.collection, len(vector))
63
+
64
+ point_id = str(uuid.uuid4())
65
+ payload = dict(node.values)
66
+
67
+ try:
68
+ self._client.upsert(
69
+ collection_name=node.collection,
70
+ points=[PointStruct(id=point_id, vector=vector, payload=payload)],
71
+ )
72
+ except UnexpectedResponse as e:
73
+ raise QQLRuntimeError(f"Qdrant error during INSERT: {e}") from e
74
+
75
+ return ExecutionResult(
76
+ success=True,
77
+ message=f"Inserted 1 point [{point_id}]",
78
+ data={"id": point_id, "collection": node.collection},
79
+ )
80
+
81
+ def _execute_create(self, node: CreateCollectionStmt) -> ExecutionResult:
82
+ if self._client.collection_exists(node.collection):
83
+ return ExecutionResult(
84
+ success=True,
85
+ message=f"Collection '{node.collection}' already exists",
86
+ )
87
+ # Create with default model dimensions
88
+ embedder = Embedder(self._config.default_model)
89
+ dims = embedder.dimensions
90
+ self._client.create_collection(
91
+ collection_name=node.collection,
92
+ vectors_config=VectorParams(size=dims, distance=Distance.COSINE),
93
+ )
94
+ return ExecutionResult(
95
+ success=True,
96
+ message=f"Collection '{node.collection}' created ({dims}-dimensional vectors, cosine distance)",
97
+ )
98
+
99
+ def _execute_drop(self, node: DropCollectionStmt) -> ExecutionResult:
100
+ if not self._client.collection_exists(node.collection):
101
+ raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
102
+ self._client.delete_collection(node.collection)
103
+ return ExecutionResult(
104
+ success=True,
105
+ message=f"Collection '{node.collection}' dropped",
106
+ )
107
+
108
+ def _execute_show(self, node: ShowCollectionsStmt) -> ExecutionResult:
109
+ response = self._client.get_collections()
110
+ names = [c.name for c in response.collections]
111
+ return ExecutionResult(
112
+ success=True,
113
+ message=f"{len(names)} collection(s) found",
114
+ data=names,
115
+ )
116
+
117
+ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
118
+ if not self._client.collection_exists(node.collection):
119
+ raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
120
+
121
+ model_name = node.model or self._config.default_model
122
+ embedder = Embedder(model_name)
123
+ vector = embedder.embed(node.query_text)
124
+
125
+ try:
126
+ response = self._client.query_points(
127
+ collection_name=node.collection,
128
+ query=vector,
129
+ limit=node.limit,
130
+ )
131
+ except UnexpectedResponse as e:
132
+ raise QQLRuntimeError(f"Qdrant error during SEARCH: {e}") from e
133
+
134
+ results = [
135
+ {"id": str(h.id), "score": round(h.score, 4), "payload": h.payload}
136
+ for h in response.points
137
+ ]
138
+ return ExecutionResult(
139
+ success=True,
140
+ message=f"Found {len(results)} result(s)",
141
+ data=results,
142
+ )
143
+
144
+ def _execute_delete(self, node: DeleteStmt) -> ExecutionResult:
145
+ if not self._client.collection_exists(node.collection):
146
+ raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
147
+
148
+ from qdrant_client.models import PointIdsList
149
+
150
+ try:
151
+ self._client.delete(
152
+ collection_name=node.collection,
153
+ points_selector=PointIdsList(points=[node.point_id]),
154
+ )
155
+ except UnexpectedResponse as e:
156
+ raise QQLRuntimeError(f"Qdrant error during DELETE: {e}") from e
157
+
158
+ return ExecutionResult(
159
+ success=True,
160
+ message=f"Deleted point '{node.point_id}' from '{node.collection}'",
161
+ )
162
+
163
+ # ── Helpers ───────────────────────────────────────────────────────────
164
+
165
+ def _ensure_collection(self, name: str, vector_size: int) -> None:
166
+ """Create the collection if it doesn't exist. Raises on dimension mismatch."""
167
+ if self._client.collection_exists(name):
168
+ info = self._client.get_collection(name)
169
+ existing_size = info.config.params.vectors.size # type: ignore[union-attr]
170
+ if existing_size != vector_size:
171
+ raise QQLRuntimeError(
172
+ f"Vector dimension mismatch: collection '{name}' expects "
173
+ f"{existing_size} dims, but model produces {vector_size} dims. "
174
+ f"Specify a compatible model with USING MODEL '<model>'."
175
+ )
176
+ else:
177
+ self._client.create_collection(
178
+ collection_name=name,
179
+ vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
180
+ )