From 7646e84d3f671db27f1657684c20ae9150986ffb Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Tue, 27 Oct 2026 19:57:53 +0800 Subject: [PATCH 1/2] feat(python): support path-like inputs and improve errors --- docs/docs/index/usage.md | 8 +- python_bindings/__init__.py | 15 +- python_bindings/__init__.pyi | 45 +++-- python_bindings/bindings_common.hpp | 100 ++++++++++- python_bindings/hnsw_bindings.cpp | 51 +++--- python_bindings/ivf_bindings.cpp | 54 +++--- python_bindings/kmeans_bindings.cpp | 4 +- python_bindings/symqg_bindings.cpp | 28 +-- tests/python/test_hnsw.py | 19 +- tests/python/test_input_boundaries.py | 250 ++++++++++++++++++++++++++ tests/python/test_ivf.py | 29 ++- tests/python/test_qgkmeans.py | 20 ++- tests/python/test_symqg.py | 19 +- tests/python/typing/public_api.py | 27 +++ 14 files changed, 569 insertions(+), 100 deletions(-) create mode 100644 tests/python/test_input_boundaries.py diff --git a/docs/docs/index/usage.md b/docs/docs/index/usage.md index 0eb2f34e..1f0a543b 100644 --- a/docs/docs/index/usage.md +++ b/docs/docs/index/usage.md @@ -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, including surrogate-escaped names; 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 diff --git a/python_bindings/__init__.py b/python_bindings/__init__.py index 059a94c1..2d454aa3 100644 --- a/python_bindings/__init__.py +++ b/python_bindings/__init__.py @@ -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): diff --git a/python_bindings/__init__.pyi b/python_bindings/__init__.pyi index cbe23df8..16fd24ae 100644 --- a/python_bindings/__init__.pyi +++ b/python_bindings/__init__.pyi @@ -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 @@ -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. @@ -307,13 +310,14 @@ 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 ----- @@ -321,13 +325,14 @@ class IvfIndex: """ @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 ------- @@ -570,13 +575,14 @@ 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 ----- @@ -584,13 +590,14 @@ class HnswIndex: """ @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 ------- @@ -786,13 +793,14 @@ 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 ----- @@ -800,13 +808,14 @@ class SymqgIndex: """ @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 ------- diff --git a/python_bindings/bindings_common.hpp b/python_bindings/bindings_common.hpp index de30e833..e9d05cd0 100644 --- a/python_bindings/bindings_common.hpp +++ b/python_bindings/bindings_common.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -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(value) + : py::reinterpret_steal(PyOS_FSPath(value.ptr())); + if (!path) { + throw py::error_already_set(); + } + if (PyUnicode_Check(path.ptr())) { +#if defined(_WIN32) + path = py::reinterpret_steal(PyUnicode_AsUTF8String(path.ptr())); +#else + path = py::reinterpret_steal(PyUnicode_EncodeFSDefault(path.ptr())); +#endif + if (!path) { + throw py::error_already_set(); + } + } + auto result = py::cast(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(); + if (py::isinstance(value)) { + result += + " with dtype " + + py::str(py::reinterpret_borrow(value).dtype()).cast(); + } + 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(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; @@ -94,10 +155,16 @@ inline py::array_t ensure_2d_array ) { auto array = py::array_t::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()).cast() + "; 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. @@ -121,10 +188,16 @@ inline py::array_t ensure_1d_array ) { auto array = py::array_t::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()).cast() + "; 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; } @@ -137,11 +210,17 @@ inline py::array_t 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() + ); } return ensure_1d_array(value, name); } @@ -156,7 +235,8 @@ inline std::vector checked_cluster_ids( const auto checked_copy = [&](const auto& ids) { if (static_cast(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 result(rows); @@ -169,7 +249,11 @@ inline std::vector checked_cluster_ids( const int64_t id = values[i]; if (id < 0 || static_cast(id) >= clusters || static_cast(id) > std::numeric_limits::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(id); } diff --git a/python_bindings/hnsw_bindings.cpp b/python_bindings/hnsw_bindings.cpp index b2134586..d070b911 100644 --- a/python_bindings/hnsw_bindings.cpp +++ b/python_bindings/hnsw_bindings.cpp @@ -58,18 +58,22 @@ class HnswIndex { auto data_array = ensure_2d_array(data, "data"); auto centroids_array = ensure_2d_array(centroids, "centroids"); - if (static_cast(data_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("data dimension does not match index dim"); - } + ensure_dimension( + data_array, index_->dimension(), "data dimension does not match index dim" + ); if (data_array.shape(0) == 0 || static_cast(data_array.shape(0)) > index_->max_elements()) { throw std::invalid_argument( - "number of data rows must be between 1 and max_elements" + "number of data rows must be between 1 and max_elements; expected 1 to " + + std::to_string(index_->max_elements()) + ", got shape " + + array_shape(data_array) ); } - if (static_cast(centroids_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("centroid dimension does not match index dim"); - } + ensure_dimension( + centroids_array, + index_->dimension(), + "centroid dimension does not match index dim" + ); const size_t num_clusters = static_cast(centroids_array.shape(0)); if (num_clusters == 0) { throw std::invalid_argument("at least one centroid is required"); @@ -110,9 +114,9 @@ class HnswIndex { if (!built_) { throw std::runtime_error("HnswIndex must be built or loaded before add"); } - if (static_cast(data_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("data dimension does not match index dim"); - } + ensure_dimension( + data_array, index_->dimension(), "data dimension does not match index dim" + ); const auto rows = static_cast(data_array.shape(0)); std::vector assigned; @@ -185,9 +189,10 @@ class HnswIndex { if (!built_) { throw std::runtime_error("HnswIndex must be built or loaded before search"); } - if (index_->dimension() != 0 && - static_cast(query_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("query dimension does not match index dim"); + if (index_->dimension() != 0) { + ensure_dimension( + query_array, index_->dimension(), "query dimension does not match index dim" + ); } if (ef == 0) { ef = std::max(k, 10); @@ -231,21 +236,23 @@ class HnswIndex { return py::make_tuple(ids, dists); } - void save(const std::string& path) const { + void save(py::handle path) const { + const auto filename = path_string(path); const auto operation = access_.read(); if (!built_) { throw std::runtime_error("HnswIndex must be built or loaded before save"); } py::gil_scoped_release release; - index_->save(path.c_str()); + index_->save(filename.c_str()); } - static HnswIndex load(const std::string& path) { + static HnswIndex load(py::handle path) { + const auto filename = path_string(path); HnswIndex wrapper; wrapper.index_ = std::make_unique(); { py::gil_scoped_release release; - wrapper.index_->load(path.c_str()); + wrapper.index_->load(filename.c_str()); } wrapper.built_ = true; return wrapper; @@ -489,8 +496,9 @@ Removal does not reclaim storage and cannot be undone.)doc" 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 ----- @@ -504,8 +512,9 @@ The saved state includes the rotation used to build the index.)doc" 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 ------- diff --git a/python_bindings/ivf_bindings.cpp b/python_bindings/ivf_bindings.cpp index ed20c0dd..ef6d28f8 100644 --- a/python_bindings/ivf_bindings.cpp +++ b/python_bindings/ivf_bindings.cpp @@ -90,20 +90,26 @@ class IvfIndex { auto data_array = ensure_2d_array(data, "data"); auto centroids_array = ensure_2d_array(centroids, "centroids"); - if (static_cast(data_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("data dimension does not match index dim"); - } + ensure_dimension( + data_array, index_->dimension(), "data dimension does not match index dim" + ); if (static_cast(data_array.shape(0)) != index_->max_elements()) { throw std::invalid_argument( - "number of data rows must match index max_elements" + "number of data rows must match index max_elements; expected " + + std::to_string(index_->max_elements()) + ", got shape " + + array_shape(data_array) ); } - if (static_cast(centroids_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("centroid dimension does not match index dim"); - } + ensure_dimension( + centroids_array, + index_->dimension(), + "centroid dimension does not match index dim" + ); if (static_cast(centroids_array.shape(0)) != index_->num_clusters()) { throw std::invalid_argument( - "number of centroid rows must match index num_clusters" + "number of centroid rows must match index num_clusters; expected " + + std::to_string(index_->num_clusters()) + ", got shape " + + array_shape(centroids_array) ); } auto cluster_ids_vec = checked_cluster_ids( @@ -133,9 +139,9 @@ class IvfIndex { if (!built_) { throw std::runtime_error("IvfIndex must be built or loaded before add"); } - if (static_cast(data_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("data dimension does not match index dim"); - } + ensure_dimension( + data_array, index_->dimension(), "data dimension does not match index dim" + ); const auto rows = static_cast(data_array.shape(0)); std::vector assigned; @@ -216,9 +222,9 @@ class IvfIndex { if (!built_) { throw std::runtime_error("IvfIndex must be built or loaded before search"); } - if (static_cast(query_array.shape(1)) != index_->dimension()) { - throw std::invalid_argument("query dimension does not match index dim"); - } + ensure_dimension( + query_array, index_->dimension(), "query dimension does not match index dim" + ); if (k == 0 || k > index_->max_elements()) { throw std::invalid_argument("k must be between 1 and max_elements"); } @@ -256,21 +262,23 @@ class IvfIndex { return py::make_tuple(ids, dists); } - void save(const std::string& path) const { + void save(py::handle path) const { + const auto filename = path_string(path); const auto operation = access_.read(); if (!built_) { throw std::runtime_error("IvfIndex must be built or loaded before save"); } py::gil_scoped_release release; - index_->save(path.c_str()); + index_->save(filename.c_str()); } - static IvfIndex load(const std::string& path) { + static IvfIndex load(py::handle path) { + const auto filename = path_string(path); IvfIndex wrapper; wrapper.index_ = std::make_unique(); { py::gil_scoped_release release; - wrapper.index_->load(path.c_str()); + wrapper.index_->load(filename.c_str()); } wrapper.built_ = true; return wrapper; @@ -564,8 +572,9 @@ runtime, not by static type checkers.)doc" 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 ----- @@ -579,8 +588,9 @@ IVF with HNSW centroid routing also writes its required sidecar file.)doc" 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 ------- diff --git a/python_bindings/kmeans_bindings.cpp b/python_bindings/kmeans_bindings.cpp index 53cc1d88..25410985 100644 --- a/python_bindings/kmeans_bindings.cpp +++ b/python_bindings/kmeans_bindings.cpp @@ -47,9 +47,7 @@ py::array_t centroids_to_array(const Clustering& clustering, const char* template double train(Clustering& clustering, py::handle x) { auto x_array = ensure_2d_array(x, "x"); - if (static_cast(x_array.shape(1)) != clustering.d) { - throw std::invalid_argument("x dimension does not match d"); - } + ensure_dimension(x_array, clustering.d, "x dimension does not match d"); { py::gil_scoped_release release; diff --git a/python_bindings/symqg_bindings.cpp b/python_bindings/symqg_bindings.cpp index 910b4dd4..9b5a6dff 100644 --- a/python_bindings/symqg_bindings.cpp +++ b/python_bindings/symqg_bindings.cpp @@ -61,9 +61,7 @@ class SymqgIndex { throw std::invalid_argument("ef_construction must be positive and fit uint32"); } auto data_array = ensure_2d_array(data, "data"); - if (static_cast(data_array.shape(1)) != dim_) { - throw std::invalid_argument("data dimension does not match index dim"); - } + ensure_dimension(data_array, dim_, "data dimension does not match index dim"); const size_t num_points = static_cast(data_array.shape(0)); auto index = std::make_unique>( @@ -108,9 +106,7 @@ class SymqgIndex { if (ef < k) { throw std::invalid_argument("ef must be at least k"); } - if (static_cast(query_array.shape(1)) != dim_) { - throw std::invalid_argument("query dimension does not match index dim"); - } + ensure_dimension(query_array, dim_, "query dimension does not match index dim"); const size_t nq = static_cast(query_array.shape(0)); const auto shape = std::vector{ @@ -136,21 +132,23 @@ class SymqgIndex { return py::make_tuple(ids, dists); } - void save(const std::string& path) const { + void save(py::handle path) const { + const auto filename = path_string(path); const auto operation = access_.read(); if (!built_) { throw std::runtime_error("SymqgIndex must be built before save"); } py::gil_scoped_release release; - index_->save(path.c_str()); + index_->save(filename.c_str()); } - static SymqgIndex load(const std::string& path) { + static SymqgIndex load(py::handle path) { + const auto filename = path_string(path); SymqgIndex wrapper; wrapper.index_ = std::make_unique>(); { py::gil_scoped_release release; - wrapper.index_->load(path.c_str()); + wrapper.index_->load(filename.c_str()); } wrapper.dim_ = wrapper.index_->dimension(); wrapper.max_degree_ = wrapper.index_->degree_bound(); @@ -360,8 +358,9 @@ checked at runtime, not by static type checkers.)doc" 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 ----- @@ -375,8 +374,9 @@ The saved state includes the rotation used to build the index.)doc" 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 ------- diff --git a/tests/python/test_hnsw.py b/tests/python/test_hnsw.py index f92e23cb..fc8efb72 100644 --- a/tests/python/test_hnsw.py +++ b/tests/python/test_hnsw.py @@ -1,5 +1,6 @@ """Tests for HnswIndex: construction, search, properties, error handling, save/load.""" +import re from pathlib import Path import numpy as np @@ -153,7 +154,14 @@ def test_wrong_data_dim_raises(clusters): idx = HnswIndex(DIM, N_VECTORS, M=8, ef_construction=50, nbits=4) centroids, cluster_ids = clusters bad_data = np.zeros((N_VECTORS, DIM + 1), dtype=np.float32) - with pytest.raises(ValueError, match="^data dimension does not match index dim$"): + with pytest.raises( + ValueError, + match="^" + + re.escape( + f"data dimension does not match index dim; expected last dimension {DIM}, got shape {bad_data.shape}" + ) + + "$", + ): idx.build(bad_data, centroids, cluster_ids) @@ -168,7 +176,14 @@ def test_out_of_range_cluster_id_raises(base_data, clusters): def test_wrong_query_dim_raises(built_hnsw): bad_queries = np.zeros((5, DIM + 1), dtype=np.float32) - with pytest.raises(ValueError, match="^query dimension does not match index dim$"): + with pytest.raises( + ValueError, + match="^" + + re.escape( + f"query dimension does not match index dim; expected last dimension {DIM}, got shape {bad_queries.shape}" + ) + + "$", + ): built_hnsw.search(bad_queries, k=1) diff --git a/tests/python/test_input_boundaries.py b/tests/python/test_input_boundaries.py new file mode 100644 index 00000000..32acf8eb --- /dev/null +++ b/tests/python/test_input_boundaries.py @@ -0,0 +1,250 @@ +"""Public input diagnostics and filesystem-path conversion contracts.""" + +import os +from pathlib import Path + +import numpy as np +import pytest +from rabitqlib import HnswIndex, IvfIndex, QGKMeans, RaBitQKMeans, SymqgIndex + + +class FilesystemPath: + def __init__(self, value): + self.value = value + self.calls = 0 + + def __fspath__(self): + self.calls += 1 + return self.value + + +@pytest.fixture(scope="module", params=["ivf", "ivf_hnsw", "hnsw", "symqg"]) +def built_index(request): + data = np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) + if request.param == "symqg": + index = SymqgIndex(64, 32) + index.build(data, 40) + else: + if request.param.startswith("ivf"): + initializer = "hnsw" if request.param == "ivf_hnsw" else "flat" + index = IvfIndex(64, 80, 1, 4, initializer=initializer) + else: + index = HnswIndex(64, 80, nbits=4) + index.build( + data, data.mean(axis=0, keepdims=True), np.zeros(80, dtype=np.uint32) + ) + return index, data[:2] + + +@pytest.mark.parametrize( + "kind", ["str", "path", "fspath_str", "bytes", "bytearray", "fspath_bytes"] +) +def test_path_roundtrip(built_index, tmp_path, kind): + index, queries = built_index + filename = tmp_path / "索引 café 🐇.index" + value = { + "str": str(filename), + "path": filename, + "fspath_str": FilesystemPath(str(filename)), + "bytes": os.fsencode(filename), + "bytearray": bytearray(os.fsencode(filename)), + "fspath_bytes": FilesystemPath(os.fsencode(filename)), + }[kind] + index.save(value) + assert filename.is_file() + if isinstance(value, FilesystemPath): + assert value.calls == 1 + if isinstance(index, IvfIndex) and index.initializer == "hnsw": + assert Path(str(filename) + ".hnsw").is_file() + restored = type(index).load(value) + if isinstance(value, FilesystemPath): + assert value.calls == 2 + expected = index.search(queries, 3, 32) + actual = restored.search(queries, 3, 32) + np.testing.assert_array_equal(actual[0], expected[0]) + np.testing.assert_allclose(actual[1], expected[1], rtol=1e-6, atol=1e-6) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX native path bytes") +def test_path_preserves_undecodable_bytes(built_index, tmp_path): + index, queries = built_index + raw = os.fsencode(tmp_path) + b"/index-\xff.bin" + filename = Path(os.fsdecode(raw)) + index.save(filename) + assert os.path.isfile(raw) + restored = type(index).load(filename) + np.testing.assert_array_equal( + restored.search(queries, 3, 32)[0], index.search(queries, 3, 32)[0] + ) + if isinstance(index, IvfIndex) and index.initializer == "hnsw": + assert os.path.isfile(raw + b".hnsw") + + +@pytest.mark.parametrize("built_index", ["ivf_hnsw"], indirect=True) +@pytest.mark.parametrize( + "value", [object(), FilesystemPath(123), FilesystemPath(bytearray(b"index"))] +) +def test_invalid_paths_raise_type_error(built_index, value): + index, _ = built_index + with pytest.raises(TypeError): + index.save(value) + with pytest.raises(TypeError): + type(index).load(value) + + +@pytest.mark.parametrize( + "built_index,kind", + [ + ("ivf_hnsw", "str"), + ("ivf_hnsw", "bytes"), + ("ivf_hnsw", "bytearray"), + ("ivf_hnsw", "pathlike"), + ("hnsw", "str"), + ("symqg", "str"), + ], + indirect=["built_index"], +) +def test_embedded_null_does_not_truncate_path(built_index, tmp_path, kind): + index, _ = built_index + filename = tmp_path / "untouched.index" + filename.write_bytes(b"unchanged") + value = str(filename) + "\0suffix" + if kind == "bytes": + value = os.fsencode(value) + elif kind == "bytearray": + value = bytearray(os.fsencode(value)) + elif kind == "pathlike": + value = FilesystemPath(value) + for operation in (index.save, type(index).load): + with pytest.raises( + ValueError, match="^path must not contain embedded null bytes$" + ): + operation(value) + assert filename.read_bytes() == b"unchanged" + assert not Path(str(filename) + ".hnsw").exists() + + +@pytest.mark.parametrize("built_index", ["ivf_hnsw", "hnsw", "symqg"], indirect=True) +def test_fspath_exception_is_preserved(built_index): + class BrokenPath: + def __fspath__(self): + raise RuntimeError("fspath failed") + + index, _ = built_index + for operation in (index.save, type(index).load): + with pytest.raises(RuntimeError, match="^fspath failed$"): + operation(BrokenPath()) + + +@pytest.mark.parametrize("model", [QGKMeans, RaBitQKMeans]) +def test_unknown_keyword_names_public_constructor(model): + with pytest.raises(AttributeError) as error: + model(64, 40, niterr=2) + assert ( + str(error.value) + == f"{model.__name__}() got an unexpected keyword argument 'niterr'" + ) + + +@pytest.mark.parametrize("model", [QGKMeans, RaBitQKMeans]) +@pytest.mark.parametrize("value", ["two", -1]) +def test_keyword_conversion_names_public_parameter(model, value): + with pytest.raises(TypeError) as error: + model(64, 40, niter=value) + assert ( + str(error.value) + == f"{model.__name__}() could not convert parameter 'niter' from {type(value).__name__}" + ) + + +@pytest.mark.parametrize("built_index", ["ivf_hnsw"], indirect=True) +def test_integer_id_dtype_diagnostic(built_index): + index, _ = built_index + assert isinstance(index, IvfIndex) + with pytest.raises(ValueError) as error: + index.remove(np.array([1.5], dtype=np.float32)) # type: ignore[arg-type] + assert str(error.value) == "ids must contain integers; got dtype float32" + + +@pytest.mark.parametrize("built_index", ["ivf_hnsw"], indirect=True) +@pytest.mark.parametrize("shape", [(), (64,), (2, 1, 64)]) +def test_query_rank_diagnostics(built_index, shape): + index, _ = built_index + with pytest.raises(ValueError) as error: + index.search(np.zeros(shape, dtype=np.float32), 1, 32) + assert str(error.value) == f"queries must be a 2D NumPy array; got shape {shape}" + + +@pytest.mark.parametrize("built_index", ["ivf_hnsw", "hnsw", "symqg"], indirect=True) +def test_path_callback_runs_before_access_guard(built_index, tmp_path): + index, queries = built_index + + class UpdatingPath: + def __fspath__(self): + if isinstance(index, IvfIndex): + index.reserve(0, index.num_stored_points) + elif isinstance(index, HnswIndex): + index.resize(index.max_elements) + else: + data = ( + np.random.default_rng(42).normal(size=(80, 64)).astype(np.float32) + ) + index.build(data, 40) + return str(tmp_path / "callback.index") + + index.save(UpdatingPath()) + expected = index.search(queries, 3, 32) + restored = type(index).load(tmp_path / "callback.index") + np.testing.assert_array_equal(restored.search(queries, 3, 32)[0], expected[0]) + + +@pytest.mark.parametrize("index_kind,rows", [("ivf", 79), ("hnsw", 81)]) +def test_build_row_count_diagnostics(index_kind, rows): + if index_kind == "ivf": + index = IvfIndex(64, 80, 1, 4) + expected = "number of data rows must match index max_elements; expected 80, got shape (79, 64)" + else: + index = HnswIndex(64, 80) + expected = "number of data rows must be between 1 and max_elements; expected 1 to 80, got shape (81, 64)" + with pytest.raises(ValueError) as error: + index.build( + np.zeros((rows, 64)), np.zeros((1, 64)), np.zeros(rows, dtype=np.uint32) + ) + assert str(error.value) == expected + + +@pytest.mark.parametrize("index_kind,dtype", [("ivf", np.uint32), ("hnsw", np.int64)]) +def test_cluster_id_error_identifies_position( + index_kind, dtype: type[np.int64] | type[np.uint32] +): + if index_kind == "ivf": + index = IvfIndex(64, 80, 1, 4) + prefix = "cluster_ids must be in [0, num_clusters)" + else: + index = HnswIndex(64, 80) + prefix = "cluster_ids contains an out-of-range value" + ids = np.zeros(80, dtype=dtype) + ids[-1] = 1 + with pytest.raises(ValueError) as error: + index.build(np.zeros((80, 64)), np.zeros((1, 64)), ids) + assert str(error.value) == prefix + "; expected [0, 1), invalid entry at index 79" + + +@pytest.mark.parametrize("built_index", ["ivf_hnsw"], indirect=True) +@pytest.mark.parametrize( + "value,description", + [ + (np.array([["bad"]]), "ndarray with dtype None: ivf.remove([1.5]) # type: ignore[list-item] hnsw.remove(bool_ids) # type: ignore[arg-type] ivf.remove(string_ids) # type: ignore[arg-type] + + +def check_paths( + ivf: IvfIndex, + hnsw: HnswIndex, + graph: SymqgIndex, + path: Path, + text_path: PathLike[str], + bytes_path: PathLike[bytes], +) -> None: + for filename in ( + path, + text_path, + bytes_path, + "index.bin", + b"index.bin", + bytearray(b"index.bin"), + ): + for index in (ivf, hnsw, graph): + index.save(filename) + assert_type(IvfIndex.load(filename), IvfIndex) + assert_type(HnswIndex.load(filename), HnswIndex) + assert_type(SymqgIndex.load(filename), SymqgIndex) + ivf.save(42) # type: ignore[arg-type] + HnswIndex.load(object()) # type: ignore[arg-type] From 0d9a1403728f8384b4fbc5a8091bd18656def4ca Mon Sep 17 00:00:00 2001 From: gouyt13clear Date: Tue, 27 Oct 2026 20:15:48 +0800 Subject: [PATCH 2/2] test(python): probe filesystem support for byte filenames --- docs/docs/index/usage.md | 6 +++--- tests/python/test_input_boundaries.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/docs/index/usage.md b/docs/docs/index/usage.md index 1f0a543b..40c9e0d2 100644 --- a/docs/docs/index/usage.md +++ b/docs/docs/index/usage.md @@ -132,9 +132,9 @@ before starting the program. 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, including surrogate-escaped names; Windows byte paths use UTF-8. Embedded -null characters are rejected before file access. IVF HNSW sidecars use the same -path conversion. +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 diff --git a/tests/python/test_input_boundaries.py b/tests/python/test_input_boundaries.py index 32acf8eb..d04cd1d7 100644 --- a/tests/python/test_input_boundaries.py +++ b/tests/python/test_input_boundaries.py @@ -1,5 +1,6 @@ """Public input diagnostics and filesystem-path conversion contracts.""" +import errno import os from pathlib import Path @@ -69,6 +70,16 @@ def test_path_roundtrip(built_index, tmp_path, kind): def test_path_preserves_undecodable_bytes(built_index, tmp_path): index, queries = built_index raw = os.fsencode(tmp_path) + b"/index-\xff.bin" + # POSIX does not guarantee that the filesystem accepts non-UTF-8 names. + # Probe independently so binding failures below cannot become skips. + try: + descriptor = os.open(raw, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except OSError as error: + if error.errno in (errno.EINVAL, errno.EILSEQ): + pytest.skip(f"filesystem rejects non-UTF-8 filenames (errno {error.errno})") + raise + os.close(descriptor) + os.unlink(raw) filename = Path(os.fsdecode(raw)) index.save(filename) assert os.path.isfile(raw)