Skip to content

feat(client): add server_info() method returning the full VersionInfo model - #1297

Closed
Harsh23Kashyap wants to merge 1 commit into
qdrant:devfrom
Harsh23Kashyap:feat/client-server-info
Closed

feat(client): add server_info() method returning the full VersionInfo model#1297
Harsh23Kashyap wants to merge 1 commit into
qdrant:devfrom
Harsh23Kashyap:feat/client-server-info

Conversation

@Harsh23Kashyap

Copy link
Copy Markdown

Summary

Add a public server_info() method to all 6 client classes so users can read the Qdrant server's full VersionInfo (with title, version, commit) without digging into client.http.service_api.root().

Fixes #1296.

Problem

qdrant_version() (#1295) returns just the version string (e.g. "1.10.0"). The full VersionInfo model has two more fields that the user might want: title (the server's display name) and commit (the git commit hash). The infrastructure is in place — service_api.root() returns the full m.VersionInfo pydantic model — but the high-level client classes don't expose it.

Implementation

One server_info() method per class. Each is 1-5 lines, all return a VersionInfo | None. No new abstractions, no new state, no public API change beyond the new method names.

  • QdrantRemote.server_info() — sync. Calls self.openapi_client.service_api.root(). Returns the VersionInfo, or None on any exception.
  • AsyncQdrantRemote.server_info() — async. Awaits self.http.service_api.root(). Returns the VersionInfo, or None on any exception.
  • QdrantLocal.server_info() — sync. Returns a synthetic VersionInfo(title="qdrant-client (local mode)", version=<client library version>, commit=None). Never fails.
  • AsyncQdrantLocal.server_info() — sync (no I/O). Same as QdrantLocal.server_info().
  • QdrantClient.server_info() — sync. Delegates to self._client.server_info().
  • AsyncQdrantClient.server_info() — async. Awaits the inner result if it's a coroutine (remote mode), returns the model directly (local mode). Detected via inspect.iscoroutine.

The None contract: any failure (timeout, connection refused, non-2xx, API error) is folded into a None return; no exception escapes. Same "best-effort" pattern as health_check() (#1289) and qdrant_version() (#1294).

The return type is qdrant_client.http.models.VersionInfo | None. Users who want just the version string continue to use qdrant_version() (from #1295); users who want the full model use server_info().

Regen

The async files are generated by the AST transformer pipeline. The transformer converts def X to async def X only when X is in async_methods (built from iscoroutinefunction of the async base class). server_info is not in any async base and is not a coroutine function, so the transformer would keep it sync in the async mirror — but the async remote and async facade need it to be async def (to await the underlying root call).

Two changes keep the new methods alive across regens:

  • server_info added to exclude_methods in both client_generator.py and remote_generator.py. The transformer skips it during regen.
  • tools/generate_async_client.sh gets an AST-based post-regen step that re-injects the async def server_info into both async_qdrant_client.py and async_qdrant_remote.py after the close() method. Fails loudly with non-zero exit if close() cannot be found (signature change), so a stale regen is caught immediately.

The local async file (local/async_qdrant_local.py) keeps def server_info (sync) because the AST transformer correctly leaves it sync when not in async_methods — and the facade correctly detects that sync return with inspect.iscoroutine and returns it directly.

Verified the sed step end-to-end by manually removing the methods from the generated files, running the regen script, and confirming the methods are re-injected correctly with content matching the originals (empty diff).

Tests

tests/test_server_info.py (22 tests) covers:

  • QdrantClient facade: local :memory: (synthetic VersionInfo), local path, remote with mocked root (success / connection error / timeout).
  • AsyncQdrantClient facade: same matrix (all awaited).
  • QdrantRemote directly: success, connection error, attribute error.
  • AsyncQdrantRemote directly: success, connection error.
  • QdrantLocal directly: synthetic VersionInfo, never returns None.
  • AsyncQdrantLocal directly: same.
  • Never-raises contract: RuntimeError folds to None.
  • Return type contract: result has title/version/commit attributes (pydantic model).

All 22 new tests pass; 46 existing tests in test_tracing.py, test_common.py, test_in_memory.py, and test_local_persistence.py still pass (68 total).

Verification

  • mypy clean on all 6 modified source files.
  • ruff check + format clean on the new test file.
  • AST-based regen sed step verified end-to-end (remove → regen → diff is empty).
  • The local-mode VersionInfo uses importlib.metadata.version("qdrant-client") (Python 3.8+ stdlib).

Base branch

PR targets upstream/dev per maintainer joein's 2026-07-21 close comment on #1269: "All the PRs should point dev branch, not master." Matches the PR template at .github/PULL_REQUEST_TEMPLATE.md:4.

Out of scope

  • Returning just the commit field as a separate qdrant_commit() method. Users can do client.server_info().commit if they want.
  • A closed property on the facade classes (orthogonal; the with block from feat(client): support context manager on QdrantClient and AsyncQdrantClient #1286 covers the common case).
  • Exposing other root-level metadata. The VersionInfo model only has title, version, commit; if more fields are added upstream, this method automatically returns them.

… model

QdrantClient, AsyncQdrantClient, QdrantRemote, AsyncQdrantRemote,
QdrantLocal, and AsyncQdrantLocal all gain a public server_info()
method that returns the qdrant_client.http.models.VersionInfo
pydantic model (with title, version, and commit).

Remote mode calls the existing service_api.root() binding and
returns the VersionInfo, or None on any failure (timeout,
connection refused, non-2xx, API error). Local mode returns a
synthetic VersionInfo with title='qdrant-client (local mode)',
version=<client library version>, and commit=None.

The method never raises; failures fold into None. Same best-effort
pattern as health_check() and qdrant_version() from #1289, #1294.

The AST transformer pipeline can't reproduce the async def version
of server_info (the sync method gets kept sync because the method
isn't in AsyncQdrantBase, but the async remote/facade need async
def to await the underlying root() call). Added server_info to
exclude_methods in client_generator.py and remote_generator.py;
tools/generate_async_client.sh now has an AST-based post-regen
step that re-injects the async def server_info into both
async_qdrant_client.py and async_qdrant_remote.py. Fails loudly
with non-zero exit if close() can't be found in the regenerated
file (signature change), so a stale regen is caught immediately.

Tests in tests/test_server_info.py cover sync and async facade,
sync and async remote, sync local mode (returns synthetic
VersionInfo with title='qdrant-client (local mode)'), async
local mode, success and failure paths, connection errors,
timeouts, attribute errors, runtime errors, the VersionInfo
return type contract, and the never-raises contract. 22/22 new
tests pass; 46/46 existing tests in test_tracing.py,
test_common.py, test_in_memory.py, and test_local_persistence.py
still pass (68 total).

Fixes #1296
@netlify

netlify Bot commented Jul 30, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit eb010fe
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a6b5fae18c90f0008b74ffc
😎 Deploy Preview https://deploy-preview-1297--poetic-froyo-8baba7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds server_info() to all synchronous and asynchronous client variants. Remote clients call the REST root endpoint and return None on failures; local clients return synthetic VersionInfo data. Facade clients delegate to their underlying implementations, with asynchronous handling for both synchronous and coroutine results. Async generation tooling preserves the methods, and comprehensive tests cover success, failure, local, remote, and type-shape behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: joein

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a public server_info() method returning VersionInfo.
Description check ✅ Passed The description clearly matches the implemented server_info() API, regeneration safeguards, and test coverage.
Linked Issues check ✅ Passed The changes satisfy #1296: all six clients expose server_info(), remote failures return None, local mode returns synthetic VersionInfo, and tests cover it.
Out of Scope Changes check ✅ Passed The generator updates and tests are directly tied to the new server_info() feature and do not appear out of scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
qdrant_client/async_qdrant_remote.py (1)

264-275: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Blind except Exception: flagged by Ruff (BLE001).

Same as qdrant_remote.py — intentional for best-effort semantics, but worth a # noqa: BLE001 with justification for lint cleanliness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/async_qdrant_remote.py` around lines 264 - 275, Update the
exception handler in server_info to add a targeted Ruff suppression for BLE001,
with a brief inline justification that all failures are intentionally converted
to None for best-effort semantics. Preserve the existing async root request and
return behavior.

Source: Linters/SAST tools

qdrant_client/qdrant_remote.py (1)

324-335: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Blind except Exception: flagged by Ruff (BLE001).

Intentional here for the "never raises" contract, but Ruff will flag it (same pattern as async_qdrant_remote.py). Consider a # noqa: BLE001 with justification to keep lint clean, matching the PR's "maintain... ruff cleanliness" goal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/qdrant_remote.py` around lines 324 - 335, Update the exception
handler in server_info to retain the intentional catch-all required by its
never-raises contract while suppressing Ruff BLE001 with an inline noqa
annotation and brief justification. Keep returning None for every failure and
align the suppression style with the corresponding async implementation.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@qdrant_client/async_qdrant_remote.py`:
- Around line 264-275: Update AsyncQdrantRemote.server_info() to check
_prefer_grpc and call self.grpc_root.HealthCheck(...) when enabled, while
retaining the existing REST root() call as the fallback and returning None on
failures. Apply the same branching behavior to the synchronous
QdrantRemote.server_info() implementation, mirroring info()’s gRPC usage.

In `@qdrant_client/qdrant_client.py`:
- Around line 180-192: The QdrantBase type does not expose the server_info
method used by the facade. Add a server_info declaration to QdrantBase with the
VersionInfo-or-None return type and the existing best-effort contract, ensuring
self._client.server_info() type-checks without changing runtime behavior.

In `@qdrant_client/qdrant_remote.py`:
- Around line 324-335: Update QdrantRemote.server_info() to use
self.grpc_root.HealthCheck when prefer_grpc=True, matching the branch used by
QdrantRemote.info(), while retaining the REST service_api.root() path otherwise.
Preserve the method’s None-on-failure behavior and mirror info()’s
synchronous/asynchronous handling consistently.

In `@tests/test_server_info.py`:
- Around line 29-33: Update both local-storage tests, including
test_local_path_returns_synthetic and its async counterpart, to accept pytest’s
tmp_path fixture and pass a unique path derived from it to QdrantClient instead
of hardcoded /tmp paths. Preserve the existing assertions and async behavior
while ensuring each test uses isolated temporary storage.

In `@tools/generate_async_client.sh`:
- Around line 36-101: Update the regeneration script’s injection logic for
AsyncQdrantClient.server_info so the generated async_qdrant_client.py also
contains the inspect import required by inspect.iscoroutine. Add the import
through a stable existing top-level import anchor or locate the first
import/from statement with the parsed AST, while preserving the existing method
injection behavior.

---

Nitpick comments:
In `@qdrant_client/async_qdrant_remote.py`:
- Around line 264-275: Update the exception handler in server_info to add a
targeted Ruff suppression for BLE001, with a brief inline justification that all
failures are intentionally converted to None for best-effort semantics. Preserve
the existing async root request and return behavior.

In `@qdrant_client/qdrant_remote.py`:
- Around line 324-335: Update the exception handler in server_info to retain the
intentional catch-all required by its never-raises contract while suppressing
Ruff BLE001 with an inline noqa annotation and brief justification. Keep
returning None for every failure and align the suppression style with the
corresponding async implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d5cf5fcd-1a4e-4d52-b300-2c96fc87b37d

📥 Commits

Reviewing files that changed from the base of the PR and between e7aecbd and eb010fe.

📒 Files selected for processing (10)
  • qdrant_client/async_qdrant_client.py
  • qdrant_client/async_qdrant_remote.py
  • qdrant_client/local/async_qdrant_local.py
  • qdrant_client/local/qdrant_local.py
  • qdrant_client/qdrant_client.py
  • qdrant_client/qdrant_remote.py
  • tests/test_server_info.py
  • tools/async_client_generator/client_generator.py
  • tools/async_client_generator/remote_generator.py
  • tools/generate_async_client.sh

Comment on lines +264 to +275
async def server_info(self) -> types.VersionInfo | None:
"""Return the Qdrant server's full ``VersionInfo`` model (with
``title``, ``version``, and ``commit``). Awaits the REST ``/``
endpoint via the existing async service API binding.

On any failure (timeout, connection refused, non-2xx, API
error), returns ``None`` rather than raising."""
try:
return await self.http.service_api.root()
except Exception:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant async/sync remote files and nearby implementations.
for f in qdrant_client/async_qdrant_remote.py qdrant_client/qdrant_remote.py; do
  if [ -f "$f" ]; then
    echo "===== $f ====="
    wc -l "$f"
    ast-grep outline "$f" --match "server_info" --view expanded || true
    ast-grep outline "$f" --match "info" --view expanded || true
    sed -n '240,285p' "$f"
    sed -n '2530,2590p' "$f"
  fi
done

# Search for server_info/root/HealthCheck implementations/usages across the package.
echo "===== server_info usages/definitions ====="
rg -n "def server_info|async def server_info|service_api\.root|HealthCheck|prefer_grpc|_prefer_grpc|convert_health_check_reply" qdrant_client -S

Repository: qdrant/qdrant-client

Length of output: 20969


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== qdrant_client/qdrant_remote.py server_info and info ====="
sed -n '320,336p' qdrant_client/qdrant_remote.py
sed -n '2764,2782p' qdrant_client/qdrant_remote.py

echo "===== qdrant_client/async_qdrant_remote.py init/http/grpc attributes ====="
sed -n '45,75p' qdrant_client/async_qdrant_remote.py
sed -n '220,280p' qdrant_client/async_qdrant_remote.py
sed -n '430,455p' qdrant_client/async_qdrant_remote.py
sed -n '2520,2579p' qdrant_client/async_qdrant_remote.py

echo "===== qdrant_client/async_qdrant_remote.py attribute references around server_info ====="
rg -n "self\.(http|rest|grpc_root)\." qdrant_client/async_qdrant_remote.py -S

Repository: qdrant/qdrant-client

Length of output: 11266


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== qdrant_client/async_qdrant_remote.py self.rest assignments ====="
rg -n "self\.(rest|http)\.service_api|asyncopenapiclient|service_api|REST|rest =" qdrant_client/async_qdrant_remote.py -S

echo "===== qdrant_client/qdrant_remote.py self.rest assignments ====="
rg -n "self\.(rest|openapi_client)\.service_api|service_api|REST|rest =" qdrant_client/qdrant_remote.py -S

echo "===== parent openapi_client/service_api definitions ====="
rg -n "class OpenAPI|openapi_client|service_api|class AsyncOpenAPIClient" qdrant_client/async_qdrant_remote.py qdrant_client/qdrant_remote.py qdrant_client -g '*.py' -S

Repository: qdrant/qdrant-client

Length of output: 18447


Use gRPC in server_info() when _prefer_grpc is set.

AsyncQdrantRemote.server_info() unconditionally calls self.http.service_api.root(), so clients configured for gRPC (prefer_grpc=True) silently receive None instead of the health-check version. Mirror info() by using self.grpc_root.HealthCheck(...) for gRPC, with the REST fallback in the catch/else path where appropriate. The sync QdrantRemote.server_info() has the same _prefer_grpc gap.

🧰 Tools
🪛 Ruff (0.16.0)

[warning] 273-273: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/async_qdrant_remote.py` around lines 264 - 275, Update
AsyncQdrantRemote.server_info() to check _prefer_grpc and call
self.grpc_root.HealthCheck(...) when enabled, while retaining the existing REST
root() call as the fallback and returning None on failures. Apply the same
branching behavior to the synchronous QdrantRemote.server_info() implementation,
mirroring info()’s gRPC usage.

Comment on lines +180 to +192
def server_info(self) -> types.VersionInfo | None:
"""Return the Qdrant server's full ``VersionInfo`` model (with
``title``, ``version``, and ``commit``). For local mode, returns
a synthetic ``VersionInfo`` with
``title="qdrant-client (local mode)"``,
``version=<client library version>``, and ``commit=None``.

On any remote failure (timeout, connection refused, non-2xx),
returns ``None`` rather than raising — the model is best-effort."""
if hasattr(self, "_client"):
return self._client.server_info()
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -a 'qdrant_base.py|client_base.py' qdrant_client --regex
rg -n 'class (Qdrant|AsyncQdrant)Base' -A 5 qdrant_client
rg -n 'def server_info' qdrant_client

Repository: qdrant/qdrant-client

Length of output: 1498


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- client_base.py ---\n'
sed -n '1,120p' qdrant_client/client_base.py

printf '\n--- qdrant_client.py self._client/server_info slices ---\n'
rg -n 'self\._client|class QdrantClient|def __init__|def server_info' qdrant_client/qdrant_client.py

printf '\n--- __init__.py exports ---\n'
sed -n '1,160p' qdrant_client/__init__.py

printf '\n--- check declared base methods/annotations for QdrantBase ---\n'
python3 - <<'PY'
import ast
from pathlib import Path

path = Path("qdrant_client/client_base.py")
tree = ast.parse(path.read_text())
cls = next((node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "QdrantBase"), None)
assert cls
methods = [m.name for m in cls.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))]
print("QdrantBase methods:", methods)
print("server_info declared:", "server_info" in methods)
print("has annotations section:", any(isinstance(node, ast.AnnAssign) or isinstance(node, ast.Assign) for node in cls.body))
PY

Repository: qdrant/qdrant-client

Length of output: 249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- client_base.py ---'
sed -n '1,120p' qdrant_client/client_base.py

echo
echo '--- qdrant_client.py self._client/server_info slices ---'
rg -n 'self\._client|class QdrantClient|def __init__|def server_info' qdrant_client/qdrant_client.py

echo
echo '--- __init__.py exports ---'
sed -n '1,160p' qdrant_client/__init__.py

echo
echo '--- check declared base methods/annotations for QdrantBase ---'
python3 - <<'PY'
import ast
from pathlib import Path

path = Path("qdrant_client/client_base.py")
tree = ast.parse(path.read_text())
cls = next((node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "QdrantBase"), None)
assert cls
methods = [m.name for m in cls.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))]
print("QdrantBase methods:", methods)
print("server_info declared:", "server_info" in methods)
print("has annotations section:", any(isinstance(node, ast.AnnAssign) or isinstance(node, ast.Assign) for node in cls.body))
PY

Repository: qdrant/qdrant-client

Length of output: 9774


Declare server_info on QdrantBase.

self._client is typed as QdrantBase, but QdrantBase does not declare server_info, so type checkers can flag self._client.server_info() in this facade. Add the stub method to the base type or adjust the _client type so this access is visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/qdrant_client.py` around lines 180 - 192, The QdrantBase type
does not expose the server_info method used by the facade. Add a server_info
declaration to QdrantBase with the VersionInfo-or-None return type and the
existing best-effort contract, ensuring self._client.server_info() type-checks
without changing runtime behavior.

Comment on lines +324 to +335
def server_info(self) -> types.VersionInfo | None:
"""Return the Qdrant server's full ``VersionInfo`` model (with
``title``, ``version``, and ``commit``). Calls the REST ``/``
endpoint via the existing service API binding.

On any failure (timeout, connection refused, non-2xx, API
error), returns ``None`` rather than raising."""
try:
return self.openapi_client.service_api.root()
except Exception:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)qdrant_remote\.py$|async_qdrant_remote\.py$' || true

echo "== qdrant_remote outline relevant =="
ast-grep outline qdrant_client/qdrant_remote.py --match server_info --view expanded || true
ast-grep outline qdrant_client/qdrant_remote.py --match info --view expanded || true

echo "== server_info and info sections =="
sed -n '300,345p' qdrant_client/qdrant_remote.py
sed -n '2740,2805p' qdrant_client/qdrant_remote.py

echo "== prefer_grpc occurrences in qdrant_remote =="
rg -n "_prefer_grpc|prefer_grpc|grpc_root|HealthCheck|service_api\.root" qdrant_client/qdrant_remote.py

echo "== async equivalent if present =="
if [ -f qdrant_client/qdrant_async_remote.py ]; then
  sed -n '300,345p' qdrant_client/qdrant_async_remote.py
  sed -n '2740,2805p' qdrant_client/qdrant_async_remote.py
fi

echo "== imports and conversion references =="
rg -n "from .* GrpcToRest|class GrpcToRest|convert_health_check_reply|HealthCheckRequest|grpc" qdrant_client -g '*.py' | head -n 200

Repository: qdrant/qdrant-client

Length of output: 29152


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== search remote client constructors for prefer_grpc semantics =="
rg -n "prefer_grpc|_prefer_grpc" qdrant_client qdrant_client/**/*.py | head -n 240

echo "== inspect qdrant_client init signatures and client wrappers =="
rg -n "class QdrantClient|prefer_grpc|QdrantRemote\\(" qdrant_client -g '*.py' | head -n 240
sed -n '1,220p' qdrant_client/qdrant_client.py 2>/dev/null || true

Repository: qdrant/qdrant-client

Length of output: 24175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== async_qdrant_client server_info section =="
rg -n "def server_info|server_info\\(" qdrant_client/async_qdrant_client.py
sed -n '220,270p' qdrant_client/async_qdrant_client.py

echo "== async_qdrant_remote info/server_info section =="
sed -n '300,340p' qdrant_client/async_qdrant_remote.py
sed -n '2620,2690p' qdrant_client/async_qdrant_remote.py

echo "== read-only behavior probe for QdrantRemote path selection =="
python3 - <<'PY'
from pathlib import Path
p = Path('qdrant_client/qdrant_remote.py')
s = p.read_text()
idx_server_info = s.index('    def server_info(self)') 
idx_info = s.index('    def info(self) -> types.VersionInfo:')
server = s[idx_server_info:idx_info].splitlines()
info = []
for i, line in enumerate(s[idx_info:].splitlines()[:35], start=idx_info):
    info.append((i+1, line))
print("server_info contains _prefer_grpc:", any("_prefer_grpc" in line for line in server))
print("server_info REST-only path:", any("openapi_client.service_api.root()" in line for line in server))
print("info contains gRPC branch:", any("_prefer_grpc" in line for line in s[idx_info:idx_info+s[idx_info:].index("        version_info = self.rest.service_api.root()")].splitlines()))
PY

Repository: qdrant/qdrant-client

Length of output: 7487


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== precise server_info call in sync client =="
rg -n -C 8 "    def server_info" qdrant_client/qdrant_client.py

echo "== precise server_info call in async client =="
rg -n -C 8 "    async def server_info" qdrant_client/async_qdrant_client.py

echo "== precise server_info in qdrant_remote =="
sed -n '298,336p' qdrant_client/qdrant_remote.py

echo "== precise info and root conversions =="
sed -n '2766,2786p' qdrant_client/qdrant_remote.py
sed -n '2632,2644p' qdrant_client/qdrant_remote.py

Repository: qdrant/qdrant-client

Length of output: 4669


Use HealthCheck for server_info() when prefer_grpc=True.

QdrantRemote.info() uses gRPC HealthCheck via self.grpc_root when prefer_grpc=True, but QdrantRemote.server_info() always calls the REST openapi_client.service_api.root(). Clients with prefer_grpc=True can fail this path even though gRPC HealthCheck would succeed, then silently return None; mirror the info() branch here and apply the same async behavior consistently.

🧰 Tools
🪛 Ruff (0.16.0)

[warning] 333-333: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/qdrant_remote.py` around lines 324 - 335, Update
QdrantRemote.server_info() to use self.grpc_root.HealthCheck when
prefer_grpc=True, matching the branch used by QdrantRemote.info(), while
retaining the REST service_api.root() path otherwise. Preserve the method’s
None-on-failure behavior and mirror info()’s synchronous/asynchronous handling
consistently.

Comment thread tests/test_server_info.py
Comment on lines +29 to +33
def test_local_path_returns_synthetic(self):
client = QdrantClient(path="/tmp/qdrant_server_info_test")
result = client.server_info()
assert result is not None
assert result.title == "qdrant-client (local mode)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use tmp_path fixture instead of hardcoded /tmp paths.

Both tests use fixed /tmp/qdrant_server_info_test[_async] paths for local storage with no cleanup. Re-running the suite (or parallel workers reusing the same path) can hit a stale storage/lock-file from a previous run, causing flaky failures; the path is also non-portable outside POSIX systems.

🧹 Proposed fix using pytest's `tmp_path` fixture
-    def test_local_path_returns_synthetic(self):
-        client = QdrantClient(path="/tmp/qdrant_server_info_test")
+    def test_local_path_returns_synthetic(self, tmp_path):
+        client = QdrantClient(path=str(tmp_path / "qdrant_server_info_test"))
         result = client.server_info()
         assert result is not None
         assert result.title == "qdrant-client (local mode)"
-    async def test_local_path_returns_synthetic(self):
-        client = AsyncQdrantClient(path="/tmp/qdrant_server_info_test_async")
+    async def test_local_path_returns_synthetic(self, tmp_path):
+        client = AsyncQdrantClient(path=str(tmp_path / "qdrant_server_info_test_async"))
         result = await client.server_info()
         assert result is not None
         assert result.title == "qdrant-client (local mode)"

Also applies to: 73-77

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 29-29: Do not hardcode temporary file or directory names
Context: "/tmp/qdrant_server_info_test"
Note: [CWE-377] Insecure Temporary File.

(hardcoded-tmp-file)

🪛 Ruff (0.16.0)

[error] 30-30: Probable insecure usage of temporary file or directory: "/tmp/qdrant_server_info_test"

(S108)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_server_info.py` around lines 29 - 33, Update both local-storage
tests, including test_local_path_returns_synthetic and its async counterpart, to
accept pytest’s tmp_path fixture and pass a unique path derived from it to
QdrantClient instead of hardcoded /tmp paths. Preserve the existing assertions
and async behavior while ensuring each test uses isolated temporary storage.

Source: Linters/SAST tools

Comment on lines +36 to +101
python3 - "$ABSOLUTE_PROJECT_ROOT" <<'PY'
import sys, ast, pathlib

root = pathlib.Path(sys.argv[1])
targets = [
("qdrant_client/async_qdrant_client.py", """\
async def server_info(self) -> types.VersionInfo | None:
\"\"\"Return the Qdrant server's full ``VersionInfo`` model (with
``title``, ``version``, and ``commit``). For local mode, returns
a synthetic ``VersionInfo``. On any remote failure, returns
``None`` rather than raising \u2014 the model is best-effort.

The inner client may return a coroutine (remote mode) or a
model (local mode); await the coroutine when present.\"\"\"
if hasattr(self, \"_client\"):
result = self._client.server_info()
if inspect.iscoroutine(result):
result = await result
return result
return None
"""),
("qdrant_client/async_qdrant_remote.py", """\
async def server_info(self) -> types.VersionInfo | None:
\"\"\"Return the Qdrant server's full ``VersionInfo`` model (with
``title``, ``version``, and ``commit``). Awaits the REST ``/``
endpoint via the existing async service API binding.

On any failure (timeout, connection refused, non-2xx, API
error), returns ``None`` rather than raising.\"\"\"
try:
return await self.http.service_api.root()
except Exception:
return None
"""),
]

for rel, inject in targets:
p = root / rel
if not p.exists():
continue
text = p.read_text()
if "async def server_info" in text:
continue
# Find the close() method to insert after it.
tree = ast.parse(text)
close_node = None
for node in tree.body:
if isinstance(node, ast.ClassDef):
for item in node.body:
if (
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
and item.name == "close"
):
close_node = item
break
if close_node is not None:
break
if close_node is None:
print(f"regen: {rel}: close() not found; async def server_info NOT re-injected (manual fix required)")
sys.exit(1)
lines = text.splitlines(keepends=True)
insert_at = close_node.end_lineno
new_text = "".join(lines[:insert_at]) + "\n" + inject + "".join(lines[insert_at:])
p.write_text(new_text)
print(f"regen: injected async def server_info into {rel}")
PY

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Regeneration will drop import inspect, breaking AsyncQdrantClient.server_info() on next run.

The injected async_qdrant_client.py snippet (lines 42-56) references inspect.iscoroutine(result), but this script never re-injects import inspect into the file. Since client_generator.py excludes server_info from transformation, the freshly generated async_qdrant_client.py (produced by the mv at line 18, before this injection block runs) contains neither the method nor the import — qdrant_client.py (the sync source) doesn't import inspect either. On the very next regeneration, this will silently ship a server_info() that raises NameError: name 'inspect' is not defined on every call (for both local and remote clients), since that line runs unconditionally once hasattr(self, "_client") is true.

🔧 Proposed fix: also ensure the import is present
 for rel, inject in targets:
     p = root / rel
     if not p.exists():
         continue
     text = p.read_text()
     if "async def server_info" in text:
         continue
+    if rel.endswith("async_qdrant_client.py") and "import inspect" not in text:
+        text = text.replace("import warnings\n", "import inspect\nimport warnings\n", 1)
     # Find the close() method to insert after it.
-    tree = ast.parse(text)
+    tree = ast.parse(text)

(Adjust the anchor import line to whatever stable top-level import already exists in the generated file, or insert before the first import/from statement found via ast.parse.)

🔍 Verification script
#!/bin/bash
echo "--- Does sync qdrant_client.py import inspect? ---"
grep -n '^import inspect' qdrant_client/qdrant_client.py && echo FOUND || echo "NOT FOUND (confirms gap)"
echo "--- Current async_qdrant_client.py import block ---"
sed -n '1,26p' qdrant_client/async_qdrant_client.py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/generate_async_client.sh` around lines 36 - 101, Update the
regeneration script’s injection logic for AsyncQdrantClient.server_info so the
generated async_qdrant_client.py also contains the inspect import required by
inspect.iscoroutine. Add the import through a stable existing top-level import
anchor or locate the first import/from statement with the parsed AST, while
preserving the existing method injection behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant