Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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)
38 changes: 31 additions & 7 deletions src/qql/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,20 +173,44 @@ 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)
else:
client.close()

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 +252,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 +310,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 +343,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