Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ qql connect --url http://localhost:6333

# Qdrant Cloud
qql connect --url https://<your-cluster>.qdrant.io --secret <your-api-key>

# Internal/self-signed certificate
qql connect --url https://<your-host>:6333 --secret <your-api-key> --ca-cert /path/to/ca.pem

# Disable TLS verification when you cannot provide a CA bundle
qql connect --url https://<your-host>:6333 --secret <your-api-key> --no-verify
```

Then type `qql` to open the interactive shell.
Expand Down
18 changes: 18 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,24 @@ qql connect --url http://localhost:6333
qql connect --url https://<your-cluster>.qdrant.io --secret <your-api-key>
```

### HTTPS with an internal CA or self-signed certificate

Prefer a custom CA bundle when your Qdrant endpoint uses an internal or
self-signed certificate:

```bash
qql connect --url https://<your-host>:6333 --secret <your-api-key> --ca-cert /path/to/ca.pem
```

If you cannot provide a CA bundle, TLS verification can be disabled:

```bash
qql connect --url https://<your-host>:6333 --secret <your-api-key> --no-verify
```

`--no-verify` should be limited to trusted internal environments because it
skips certificate validation.

On success you will see:

```
Expand Down
29 changes: 27 additions & 2 deletions docs/programmatic.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ with Connection("https://<your-cluster>.qdrant.io", secret="<your-api-key>") as
print(result.data)
```

### Internal or self-signed certificates

Prefer a custom CA bundle when your Qdrant endpoint uses an internal or
self-signed certificate:

```python
from qql import Connection

with Connection(
"https://<your-host>:6333",
secret="<your-api-key>",
verify="/path/to/ca.pem",
) as conn:
result = conn.run_query("SHOW COLLECTIONS")
```

If a CA bundle is not available, pass `verify=False` to disable TLS
verification for trusted internal environments:

```python
with Connection("https://<your-host>:6333", verify=False) as conn:
...
```

### Custom embedding model

```python
Expand Down Expand Up @@ -155,6 +179,7 @@ with Connection("http://localhost:6333") as conn:
| `url` | `str` | `"http://localhost:6333"` | Qdrant instance URL |
| `secret` | `str \| None` | `None` | API key; `None` for unauthenticated |
| `default_model` | `str \| None` | `None` → `sentence-transformers/all-MiniLM-L6-v2` | Dense embedding model used when no `USING MODEL` clause is given |
| `verify` | `bool \| str` | `True` | TLS verification setting; use `False` to skip verification or a CA bundle path for internal/self-signed certificates |
| `default_dense_vector_name` | `str` | `"dense"` | Dense vector name used when QQL creates a collection and no explicit `USING VECTOR` name is given |
| `default_sparse_vector_name` | `str` | `"sparse"` | Sparse vector name used when QQL creates a hybrid collection and no explicit sparse vector name is given |

Expand Down Expand Up @@ -200,8 +225,8 @@ for hit in result.data:
print(hit["score"], hit["payload"])
```

`run_query()` accepts the same `url`, `secret`, and `default_model` parameters
as `Connection.__init__()`.
`run_query()` accepts the same `url`, `secret`, `default_model`, and `verify`
parameters as `Connection.__init__()`.

---

Expand Down
4 changes: 3 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ The connection config is stored at `~/.qql/config.json`:
{
"url": "http://localhost:6333",
"secret": null,
"default_model": "sentence-transformers/all-MiniLM-L6-v2"
"default_model": "sentence-transformers/all-MiniLM-L6-v2",
"verify": true
}
```

Expand All @@ -142,6 +143,7 @@ The connection config is stored at `~/.qql/config.json`:
| `url` | Qdrant instance URL |
| `secret` | API key (null if not required) |
| `default_model` | Dense embedding model used when no `USING MODEL` clause is given |
| `verify` | TLS verification setting: `true`, `false`, or a custom CA bundle path from `--ca-cert` |

You can edit this file directly to change the default model without reconnecting.

Expand Down
8 changes: 7 additions & 1 deletion src/qql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def run_query(
url: str = "http://localhost:6333",
secret: str | None = None,
default_model: str | None = None,
verify: bool | str = True,
) -> ExecutionResult:
"""One-shot convenience function kept for backward compatibility.

Expand All @@ -55,5 +56,10 @@ def run_query(
with Connection(url, secret=secret) as conn:
result = conn.run_query(query)
"""
with Connection(url=url, secret=secret, default_model=default_model) as conn:
with Connection(
url=url,
secret=secret,
default_model=default_model,
verify=verify,
) as conn:
return conn.run_query(query)
36 changes: 29 additions & 7 deletions src/qql/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,20 +173,42 @@ def main(ctx: click.Context) -> None:
@main.command()
@click.option("--url", required=True, help="Qdrant instance URL, e.g. http://localhost:6333")
@click.option("--secret", default=None, help="API key / secret (optional)")
def connect(url: str, secret: str | None) -> None:
@click.option(
"--verify/--no-verify",
default=True,
show_default=True,
help="Verify SSL/TLS certificate (disable for self-signed certs).",
)
@click.option(
"--ca-cert",
default=None,
type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True),
help="Path to a custom CA certificate bundle (PEM).",
)
def connect(
url: str,
secret: str | None,
verify: bool,
ca_cert: str | None,
) -> None:
"""Connect to a Qdrant instance and launch the QQL shell."""
from qdrant_client import QdrantClient

if ca_cert and not verify:
raise click.UsageError("--ca-cert cannot be used with --no-verify.")

verify_val: bool | str = ca_cert if ca_cert else verify

console.print(f"Connecting to [bold]{url}[/bold]...")

try:
client = QdrantClient(url=url, api_key=secret)
client.get_collections() # validate connection
client = QdrantClient(url=url, api_key=secret, verify=verify_val)
client.get_collections()
except Exception as e:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
sys.exit(1)

cfg = QQLConfig(url=url, secret=secret)
cfg = QQLConfig(url=url, secret=secret, verify=verify_val)
save_config(cfg)
console.print("[bold green]Connected.[/bold green] Config saved to ~/.qql/config.json\n")
_launch_repl(cfg)
Expand Down Expand Up @@ -228,7 +250,7 @@ def execute(file: str, stop_on_error: bool) -> None:
sys.exit(1)

try:
client = QdrantClient(url=cfg.url, api_key=cfg.secret)
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
Expand Down Expand Up @@ -286,7 +308,7 @@ def dump(collection: str, output: str, batch_size: int) -> None:
sys.exit(1)

try:
client = QdrantClient(url=cfg.url, api_key=cfg.secret)
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
Expand Down Expand Up @@ -319,7 +341,7 @@ def _launch_repl(cfg: QQLConfig) -> None:
from qdrant_client import QdrantClient

try:
client = QdrantClient(url=cfg.url, api_key=cfg.secret)
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Could not connect to {cfg.url}:[/bold red] {e}")
Expand Down
2 changes: 2 additions & 0 deletions src/qql/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class QQLConfig:
default_model: str = DEFAULT_MODEL
default_dense_vector_name: str = DEFAULT_DENSE_VECTOR_NAME
default_sparse_vector_name: str = DEFAULT_SPARSE_VECTOR_NAME
verify: bool | str = True


def save_config(cfg: QQLConfig) -> None:
Expand All @@ -43,6 +44,7 @@ def load_config() -> QQLConfig | None:
default_sparse_vector_name=data.get(
"default_sparse_vector_name", DEFAULT_SPARSE_VECTOR_NAME
),
verify=data.get("verify", True),
)


Expand Down
7 changes: 6 additions & 1 deletion src/qql/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(
url: str = "http://localhost:6333",
secret: str | None = None,
default_model: str | None = None,
verify: bool | str = True,
) -> None:
"""Create a connection to a Qdrant instance.

Expand All @@ -60,15 +61,19 @@ def __init__(
default_model: Dense embedding model used when no ``USING MODEL`` clause
is specified. Defaults to
``sentence-transformers/all-MiniLM-L6-v2``.
verify: SSL certificate verification. Set to ``False`` to skip
verification for self-signed/internal certificates, or pass
a path to a custom CA bundle (default: ``True``).
"""
from qdrant_client import QdrantClient

self._config = QQLConfig(
url=url,
secret=secret,
default_model=default_model or DEFAULT_MODEL,
verify=verify,
)
self._client = QdrantClient(url=url, api_key=secret)
self._client = QdrantClient(url=url, api_key=secret, verify=verify)
self._executor = Executor(self._client, self._config)

# ── Public API ────────────────────────────────────────────────────────
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Tests for the Click CLI surface."""

from click.testing import CliRunner

from qql import QQLConfig
from qql.cli import main


def test_connect_saves_no_verify_config(mocker):
mock_client = mocker.MagicMock()
mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client)
save_config = mocker.patch("qql.cli.save_config")
launch_repl = mocker.patch("qql.cli._launch_repl")

result = CliRunner().invoke(
main,
["connect", "--url", "https://internal.example.io", "--no-verify"],
)

assert result.exit_code == 0
mock_client_cls.assert_called_once_with(
url="https://internal.example.io", api_key=None, verify=False
)
save_config.assert_called_once_with(
QQLConfig(url="https://internal.example.io", secret=None, verify=False)
)
launch_repl.assert_called_once()


def test_connect_saves_custom_ca_bundle_config(tmp_path, mocker):
ca_cert = tmp_path / "internal-ca.pem"
ca_cert.write_text("certificate")
mock_client = mocker.MagicMock()
mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client)
save_config = mocker.patch("qql.cli.save_config")
mocker.patch("qql.cli._launch_repl")

result = CliRunner().invoke(
main,
[
"connect",
"--url",
"https://internal.example.io",
"--ca-cert",
str(ca_cert),
],
)

assert result.exit_code == 0
verify = str(ca_cert.resolve())
mock_client_cls.assert_called_once_with(
url="https://internal.example.io", api_key=None, verify=verify
)
save_config.assert_called_once_with(
QQLConfig(url="https://internal.example.io", secret=None, verify=verify)
)


def test_connect_rejects_ca_bundle_when_verification_is_disabled(tmp_path):
ca_cert = tmp_path / "internal-ca.pem"
ca_cert.write_text("certificate")

result = CliRunner().invoke(
main,
[
"connect",
"--url",
"https://internal.example.io",
"--no-verify",
"--ca-cert",
str(ca_cert),
],
)

assert result.exit_code != 0
assert "--ca-cert cannot be used with --no-verify" in result.output


def test_execute_uses_saved_verify_config(tmp_path, mocker):
script = tmp_path / "script.qql"
script.write_text("SHOW COLLECTIONS")
cfg = QQLConfig(url="https://internal.example.io", secret="s3cr3t", verify=False)
mock_client = mocker.MagicMock()
mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client)
mocker.patch("qql.cli.load_config", return_value=cfg)
run_script = mocker.patch("qql.script.run_script", return_value=(1, 0))

result = CliRunner().invoke(main, ["execute", str(script)])

assert result.exit_code == 0
mock_client_cls.assert_called_once_with(
url="https://internal.example.io", api_key="s3cr3t", verify=False
)
run_script.assert_called_once()
22 changes: 20 additions & 2 deletions tests/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,27 @@ def test_custom_url_and_secret_passed_to_qdrant_client(self, mocker):
mock_client_cls = mocker.patch("qdrant_client.QdrantClient")
Connection("https://cloud.example.io", secret="s3cr3t")
mock_client_cls.assert_called_once_with(
url="https://cloud.example.io", api_key="s3cr3t"
url="https://cloud.example.io", api_key="s3cr3t", verify=True
)

def test_ssl_verify_option_passed_to_qdrant_client(self, mocker):
mock_client_cls = mocker.patch("qdrant_client.QdrantClient")
conn = Connection("https://internal.example.io", verify=False)
mock_client_cls.assert_called_once_with(
url="https://internal.example.io", api_key=None, verify=False
)
assert conn.config.verify is False

def test_custom_ca_bundle_passed_to_qdrant_client(self, mocker):
mock_client_cls = mocker.patch("qdrant_client.QdrantClient")
conn = Connection("https://internal.example.io", verify="/etc/ssl/internal-ca.pem")
mock_client_cls.assert_called_once_with(
url="https://internal.example.io",
api_key=None,
verify="/etc/ssl/internal-ca.pem",
)
assert conn.config.verify == "/etc/ssl/internal-ca.pem"

def test_custom_default_model_stored_in_config(self, mocker):
mocker.patch("qdrant_client.QdrantClient")
conn = Connection("http://localhost:6333", default_model="BAAI/bge-small-en-v1.5")
Expand Down Expand Up @@ -156,7 +174,7 @@ def test_run_query_delegates_to_connection(self, mocker):
conn_cls = mocker.patch("qql.Connection", return_value=conn_instance)
run_query("SHOW COLLECTIONS", url="http://localhost:6333")
conn_cls.assert_called_once_with(
url="http://localhost:6333", secret=None, default_model=None
url="http://localhost:6333", secret=None, default_model=None, verify=True
)
conn_instance.run_query.assert_called_once_with("SHOW COLLECTIONS")

Expand Down
Loading