Skip to content
Merged
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
8 changes: 6 additions & 2 deletions docs/docs/index/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,12 @@ Python index methods default to one thread; clustering defaults to `0`.
On Linux, CPU detection accounts for affinity and OpenMP binding. Apply binding
before starting the program.

Index save/load paths are UTF-8 strings on Windows and native path bytes on POSIX
in C++; Python paths are Unicode strings on all platforms.
C++ paths use UTF-8 on Windows and native path bytes on POSIX.
Python `save`/`load` accept strings, `pathlib.Path` and `os.PathLike` objects;
existing `bytes` and `bytearray` paths remain supported. POSIX paths preserve native
bytes, but non-UTF-8 filenames require filesystem support. Windows byte paths use
UTF-8. Embedded null characters are rejected before file access. IVF HNSW sidecars
use the same path conversion.

## Index file compatibility

Expand Down
15 changes: 13 additions & 2 deletions python_bindings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,19 @@ def __init__(self, d, k, **kwargs):
self.k = int(k)
self.cp = self._parameters_type()
for name, value in kwargs.items():
getattr(self.cp, name)
setattr(self.cp, name, value)
try:
getattr(self.cp, name)
except AttributeError:
raise AttributeError(
f"{type(self).__name__}() got an unexpected keyword argument {name!r}"
) from None
try:
setattr(self.cp, name, value)
except TypeError as error:
raise TypeError(
f"{type(self).__name__}() could not convert parameter {name!r} "
f"from {type(value).__name__}"
) from error
self._impl = None

def train(self, x):
Expand Down
45 changes: 27 additions & 18 deletions python_bindings/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Array shapes and numeric ranges are documented runtime constraints.
"""

from collections.abc import Sequence
from os import PathLike
from typing import Any, ClassVar, Literal, SupportsIndex, TypeAlias

import numpy as np
Expand All @@ -22,6 +23,8 @@ __all__ = [
"SymqgIndex",
]

_Path: TypeAlias = str | bytes | bytearray | PathLike[str] | PathLike[bytes]

_Metric: TypeAlias = Literal["l2", "ip", "innerproduct"]
_Initializer: TypeAlias = Literal["auto", "flat", "flat_rabitq", "hnsw"]
# Rank, ID ranges and Python bool (an int subtype) are validated at runtime.
Expand Down Expand Up @@ -307,27 +310,29 @@ class IvfIndex:
runtime, not by static type checkers.
"""

def save(self, path: str) -> None:
def save(self, path: _Path) -> None:
"""Save a built index to disk.

Parameters
----------
path : str
Index file path. Pass str(path) for pathlib.Path objects.
path : str, bytes, bytearray, or os.PathLike
Index file path. PathLike objects are resolved via __fspath__.
Byte paths use native bytes on POSIX and UTF-8 on Windows.

Notes
-----
IVF with HNSW centroid routing also writes its required sidecar file.
"""

@staticmethod
def load(path: str) -> IvfIndex:
def load(path: _Path) -> IvfIndex:
"""Load an index and its saved rotation from disk.

Parameters
----------
path : str
Index file path. Pass str(path) for pathlib.Path objects.
path : str, bytes, bytearray, or os.PathLike
Index file path. PathLike objects are resolved via __fspath__.
Byte paths use native bytes on POSIX and UTF-8 on Windows.

Returns
-------
Expand Down Expand Up @@ -570,27 +575,29 @@ class HnswIndex:
runtime, not by static type checkers.
"""

def save(self, path: str) -> None:
def save(self, path: _Path) -> None:
"""Save a built index to disk.

Parameters
----------
path : str
Index file path. Pass str(path) for pathlib.Path objects.
path : str, bytes, bytearray, or os.PathLike
Index file path. PathLike objects are resolved via __fspath__.
Byte paths use native bytes on POSIX and UTF-8 on Windows.

Notes
-----
The saved state includes the rotation used to build the index.
"""

@staticmethod
def load(path: str) -> HnswIndex:
def load(path: _Path) -> HnswIndex:
"""Load an index and its saved rotation from disk.

Parameters
----------
path : str
Index file path. Pass str(path) for pathlib.Path objects.
path : str, bytes, bytearray, or os.PathLike
Index file path. PathLike objects are resolved via __fspath__.
Byte paths use native bytes on POSIX and UTF-8 on Windows.

Returns
-------
Expand Down Expand Up @@ -786,27 +793,29 @@ class SymqgIndex:
checked at runtime, not by static type checkers.
"""

def save(self, path: str) -> None:
def save(self, path: _Path) -> None:
"""Save a built index to disk.

Parameters
----------
path : str
Index file path. Pass str(path) for pathlib.Path objects.
path : str, bytes, bytearray, or os.PathLike
Index file path. PathLike objects are resolved via __fspath__.
Byte paths use native bytes on POSIX and UTF-8 on Windows.

Notes
-----
The saved state includes the rotation used to build the index.
"""

@staticmethod
def load(path: str) -> SymqgIndex:
def load(path: _Path) -> SymqgIndex:
"""Load an index and its saved rotation from disk.

Parameters
----------
path : str
Index file path. Pass str(path) for pathlib.Path objects.
path : str, bytes, bytearray, or os.PathLike
Index file path. PathLike objects are resolved via __fspath__.
Byte paths use native bytes on POSIX and UTF-8 on Windows.

Returns
-------
Expand Down
100 changes: 92 additions & 8 deletions python_bindings/bindings_common.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include <pybind11/cast.h>
#include <pybind11/numpy.h>
#include <pybind11/pytypes.h>

Expand Down Expand Up @@ -64,6 +65,66 @@ class IndexAccess {
[[nodiscard]] Guard write() { return Guard(*this, true); }
};

// Resolve Python callbacks before index guards and own bytes across GIL release.
inline std::string path_string(py::handle value) {
// The old std::string caster also accepted bytearray directly.
auto path = PyByteArray_Check(value.ptr())
? py::reinterpret_borrow<py::object>(value)
: py::reinterpret_steal<py::object>(PyOS_FSPath(value.ptr()));
if (!path) {
throw py::error_already_set();
}
if (PyUnicode_Check(path.ptr())) {
#if defined(_WIN32)
path = py::reinterpret_steal<py::object>(PyUnicode_AsUTF8String(path.ptr()));
#else
path = py::reinterpret_steal<py::object>(PyUnicode_EncodeFSDefault(path.ptr()));
#endif
if (!path) {
throw py::error_already_set();
}
}
auto result = py::cast<std::string>(path);
if (result.find('\0') != std::string::npos) {
throw std::invalid_argument("path must not contain embedded null bytes");
}
return result;
}

inline std::string array_shape(const py::array& array) {
std::string result = "(";
for (py::ssize_t axis = 0; axis < array.ndim(); ++axis) {
if (axis != 0) {
result += ", ";
}
result += std::to_string(array.shape(axis));
}
if (array.ndim() == 1) {
result += ',';
}
return result + ')';
}

inline std::string array_input_type(py::handle value) {
auto result = py::str(py::type::of(value).attr("__name__")).cast<std::string>();
if (py::isinstance<py::array>(value)) {
result +=
" with dtype " +
py::str(py::reinterpret_borrow<py::array>(value).dtype()).cast<std::string>();
}
return result;
}

// Call only after rank validation. Formatting is confined to the error path.
inline void ensure_dimension(const py::array& array, size_t expected, const char* message) {
if (static_cast<size_t>(array.shape(1)) != expected) {
throw std::invalid_argument(
std::string(message) + "; expected last dimension " + std::to_string(expected) +
", got shape " + array_shape(array)
);
}
}

inline rabitqlib::MetricType metric_from_string(const std::string& metric) {
if (metric == "l2") {
return rabitqlib::METRIC_L2;
Expand Down Expand Up @@ -94,10 +155,16 @@ inline py::array_t<T, py::array::c_style | py::array::forcecast> ensure_2d_array
) {
auto array = py::array_t<T, py::array::c_style | py::array::forcecast>::ensure(value);
if (!array) {
throw std::invalid_argument(std::string(name) + " must be a NumPy array");
throw std::invalid_argument(
std::string(name) + " must be convertible to a NumPy array of dtype " +
py::str(py::dtype::of<T>()).cast<std::string>() + "; got " +
array_input_type(value)
);
}
if (array.ndim() != 2) {
throw std::invalid_argument(std::string(name) + " must be a 2D NumPy array");
throw std::invalid_argument(
std::string(name) + " must be a 2D NumPy array; got shape " + array_shape(array)
);
}
// C-contiguous NumPy views may start at an unaligned byte offset. Native
// scalar accesses require alignof(T), independently of SIMD load alignment.
Expand All @@ -121,10 +188,16 @@ inline py::array_t<T, py::array::c_style | py::array::forcecast> ensure_1d_array
) {
auto array = py::array_t<T, py::array::c_style | py::array::forcecast>::ensure(value);
if (!array) {
throw std::invalid_argument(std::string(name) + " must be a NumPy array");
throw std::invalid_argument(
std::string(name) + " must be convertible to a NumPy array of dtype " +
py::str(py::dtype::of<T>()).cast<std::string>() + "; got " +
array_input_type(value)
);
}
if (array.ndim() != 1) {
throw std::invalid_argument(std::string(name) + " must be a 1D NumPy array");
throw std::invalid_argument(
std::string(name) + " must be a 1D NumPy array; got shape " + array_shape(array)
);
}
return array;
}
Expand All @@ -137,11 +210,17 @@ inline py::array_t<int64_t, py::array::c_style | py::array::forcecast>
ensure_1d_integer_array(py::handle value, const char* name) {
auto typed = py::array::ensure(value);
if (!typed) {
throw std::invalid_argument(std::string(name) + " must be a NumPy array");
throw std::invalid_argument(
std::string(name) + " must be convertible to an integer NumPy array; got " +
array_input_type(value)
);
}
const char kind = typed.dtype().kind();
if (typed.size() > 0 && kind != 'i' && kind != 'u') {
throw std::invalid_argument(std::string(name) + " must contain integers");
throw std::invalid_argument(
std::string(name) + " must contain integers; got dtype " +
py::str(typed.dtype()).cast<std::string>()
);
}
return ensure_1d_array<int64_t>(value, name);
}
Expand All @@ -156,7 +235,8 @@ inline std::vector<PID> checked_cluster_ids(
const auto checked_copy = [&](const auto& ids) {
if (static_cast<size_t>(ids.shape(0)) != rows) {
throw std::invalid_argument(
"cluster_ids length must match number of rows in data"
"cluster_ids length must match number of rows in data; expected " +
std::to_string(rows) + " IDs, got " + std::to_string(ids.shape(0))
);
}
std::vector<PID> result(rows);
Expand All @@ -169,7 +249,11 @@ inline std::vector<PID> checked_cluster_ids(
const int64_t id = values[i];
if (id < 0 || static_cast<uint64_t>(id) >= clusters ||
static_cast<uint64_t>(id) > std::numeric_limits<PID>::max()) {
throw std::invalid_argument(range_error);
throw std::invalid_argument(
std::string(range_error) + "; expected [0, " +
std::to_string(clusters) + "), invalid entry at index " +
std::to_string(i)
);
}
result[i] = static_cast<PID>(id);
}
Expand Down
Loading
Loading