diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..cfe256f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,73 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: Lint (ruff)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: pip install ruff
+ - run: ruff check DeepImageSearch tests scripts
+
+ test:
+ name: Test (${{ matrix.os }}, py${{ matrix.python-version }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+
+ - name: Install CPU-only torch
+ # The default torch wheel pulls CUDA on Linux; the CPU index keeps CI small and fast.
+ run: pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
+ if: runner.os == 'Linux'
+
+ - name: Install package and test dependencies
+ # The extras are installed so the Chroma/Qdrant/LangChain/MCP suites
+ # actually run instead of skipping. Postgres is covered with a fake
+ # driver, so no server is needed.
+ run: pip install -e ".[dev,chroma,qdrant,langchain,mcp,llm]" pytest-cov
+
+ - name: Run tests
+ run: pytest --cov=DeepImageSearch --cov-report=xml --cov-report=term-missing --cov-fail-under=90
+
+ - name: Upload coverage
+ uses: actions/upload-artifact@v4
+ if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
+ with:
+ name: coverage
+ path: coverage.xml
+
+ build:
+ name: Build distribution
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: pip install build twine
+ - run: python -m build
+ - name: Check package metadata
+ run: twine check dist/*
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..4376461
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,77 @@
+name: Release
+
+# Publishes to PyPI when a v* tag is pushed:
+# python scripts/bump_version.py 3.0.3
+# git commit -am "Bump version to 3.0.3" && git push
+# git tag v3.0.3 && git push --tags
+#
+# Authentication uses PyPI Trusted Publishing (OIDC) — no API token is stored
+# in GitHub secrets. One-time setup on PyPI, under the project's
+# "Publishing" settings, add a trusted publisher with:
+# Owner: TechyNilesh
+# Repository: DeepImageSearch
+# Workflow: release.yml
+# Environment: pypi
+# Then create a GitHub environment named "pypi" (Settings > Environments).
+
+on:
+ push:
+ tags: ["v*"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build and verify
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Check the tag matches the packaged version
+ # Publishing 3.0.2 from a v3.0.3 tag is unrecoverable — PyPI does not
+ # allow reuploading a version — so fail before the build instead.
+ if: startsWith(github.ref, 'refs/tags/v')
+ run: |
+ tag_version="${GITHUB_REF_NAME#v}"
+ pkg_version="$(python -c 'import tomllib;print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
+ echo "tag=$tag_version pyproject=$pkg_version"
+ if [ "$tag_version" != "$pkg_version" ]; then
+ echo "::error::Tag $GITHUB_REF_NAME does not match pyproject version $pkg_version. Run scripts/bump_version.py first."
+ exit 1
+ fi
+
+ - name: Install build tooling
+ run: pip install build twine
+
+ - name: Build distributions
+ run: python -m build
+
+ - name: Check package metadata
+ run: twine check dist/*
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist
+ path: dist/
+
+ publish:
+ name: Publish to PyPI
+ needs: build
+ runs-on: ubuntu-latest
+ environment: pypi
+ permissions:
+ id-token: write # required for trusted publishing
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: dist
+ path: dist/
+
+ - name: Publish
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..814db13
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,45 @@
+# Byte-compiled / cache
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Distribution / packaging
+build/
+dist/
+*.egg-info/
+*.egg
+.eggs/
+
+# Virtual environments
+.venv/
+venv/
+env/
+ENV/
+
+# Testing / coverage
+.pytest_cache/
+.coverage
+.coverage.*
+coverage.xml
+htmlcov/
+.tox/
+.nox/
+
+# Linting / type checking
+.ruff_cache/
+.mypy_cache/
+
+# Jupyter
+.ipynb_checkpoints/
+
+# Index artefacts written by DeepImageSearch at runtime
+metadata-files/
+*.faiss
+image_records.json
+
+# OS / editor cruft
+.DS_Store
+Thumbs.db
+*.swp
+.idea/
+.vscode/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b6c49a..f344cd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,49 @@ All notable changes to DeepImageSearch will be documented in this file.
---
+## [Unreleased]
+
+### Fixed
+- **`index_type="ivf"` segfaulted the interpreter on every use.** Two causes:
+ the FAISS quantizer was garbage-collected while the index still pointed at it,
+ and FAISS's OpenMP k-means clashed with torch's runtime on macOS.
+- **macOS:** searching no longer aborts with `OMP: Error #15`. `torch` and
+ `faiss-cpu` vendor separate copies of `libomp.dylib`; DeepImageSearch now sets
+ `KMP_DUPLICATE_LIB_OK` at import and pins FAISS to one thread when it detects
+ the duplicate (see `DEEPIMAGESEARCH_FAISS_THREADS`). Import `DeepImageSearch`
+ before `torch`/`faiss` for it to take effect.
+- **`create_langchain_tool()` raised `NameError` on every call** — a pydantic
+ field shadowed the `k` parameter inside the class body, so the LangChain
+ integration could never be constructed.
+- **MCP server failed to start against mcp >= 2.0**, which renamed `FastMCP` to
+ `MCPServer`; both import paths are now supported.
+- **Qdrant search failed against qdrant-client >= 1.12**, which removed
+ `QdrantClient.search()` in favour of `query_points()`.
+- `ChromaStore.add()` rejected vectors added without metadata, and returned
+ `None` instead of `{}` for their metadata on search.
+- `FAISSStore.delete()` no longer raises `ValueError` when every vector is deleted.
+- `PostgresMetadataStore.__del__` no longer raises `AttributeError` when the
+ connection was never established.
+- `DeepImageSearch.__version__` now matches the packaged version (was `3.0.0`).
+
+### Added
+- Test suite (`tests/`, 353 tests, 98% coverage) covering every module: loader,
+ metadata stores, all four vector stores, embedding backends, captioner, agent
+ tools, the SearchEngine facade, and the v2 `Search_Setup` shim. No model
+ downloads and no servers — heavy dependencies are faked or run in-process.
+- GitHub Actions CI: lint, tests on Python 3.10–3.13 across Linux/macOS/Windows
+ with all extras installed and a 90% coverage floor, and a packaging check.
+- `CONTRIBUTING.md`, `CITATION.cff`, `.gitignore`.
+- Release workflow publishing to PyPI from a `v*` tag via Trusted
+ Publishing (OIDC, no stored token), guarded by a tag/version match check.
+- `scripts/bump_version.py`, which updates all three places the version is
+ recorded and fails loudly if any of them stops matching.
+- `# SPDX-License-Identifier: MIT` header on every source file.
+
+### Removed
+- `setup.cfg`, which referenced a non-existent `README.rst`; packaging is
+ handled entirely by `pyproject.toml`.
+
## [3.0.0] - 2026-03-29
### Complete rewrite for the agentic RAG / LLM era.
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..b5f90e1
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,25 @@
+cff-version: 1.2.0
+title: "DeepImageSearch: AI-Based Image Search Engine"
+message: "If you use this software, please cite it using these metadata."
+type: software
+authors:
+ - family-names: Verma
+ given-names: Nilesh
+ email: me@nileshverma.com
+repository-code: "https://github.com/TechyNilesh/DeepImageSearch"
+url: "https://github.com/TechyNilesh/DeepImageSearch"
+abstract: >-
+ DeepImageSearch is an AI-powered image search engine with multimodal
+ embeddings (CLIP, SigLIP, EVA-CLIP, timm), text-to-image and hybrid
+ search, pluggable vector stores (FAISS, ChromaDB, Qdrant), LLM
+ captioning, and agentic RAG integration via MCP and LangChain tools.
+keywords:
+ - image search
+ - multimodal embeddings
+ - CLIP
+ - vector search
+ - information retrieval
+ - machine learning
+license: MIT
+version: 3.0.2
+date-released: "2026-04-01"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..caea9e4
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,112 @@
+# Contributing to DeepImageSearch
+
+Thanks for your interest in improving DeepImageSearch. Bug reports, documentation
+fixes, new backends, and test coverage are all welcome.
+
+## Development setup
+
+```bash
+git clone https://github.com/TechyNilesh/DeepImageSearch.git
+cd DeepImageSearch
+python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -e ".[dev]"
+```
+
+Add the extras you need for the backend you are working on, e.g.
+`pip install -e ".[dev,chroma,qdrant]"`.
+
+## Running the checks
+
+```bash
+pytest # full suite, no model downloads, ~1s
+pytest --cov=DeepImageSearch # with coverage
+ruff check DeepImageSearch tests # lint
+```
+
+CI runs the same three commands on Python 3.10–3.13 across Linux, macOS, and
+Windows. A pull request should be green on all of them.
+
+**macOS note:** `torch` and `faiss-cpu` each vendor their own copy of
+`libomp.dylib`. Loading both is unsupported and fails two ways: the process
+aborts with `OMP: Error #15`, or — once that abort is suppressed — FAISS's
+OpenMP-parallel routines segfault instead (IVF k-means training is the usual
+casualty). `DeepImageSearch/_openmp.py` handles both: it sets
+`KMP_DUPLICATE_LIB_OK` at import time and pins FAISS to a single thread when it
+detects two distinct runtimes.
+
+Two caveats. It only takes effect if `DeepImageSearch` is imported *before*
+`torch` or `faiss`. And single-threaded FAISS is slower on large indexes — the
+module's docstring documents how to remove the duplicate runtime for real, after
+which `DEEPIMAGESEARCH_FAISS_THREADS=0` restores full multithreading.
+
+## Writing tests
+
+Tests must not download model weights — CI runs twelve jobs and cannot afford
+it. Use the `embedding` fixture from `tests/conftest.py`, a deterministic
+stand-in for CLIP that supports both text and image queries, or monkeypatch the
+backend as `tests/test_embeddings.py` does. Anything genuinely requiring weights
+belongs behind an explicit opt-in marker.
+
+## Adding a backend
+
+The pluggable layers are all defined by abstract base classes:
+
+| Extension point | Base class | Existing implementations |
+|---|---|---|
+| Vector store | `DeepImageSearch/vectorstores/base.py` | FAISS, ChromaDB, Qdrant |
+| Metadata store | `DeepImageSearch/metadatastore/base.py` | JSON, PostgreSQL |
+| Embedding | `DeepImageSearch/core/embeddings.py` | CLIP, timm, custom callable |
+
+To add one:
+
+1. Subclass the relevant base and implement every abstract method.
+2. Import it in the package's `__init__.py` inside a `try/except ImportError`
+ so the dependency stays optional, and append it to `__all__`.
+3. Declare the dependency as a new extra in `pyproject.toml`.
+4. Add tests. The interface-conformance tests at the bottom of
+ `tests/test_vectorstores.py` and `tests/test_metadata_store.py` show the
+ pattern; skip the suite when the optional dependency is absent.
+5. Document it in `Documents/` and add a demo to `Demo/` if it changes usage.
+
+## Pull requests
+
+- Branch from `main` and keep each PR to one logical change.
+- Every new source file needs the `# SPDX-License-Identifier: MIT` header.
+- Update `CHANGELOG.md` under an "Unreleased" heading.
+- Version numbers live in `pyproject.toml`, `DeepImageSearch/__init__.py`, and
+ `CITATION.cff`; `tests/test_package.py` asserts all three agree.
+
+## Releasing (maintainers)
+
+Releases publish to PyPI from a `v*` tag via
+[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no API token
+lives in GitHub secrets.
+
+```bash
+python scripts/bump_version.py 3.0.3 # updates all three version locations
+# add a 3.0.3 section to CHANGELOG.md
+git commit -am "Bump version to 3.0.3"
+git tag v3.0.3
+git push && git push --tags # triggers .github/workflows/release.yml
+```
+
+The workflow refuses to build if the tag does not match the version in
+`pyproject.toml`, because a wrong version cannot be re-uploaded to PyPI.
+
+One-time setup, if it has not been done yet:
+
+1. On PyPI, under the project's **Publishing** settings, add a trusted publisher —
+ owner `TechyNilesh`, repository `DeepImageSearch`, workflow `release.yml`,
+ environment `pypi`.
+2. In GitHub **Settings → Environments**, create an environment named `pypi`.
+ Adding a required reviewer there gives you a manual approval gate before any
+ upload.
+
+## Reporting bugs
+
+Open an issue at https://github.com/TechyNilesh/DeepImageSearch/issues with your
+OS, Python version, DeepImageSearch version, the backend in use, and a minimal
+reproduction.
+
+By contributing you agree that your contributions are licensed under the MIT
+License.
diff --git a/DeepImageSearch/DeepImageSearch.py b/DeepImageSearch/DeepImageSearch.py
index 74f8184..7e4bd0e 100644
--- a/DeepImageSearch/DeepImageSearch.py
+++ b/DeepImageSearch/DeepImageSearch.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Backward-compatible shim for DeepImageSearch v2 API.
@@ -7,7 +9,7 @@
import logging
import os
-from typing import Dict, List, Optional, Union
+from typing import Dict, List, Optional
from DeepImageSearch.data.loader import Load_Data
from DeepImageSearch.core.embeddings import EmbeddingManager
diff --git a/DeepImageSearch/__init__.py b/DeepImageSearch/__init__.py
index a0f1186..bff941f 100644
--- a/DeepImageSearch/__init__.py
+++ b/DeepImageSearch/__init__.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
DeepImageSearch — AI-powered image search with multimodal embeddings,
text-to-image search, LLM captioning, and agentic RAG integration.
@@ -19,7 +21,13 @@
engine.plot_similar_images("query.jpg")
"""
-__version__ = "3.0.0"
+__version__ = "3.0.2"
+
+# Must run before torch or faiss is imported below — see DeepImageSearch/_openmp.py
+# for why macOS needs this and what the permanent fix is.
+from DeepImageSearch._openmp import configure_environment as _configure_environment
+
+_configure_environment()
# Main API
from DeepImageSearch.search_engine import SearchEngine
diff --git a/DeepImageSearch/_openmp.py b/DeepImageSearch/_openmp.py
new file mode 100644
index 0000000..53111f4
--- /dev/null
+++ b/DeepImageSearch/_openmp.py
@@ -0,0 +1,115 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+macOS OpenMP duplicate-runtime mitigation.
+
+The `torch` and `faiss-cpu` wheels each vendor their own copy of libomp.dylib
+(torch/lib/libomp.dylib and faiss/.dylibs/libomp.dylib). Loading both into one
+process is unsupported, and it fails in two different ways:
+
+1. Without KMP_DUPLICATE_LIB_OK the process aborts on the first FAISS call
+ ("OMP: Error #15").
+2. With KMP_DUPLICATE_LIB_OK=TRUE the abort is suppressed, but FAISS's
+ OpenMP-parallel routines — k-means training for IVF indexes above all —
+ segfault instead.
+
+So the env var alone is not enough. When the duplicate is present we also pin
+FAISS to a single OpenMP thread, which keeps the parallel routines off the
+conflicting runtime and makes them correct rather than fatal. This costs FAISS
+throughput on large indexes, so it is applied only when both copies are
+actually installed, and only on macOS.
+
+The real fix is to have one libomp in the environment; see
+`duplicate_libomp_hint()` for how. Set DEEPIMAGESEARCH_FAISS_THREADS to override
+the thread pinning once you have done that.
+"""
+
+import logging
+import os
+import sys
+from importlib.util import find_spec
+
+logger = logging.getLogger(__name__)
+
+_THREADS_ENV = "DEEPIMAGESEARCH_FAISS_THREADS"
+
+
+def _vendored_libomp_paths():
+ """Locate each package's vendored libomp without importing the packages."""
+ paths = []
+ for module, relative in (("torch", "lib/libomp.dylib"), ("faiss", ".dylibs/libomp.dylib")):
+ try:
+ spec = find_spec(module)
+ except (ImportError, ValueError):
+ continue
+ if spec is None or not spec.origin:
+ continue
+ candidate = os.path.join(os.path.dirname(spec.origin), *relative.split("/"))
+ if os.path.exists(candidate):
+ paths.append(os.path.realpath(candidate))
+ return paths
+
+
+def has_duplicate_libomp() -> bool:
+ """True when torch and faiss ship distinct OpenMP runtimes on this machine."""
+ if sys.platform != "darwin":
+ return False
+ return len(set(_vendored_libomp_paths())) > 1
+
+
+def duplicate_libomp_hint() -> str:
+ """Human-readable instructions for removing the duplicate runtime."""
+ return (
+ "torch and faiss-cpu each vendor their own libomp.dylib. DeepImageSearch "
+ "works around this by allowing the duplicate and pinning FAISS to one "
+ "thread. To remove the conflict (and restore FAISS multithreading), point "
+ "one wheel at the other's runtime:\n"
+ " cd \"$(python -c 'import faiss,os;print(os.path.dirname(faiss.__file__))')/.dylibs\"\n"
+ " mv libomp.dylib libomp.dylib.bak\n"
+ " ln -s \"$(python -c 'import torch,os;print(os.path.dirname(torch.__file__))')/lib/libomp.dylib\" .\n"
+ f"Then set {_THREADS_ENV}=0 to let FAISS use all cores again."
+ )
+
+
+def configure_environment() -> None:
+ """
+ Allow the duplicate runtime to load. Must run before torch or faiss is
+ imported, so this is called at the top of the package's __init__.
+ """
+ if sys.platform != "darwin":
+ return
+ # setdefault: an explicit value in the environment always wins.
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
+
+
+def configure_faiss(faiss_module) -> None:
+ """
+ Pin FAISS to a single OpenMP thread when the duplicate runtime is present.
+
+ Called right after `import faiss`. Without this, IVF training segfaults on
+ macOS whenever torch is also loaded.
+ """
+ override = os.environ.get(_THREADS_ENV)
+ if override is not None:
+ threads = int(override)
+ if threads > 0:
+ faiss_module.omp_set_num_threads(threads)
+ return
+
+ if not has_duplicate_libomp():
+ return
+
+ try:
+ faiss_module.omp_set_num_threads(1)
+ except AttributeError: # pragma: no cover — very old faiss builds
+ return
+
+ # One concise line at WARNING; the full instructions are a level down, so
+ # importing the package does not dump a paragraph into every user's stderr.
+ logger.warning(
+ "FAISS pinned to 1 thread: torch and faiss-cpu ship duplicate libomp "
+ "runtimes on macOS, and leaving FAISS multithreaded segfaults. Set "
+ "%s=0 once the duplicate is removed (logger '%s' at INFO explains how).",
+ _THREADS_ENV, __name__,
+ )
+ logger.info("%s", duplicate_libomp_hint())
diff --git a/DeepImageSearch/agents/__init__.py b/DeepImageSearch/agents/__init__.py
index 8709854..0f309da 100644
--- a/DeepImageSearch/agents/__init__.py
+++ b/DeepImageSearch/agents/__init__.py
@@ -1 +1,3 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
from DeepImageSearch.agents.tool_interface import ImageSearchTool
diff --git a/DeepImageSearch/agents/langchain_tool.py b/DeepImageSearch/agents/langchain_tool.py
index bdb097c..995030a 100644
--- a/DeepImageSearch/agents/langchain_tool.py
+++ b/DeepImageSearch/agents/langchain_tool.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
LangChain tool wrapper for DeepImageSearch.
@@ -11,7 +13,7 @@
import json
import logging
-from typing import Any, Dict, Optional
+from typing import Optional
logger = logging.getLogger(__name__)
@@ -60,12 +62,17 @@ def create_langchain_tool(
device=device,
)
+ # Bound to a differently-named local: inside the class body below, the
+ # annotated assignment `k: int = ...` makes `k` local to that body, so
+ # referencing the parameter `k` directly there raises NameError.
+ default_k = k
+
class SearchImagesInput(BaseModel):
query: str = Field(description="Natural language query or image file path")
- k: int = Field(default=k, description="Number of results to return")
+ k: int = Field(default=default_k, description="Number of results to return")
mode: str = Field(default="auto", description="Search mode: 'text', 'image', or 'auto'")
- def _search(query: str, k: int = 5, mode: str = "auto") -> str:
+ def _search(query: str, k: int = default_k, mode: str = "auto") -> str:
results = search_tool(query=query, k=k, mode=mode)
formatted = []
for r in results:
diff --git a/DeepImageSearch/agents/mcp_server.py b/DeepImageSearch/agents/mcp_server.py
index 6ae50a5..c0bbd0e 100644
--- a/DeepImageSearch/agents/mcp_server.py
+++ b/DeepImageSearch/agents/mcp_server.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
MCP (Model Context Protocol) server for DeepImageSearch.
@@ -13,7 +15,6 @@
import argparse
import json
import logging
-from typing import Any
logger = logging.getLogger(__name__)
@@ -39,11 +40,15 @@ def create_mcp_server(
Device for inference.
"""
try:
- from mcp.server.fastmcp import FastMCP
+ # mcp >= 2.0 renamed FastMCP to MCPServer and moved it to mcp.server.mcpserver.
+ from mcp.server.mcpserver import MCPServer as _Server
except ImportError:
- raise ImportError(
- "mcp is required for the MCP server. Install with: uv pip install 'DeepImageSearch[mcp]'"
- )
+ try:
+ from mcp.server.fastmcp import FastMCP as _Server # mcp 1.x
+ except ImportError:
+ raise ImportError(
+ "mcp is required for the MCP server. Install with: uv pip install 'DeepImageSearch[mcp]'"
+ )
from DeepImageSearch.agents.tool_interface import ImageSearchTool
@@ -55,7 +60,7 @@ def create_mcp_server(
device=device,
)
- mcp = FastMCP("DeepImageSearch")
+ mcp = _Server("DeepImageSearch")
@mcp.tool()
def search_images(query: str, k: int = 5, mode: str = "auto") -> str:
diff --git a/DeepImageSearch/agents/tool_interface.py b/DeepImageSearch/agents/tool_interface.py
index c35568e..47a0c27 100644
--- a/DeepImageSearch/agents/tool_interface.py
+++ b/DeepImageSearch/agents/tool_interface.py
@@ -1,9 +1,11 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Generic tool interface for DeepImageSearch that can be used by any agent framework.
"""
import logging
-from typing import Any, Dict, List, Literal, Optional
+from typing import Any, Dict, List, Optional
from DeepImageSearch.core.embeddings import EmbeddingManager
from DeepImageSearch.core.searcher import Searcher
@@ -108,7 +110,10 @@ def tool_definition(self) -> Dict[str, Any]:
"mode": {
"type": "string",
"enum": ["auto", "text", "image"],
- "description": "Search mode: 'text' for semantic search, 'image' for visual similarity, 'auto' to detect",
+ "description": (
+ "Search mode: 'text' for semantic search, "
+ "'image' for visual similarity, 'auto' to detect"
+ ),
"default": "auto",
},
},
diff --git a/DeepImageSearch/core/__init__.py b/DeepImageSearch/core/__init__.py
index 3a5cd64..ad4bb0f 100644
--- a/DeepImageSearch/core/__init__.py
+++ b/DeepImageSearch/core/__init__.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
from DeepImageSearch.core.embeddings import EmbeddingManager, CLIPEmbedding, TimmEmbedding, CustomEmbedding
from DeepImageSearch.core.indexer import Indexer
from DeepImageSearch.core.searcher import Searcher
diff --git a/DeepImageSearch/core/captioner.py b/DeepImageSearch/core/captioner.py
index ca6f957..58b5cb5 100644
--- a/DeepImageSearch/core/captioner.py
+++ b/DeepImageSearch/core/captioner.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
LLM-powered image captioning using OpenAI SDK standard.
diff --git a/DeepImageSearch/core/embeddings.py b/DeepImageSearch/core/embeddings.py
index acac9df..d468321 100644
--- a/DeepImageSearch/core/embeddings.py
+++ b/DeepImageSearch/core/embeddings.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Embedding backends for DeepImageSearch.
@@ -8,7 +10,7 @@
import logging
from abc import ABC, abstractmethod
-from typing import List, Optional, Union
+from typing import List, Optional
import numpy as np
import torch
diff --git a/DeepImageSearch/core/indexer.py b/DeepImageSearch/core/indexer.py
index 93983ad..3d68592 100644
--- a/DeepImageSearch/core/indexer.py
+++ b/DeepImageSearch/core/indexer.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Indexing pipeline: extract embeddings, optionally caption, store in vector DB + metadata store.
"""
diff --git a/DeepImageSearch/core/searcher.py b/DeepImageSearch/core/searcher.py
index 115eb19..76f63c9 100644
--- a/DeepImageSearch/core/searcher.py
+++ b/DeepImageSearch/core/searcher.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Unified search engine supporting text, image, and hybrid queries.
"""
diff --git a/DeepImageSearch/data/__init__.py b/DeepImageSearch/data/__init__.py
index 0d7fafe..208882d 100644
--- a/DeepImageSearch/data/__init__.py
+++ b/DeepImageSearch/data/__init__.py
@@ -1 +1,3 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
from DeepImageSearch.data.loader import Load_Data
diff --git a/DeepImageSearch/data/loader.py b/DeepImageSearch/data/loader.py
index ea57490..ef829ca 100644
--- a/DeepImageSearch/data/loader.py
+++ b/DeepImageSearch/data/loader.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
import csv
import os
import logging
diff --git a/DeepImageSearch/metadatastore/__init__.py b/DeepImageSearch/metadatastore/__init__.py
index d4789d5..bc45ed6 100644
--- a/DeepImageSearch/metadatastore/__init__.py
+++ b/DeepImageSearch/metadatastore/__init__.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
from DeepImageSearch.metadatastore.base import ImageRecord, BaseMetadataStore
from DeepImageSearch.metadatastore.json_store import JsonMetadataStore
diff --git a/DeepImageSearch/metadatastore/base.py b/DeepImageSearch/metadatastore/base.py
index 1b0820c..df4526a 100644
--- a/DeepImageSearch/metadatastore/base.py
+++ b/DeepImageSearch/metadatastore/base.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Abstract metadata store interface and ImageRecord dataclass.
diff --git a/DeepImageSearch/metadatastore/json_store.py b/DeepImageSearch/metadatastore/json_store.py
index 5a5a69d..f038196 100644
--- a/DeepImageSearch/metadatastore/json_store.py
+++ b/DeepImageSearch/metadatastore/json_store.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
JSON file-based metadata store.
diff --git a/DeepImageSearch/metadatastore/postgres_store.py b/DeepImageSearch/metadatastore/postgres_store.py
index 178601d..7b3ac3e 100644
--- a/DeepImageSearch/metadatastore/postgres_store.py
+++ b/DeepImageSearch/metadatastore/postgres_store.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
PostgreSQL-based metadata store.
@@ -188,8 +190,11 @@ def load(self, path: str) -> None:
def close(self) -> None:
"""Close the database connection."""
- if self.conn and not self.conn.closed:
- self.conn.close()
+ # getattr: __init__ can fail before self.conn exists (missing driver,
+ # bad connection string), and __del__ still runs on the partial object.
+ conn = getattr(self, "conn", None)
+ if conn is not None and not conn.closed:
+ conn.close()
def __del__(self):
self.close()
diff --git a/DeepImageSearch/search_engine.py b/DeepImageSearch/search_engine.py
index a714870..f92cd8b 100644
--- a/DeepImageSearch/search_engine.py
+++ b/DeepImageSearch/search_engine.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
SearchEngine — high-level unified API for DeepImageSearch v3.
diff --git a/DeepImageSearch/vectorstores/__init__.py b/DeepImageSearch/vectorstores/__init__.py
index be426e9..7eae152 100644
--- a/DeepImageSearch/vectorstores/__init__.py
+++ b/DeepImageSearch/vectorstores/__init__.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
from DeepImageSearch.vectorstores.base import BaseVectorStore
from DeepImageSearch.vectorstores.faiss_store import FAISSStore
diff --git a/DeepImageSearch/vectorstores/base.py b/DeepImageSearch/vectorstores/base.py
index c0cf073..4d431ca 100644
--- a/DeepImageSearch/vectorstores/base.py
+++ b/DeepImageSearch/vectorstores/base.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""Abstract vector store interface."""
from abc import ABC, abstractmethod
diff --git a/DeepImageSearch/vectorstores/chroma_store.py b/DeepImageSearch/vectorstores/chroma_store.py
index c94531c..117063a 100644
--- a/DeepImageSearch/vectorstores/chroma_store.py
+++ b/DeepImageSearch/vectorstores/chroma_store.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""ChromaDB-based vector store."""
import logging
@@ -49,9 +51,11 @@ def add(
metadata: Optional[List[Dict[str, Any]]] = None,
) -> None:
vectors = vectors.astype(np.float32)
- # Chroma needs metadata values to be str, int, float, or bool
- clean_metadata = []
+ # Chroma needs metadata values to be str, int, float, or bool, and it
+ # rejects empty dicts outright — so absent metadata must be sent as None.
+ clean_metadata = None
if metadata:
+ clean_metadata = []
for m in metadata:
clean = {}
for k, v in m.items():
@@ -59,9 +63,7 @@ def add(
clean[k] = v
else:
clean[k] = str(v)
- clean_metadata.append(clean)
- else:
- clean_metadata = [{}] * len(ids)
+ clean_metadata.append(clean or None)
# ChromaDB has a batch limit, chunk if needed
batch_size = 5000
@@ -70,7 +72,7 @@ def add(
self.collection.add(
ids=ids[i:end],
embeddings=vectors[i:end].tolist(),
- metadatas=clean_metadata[i:end],
+ metadatas=clean_metadata[i:end] if clean_metadata else None,
)
logger.info(f"Added {len(ids)} vectors to ChromaDB")
@@ -90,7 +92,9 @@ def search(
output = []
if results["ids"] and results["ids"][0]:
for idx, (id_, dist) in enumerate(zip(results["ids"][0], results["distances"][0])):
- meta = results["metadatas"][0][idx] if results["metadatas"] else {}
+ # Chroma hands back None for records stored without metadata;
+ # the BaseVectorStore contract promises a dict.
+ meta = (results["metadatas"][0][idx] if results["metadatas"] else None) or {}
output.append({
"id": id_,
"score": 1.0 - dist, # Chroma returns distance, convert to similarity
diff --git a/DeepImageSearch/vectorstores/faiss_store.py b/DeepImageSearch/vectorstores/faiss_store.py
index 40eba7c..f1ed466 100644
--- a/DeepImageSearch/vectorstores/faiss_store.py
+++ b/DeepImageSearch/vectorstores/faiss_store.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""FAISS-based vector store with metadata support."""
import json
@@ -8,10 +10,15 @@
import faiss
import numpy as np
+from DeepImageSearch._openmp import configure_faiss
from DeepImageSearch.vectorstores.base import BaseVectorStore
logger = logging.getLogger(__name__)
+# Guards against a macOS segfault in FAISS's OpenMP routines when torch is also
+# loaded. No-op on other platforms and on healthy environments.
+configure_faiss(faiss)
+
class FAISSStore(BaseVectorStore):
"""
@@ -37,8 +44,12 @@ def _build_index(self, dimension: int) -> None:
if self.index_type == "flat":
self.index = faiss.IndexFlatIP(dimension) # inner product for cosine sim on normalised vectors
elif self.index_type == "ivf":
- quantizer = faiss.IndexFlatIP(dimension)
- self.index = faiss.IndexIVFFlat(quantizer, dimension, 100, faiss.METRIC_INNER_PRODUCT)
+ # The quantizer must outlive this call: IndexIVFFlat stores a raw
+ # pointer to it without taking ownership, so letting it fall out of
+ # scope leaves the index with a dangling pointer and segfaults on
+ # the first train()/search(). Keep a Python reference alive.
+ self._quantizer = faiss.IndexFlatIP(dimension)
+ self.index = faiss.IndexIVFFlat(self._quantizer, dimension, 100, faiss.METRIC_INNER_PRODUCT)
elif self.index_type == "hnsw":
self.index = faiss.IndexHNSWFlat(dimension, 32, faiss.METRIC_INNER_PRODUCT)
else:
@@ -123,6 +134,13 @@ def delete(self, ids: List[str]) -> None:
if len(keep_mask) == len(self._ids):
return
+ if not keep_mask:
+ # Everything was deleted — np.vstack would fail on an empty list
+ self._build_index(self.dimension)
+ self._ids = []
+ self._metadata = []
+ return
+
# Reconstruct all vectors
all_vectors = np.vstack([self.index.reconstruct(i) for i in keep_mask]).astype(np.float32)
new_ids = [self._ids[i] for i in keep_mask]
diff --git a/DeepImageSearch/vectorstores/qdrant_store.py b/DeepImageSearch/vectorstores/qdrant_store.py
index 0361e69..e375497 100644
--- a/DeepImageSearch/vectorstores/qdrant_store.py
+++ b/DeepImageSearch/vectorstores/qdrant_store.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""Qdrant-based vector store."""
import logging
@@ -104,12 +106,23 @@ def search(
conditions.append(FieldCondition(key=key, match=MatchValue(value=value)))
query_filter = Filter(must=conditions)
- results = self.client.search(
- collection_name=self.collection_name,
- query_vector=query_vector.astype(np.float32).tolist(),
- limit=k,
- query_filter=query_filter,
- )
+ vector = query_vector.astype(np.float32).tolist()
+
+ if hasattr(self.client, "query_points"):
+ # qdrant-client >= 1.10 — `search()` was deprecated and later removed.
+ results = self.client.query_points(
+ collection_name=self.collection_name,
+ query=vector,
+ limit=k,
+ query_filter=query_filter,
+ ).points
+ else:
+ results = self.client.search(
+ collection_name=self.collection_name,
+ query_vector=vector,
+ limit=k,
+ query_filter=query_filter,
+ )
output = []
for hit in results:
diff --git a/Demo/01_basic_image_search.py b/Demo/01_basic_image_search.py
index 8ba90f5..33d0010 100644
--- a/Demo/01_basic_image_search.py
+++ b/Demo/01_basic_image_search.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 1: Basic Image-to-Image Search
diff --git a/Demo/02_text_to_image_search.py b/Demo/02_text_to_image_search.py
index 359ee05..3d57ab9 100644
--- a/Demo/02_text_to_image_search.py
+++ b/Demo/02_text_to_image_search.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 2: Text-to-Image Search
diff --git a/Demo/03_hybrid_search.py b/Demo/03_hybrid_search.py
index 27135ed..dda4809 100644
--- a/Demo/03_hybrid_search.py
+++ b/Demo/03_hybrid_search.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 3: Hybrid Search (Text + Image)
diff --git a/Demo/04_filtered_search.py b/Demo/04_filtered_search.py
index 8bf57d6..f01c8f9 100644
--- a/Demo/04_filtered_search.py
+++ b/Demo/04_filtered_search.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 4: Search with Metadata Filtering
diff --git a/Demo/05_llm_captioning.py b/Demo/05_llm_captioning.py
index 7e1a50c..310032a 100644
--- a/Demo/05_llm_captioning.py
+++ b/Demo/05_llm_captioning.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 5: LLM-Powered Image Captioning
diff --git a/Demo/06_vector_stores.py b/Demo/06_vector_stores.py
index 1c53148..1c9631e 100644
--- a/Demo/06_vector_stores.py
+++ b/Demo/06_vector_stores.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 6: Different Vector Store Backends
diff --git a/Demo/07_metadata_storage.py b/Demo/07_metadata_storage.py
index b2e95c8..6e60e2c 100644
--- a/Demo/07_metadata_storage.py
+++ b/Demo/07_metadata_storage.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 7: Metadata Storage (JSON and PostgreSQL)
diff --git a/Demo/08_agentic_tools.py b/Demo/08_agentic_tools.py
index b1f3d3e..fed1580 100644
--- a/Demo/08_agentic_tools.py
+++ b/Demo/08_agentic_tools.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 8: Agentic Integration (MCP, LangChain, Generic Tool)
diff --git a/Demo/09_embedding_models.py b/Demo/09_embedding_models.py
index 529c3a5..24fed70 100644
--- a/Demo/09_embedding_models.py
+++ b/Demo/09_embedding_models.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 9: Different Embedding Models
diff --git a/Demo/10_incremental_indexing.py b/Demo/10_incremental_indexing.py
index 71a7ebc..a022a74 100644
--- a/Demo/10_incremental_indexing.py
+++ b/Demo/10_incremental_indexing.py
@@ -1,3 +1,5 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
"""
Demo 10: Incremental Indexing and Persistence
diff --git a/README.md b/README.md
index 12a9f3b..1005965 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-

+
**DeepImageSearch** is a Python library for building AI-powered image search systems. It supports text-to-image search, image-to-image search, hybrid search, and LLM-powered captioning using CLIP/SigLIP/EVA-CLIP multimodal embeddings with FAISS/ChromaDB/Qdrant vector indexing. Built for the agentic RAG era with MCP server, LangChain tool, and PostgreSQL metadata storage out of the box.
@@ -323,6 +323,17 @@ Ready-to-run demo scripts in the [`Demo/`](https://github.com/TechyNilesh/DeepIm
For detailed documentation: [Read Full Documents](https://github.com/TechyNilesh/DeepImageSearch/blob/main/Documents/Document.md)
+## Development
+
+```bash
+pip install -e ".[dev]"
+pytest # test suite (no model downloads)
+ruff check DeepImageSearch tests # lint
+```
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for the full developer guide, including
+how to add a new vector store, metadata store, or embedding backend.
+
## Core Contributors
diff --git a/images/deepimagesearch_example.png b/images/deepimagesearch_example.png
new file mode 100644
index 0000000..59e0fe9
Binary files /dev/null and b/images/deepimagesearch_example.png differ
diff --git a/images/deepimagesearch_logo.png b/images/deepimagesearch_logo.png
index 116ee16..e45d05c 100644
Binary files a/images/deepimagesearch_logo.png and b/images/deepimagesearch_logo.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 37b01e7..7c86884 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,3 +80,29 @@ packages = ["DeepImageSearch"]
[tool.ruff]
target-version = "py310"
line-length = 120
+
+[tool.ruff.lint]
+# Pinned explicitly so CI results do not drift as ruff's defaults change.
+# Import sorting ("I") is deliberately left out — it is pure churn on the
+# existing tree; add it with a one-off `ruff check --fix` when convenient.
+select = ["E", "F"]
+
+[tool.ruff.lint.per-file-ignores]
+# __init__.py files re-export the public API; those imports are intentional.
+"__init__.py" = ["F401"]
+"DeepImageSearch/__init__.py" = ["F401", "E402"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-q --strict-markers"
+filterwarnings = ["ignore::DeprecationWarning"]
+
+[tool.coverage.run]
+source = ["DeepImageSearch"]
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "raise NotImplementedError",
+ "if TYPE_CHECKING:",
+]
diff --git a/scripts/bump_version.py b/scripts/bump_version.py
new file mode 100755
index 0000000..d25e572
--- /dev/null
+++ b/scripts/bump_version.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Bump the project version in every place it is recorded.
+
+The version lives in three files, and tests/test_package.py asserts they agree:
+ pyproject.toml version = "X.Y.Z"
+ DeepImageSearch/__init__ __version__ = "X.Y.Z"
+ CITATION.cff version: X.Y.Z (plus date-released)
+
+Usage:
+ python scripts/bump_version.py 3.0.3
+ python scripts/bump_version.py 3.0.3 --date 2026-09-01
+ python scripts/bump_version.py 3.0.3 --dry-run
+
+Then commit, tag, and push — the release workflow does the rest:
+ git commit -am "Bump version to 3.0.3"
+ git tag v3.0.3 && git push && git push --tags
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime
+import pathlib
+import re
+import sys
+
+REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
+
+SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
+
+
+def replace_once(text: str, pattern: str, replacement: str, path: pathlib.Path) -> str:
+ """Substitute exactly one match, or fail loudly — a silent miss ships a bad release."""
+ updated, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE)
+ if count != 1:
+ raise SystemExit(f"error: no version line matching {pattern!r} in {path}")
+ return updated
+
+
+def bump(version: str, released: str, root: pathlib.Path = REPO_ROOT) -> dict[str, str]:
+ """Return {relative path: new content} for every file that records the version."""
+ edits = {
+ "pyproject.toml": (r'^version = "[^"]+"', f'version = "{version}"'),
+ "DeepImageSearch/__init__.py": (r'^__version__ = "[^"]+"', f'__version__ = "{version}"'),
+ "CITATION.cff": (r"^version: .+$", f"version: {version}"),
+ }
+
+ result = {}
+ for relative, (pattern, replacement) in edits.items():
+ path = root / relative
+ text = path.read_text(encoding="utf-8")
+ result[relative] = replace_once(text, pattern, replacement, path)
+
+ # CITATION.cff also carries the release date.
+ result["CITATION.cff"] = replace_once(
+ result["CITATION.cff"],
+ r"^date-released: .+$",
+ f'date-released: "{released}"',
+ root / "CITATION.cff",
+ )
+ return result
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("version", help="new version, e.g. 3.0.3")
+ parser.add_argument("--date", help="release date for CITATION.cff (default: today)")
+ parser.add_argument("--dry-run", action="store_true", help="print what would change, write nothing")
+ args = parser.parse_args(argv)
+
+ if not SEMVER.match(args.version):
+ parser.error(f"version must look like X.Y.Z, got {args.version!r}")
+
+ released = args.date or datetime.date.today().isoformat()
+ updated = bump(args.version, released)
+
+ for relative, content in updated.items():
+ path = REPO_ROOT / relative
+ if args.dry_run:
+ print(f"would update {relative}")
+ continue
+ path.write_text(content, encoding="utf-8")
+ print(f"updated {relative}")
+
+ if args.dry_run:
+ print(f"\ndry run — nothing written (version {args.version}, date {released})")
+ return 0
+
+ print(
+ f"\nVersion set to {args.version}. Next:\n"
+ f" 1. Add a {args.version} section to CHANGELOG.md\n"
+ f" 2. git commit -am 'Bump version to {args.version}'\n"
+ f" 3. git tag v{args.version} && git push && git push --tags\n"
+ f"The release workflow builds, verifies, and publishes to PyPI."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 0c27e11..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[metadata]
-description-file = README.rst
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..730742f
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..c4a0494
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,100 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Shared fixtures.
+
+Tests must never download model weights — CI runs on four Python versions and
+three operating systems. Everything here is deterministic and CPU-only:
+`DummyEmbedding` produces fixed vectors so search results are exactly
+predictable, and images are tiny solid-colour PNGs generated on the fly.
+"""
+
+from typing import List
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from DeepImageSearch.core.embeddings import BaseEmbedding
+
+DIM = 8
+
+
+def _unit(vector: List[float]) -> np.ndarray:
+ arr = np.asarray(vector, dtype=np.float32)
+ return arr / np.linalg.norm(arr)
+
+
+class DummyEmbedding(BaseEmbedding):
+ """
+ Deterministic stand-in for CLIP.
+
+ An image embeds to a unit vector derived from its dominant colour channel;
+ a text embeds to a unit vector derived from the hash of its first word.
+ Both land in the same 8-D space, so text/image/hybrid paths are exercisable
+ without any model download.
+ """
+
+ supports_text = True
+ dimension = DIM
+
+ def __init__(self, supports_text: bool = True):
+ self.supports_text = supports_text
+ self.embed_images_calls = 0
+ self.embed_texts_calls = 0
+
+ def embed_images(self, images: List[Image.Image]) -> np.ndarray:
+ self.embed_images_calls += 1
+ vectors = []
+ for img in images:
+ r, g, b = img.convert("RGB").resize((1, 1)).getpixel((0, 0))
+ base = [r, g, b, 1.0, 0.0, 0.0, 0.0, 0.0]
+ vectors.append(_unit(base))
+ return np.vstack(vectors).astype(np.float32)
+
+ def embed_texts(self, texts: List[str]) -> np.ndarray:
+ if not self.supports_text:
+ raise NotImplementedError("This embedding model does not support text queries")
+ self.embed_texts_calls += 1
+ vectors = []
+ for text in texts:
+ seed = sum(ord(c) for c in text) % 255
+ base = [seed, 255 - seed, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]
+ vectors.append(_unit(base))
+ return np.vstack(vectors).astype(np.float32)
+
+
+@pytest.fixture
+def embedding():
+ return DummyEmbedding()
+
+
+@pytest.fixture
+def image_only_embedding():
+ return DummyEmbedding(supports_text=False)
+
+
+def make_image(path, colour=(255, 0, 0), size=(16, 16)):
+ """Write a tiny solid-colour PNG and return its path as a string."""
+ Image.new("RGB", size, colour).save(path)
+ return str(path)
+
+
+@pytest.fixture
+def image_dir(tmp_path):
+ """A folder of three distinctly-coloured images plus a nested one."""
+ root = tmp_path / "images"
+ root.mkdir()
+ make_image(root / "red.png", (255, 0, 0))
+ make_image(root / "green.png", (0, 255, 0))
+ make_image(root / "blue.jpg", (0, 0, 255))
+ nested = root / "nested"
+ nested.mkdir()
+ make_image(nested / "yellow.png", (255, 255, 0))
+ return root
+
+
+@pytest.fixture
+def image_paths(image_dir):
+ """Deterministically ordered paths of the top-level images."""
+ return sorted(str(p) for p in image_dir.glob("*.???") if p.suffix in {".png", ".jpg"})
diff --git a/tests/test_agents.py b/tests/test_agents.py
new file mode 100644
index 0000000..7a90b64
--- /dev/null
+++ b/tests/test_agents.py
@@ -0,0 +1,210 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for the agent-facing wrappers: the generic ImageSearchTool, the LangChain
+tool, and the MCP server.
+
+All of them build an embedding backend, so EmbeddingManager.create is patched to
+the dummy. The LangChain and MCP tests skip unless their extra is installed.
+"""
+
+import json
+import sys
+
+import pytest
+
+from DeepImageSearch.agents import tool_interface as ti_module
+from DeepImageSearch.agents.tool_interface import ImageSearchTool
+from DeepImageSearch.core.indexer import Indexer
+from DeepImageSearch.vectorstores.faiss_store import FAISSStore
+from tests.conftest import DIM, DummyEmbedding
+
+
+@pytest.fixture(autouse=True)
+def no_model_downloads(monkeypatch):
+ monkeypatch.setattr(
+ ti_module.EmbeddingManager, "create",
+ staticmethod(lambda *args, **kwargs: DummyEmbedding()),
+ )
+
+
+@pytest.fixture
+def saved_index(tmp_path, image_paths, embedding):
+ """A FAISS index on disk, ready for a tool to load."""
+ store = FAISSStore(dimension=DIM)
+ Indexer(embedding=embedding, vector_store=store).index(
+ image_paths, extra_metadata=[{"album": "trip"} for _ in image_paths]
+ )
+ index_dir = tmp_path / "index"
+ store.save(str(index_dir))
+ return str(index_dir)
+
+
+class TestImageSearchTool:
+ def test_loads_a_saved_index(self, saved_index, image_paths):
+ tool = ImageSearchTool(index_path=saved_index)
+ assert tool.vector_store.count() == len(image_paths)
+
+ def test_is_callable_with_a_text_query(self, saved_index):
+ results = ImageSearchTool(index_path=saved_index)(query="a red square", k=2)
+ assert len(results) == 2
+ assert set(results[0]) == {"id", "score", "metadata"}
+
+ def test_is_callable_with_an_image_path(self, saved_index, image_paths):
+ results = ImageSearchTool(index_path=saved_index)(query=image_paths[0], k=1)
+ assert results[0]["metadata"]["image_path"] == image_paths[0]
+
+ def test_forwards_filters(self, saved_index):
+ tool = ImageSearchTool(index_path=saved_index)
+ assert tool(query="a red square", k=5, filters={"album": "trip"})
+ assert tool(query="a red square", k=5, filters={"album": "nope"}) == []
+
+ def test_forwards_mode(self, saved_index, image_paths):
+ tool = ImageSearchTool(index_path=saved_index)
+ # mode='text' must not treat a path-looking string as an image
+ assert tool(query=image_paths[0], k=1, mode="text")
+
+ def test_unknown_store_type_raises(self, saved_index):
+ with pytest.raises(ValueError, match="Unknown store type"):
+ ImageSearchTool(index_path=saved_index, vector_store_type="pinecone")
+
+ def test_tool_definition_is_valid_function_calling_schema(self, saved_index):
+ definition = ImageSearchTool(index_path=saved_index).tool_definition
+ assert definition["name"] == "search_images"
+ assert definition["description"]
+
+ schema = definition["input_schema"]
+ assert schema["type"] == "object"
+ assert schema["required"] == ["query"]
+ assert set(schema["properties"]) == {"query", "k", "mode"}
+ assert schema["properties"]["mode"]["enum"] == ["auto", "text", "image"]
+ assert schema["properties"]["k"]["type"] == "integer"
+
+ def test_tool_definition_is_json_serialisable(self, saved_index):
+ definition = ImageSearchTool(index_path=saved_index).tool_definition
+ assert json.loads(json.dumps(definition)) == definition
+
+
+class TestLangChainTool:
+ @pytest.fixture(autouse=True)
+ def requires_langchain(self):
+ pytest.importorskip("langchain_core", reason="install the [langchain] extra to run these")
+
+ def test_creates_a_structured_tool(self, saved_index):
+ from DeepImageSearch.agents.langchain_tool import create_langchain_tool
+
+ tool = create_langchain_tool(index_path=saved_index)
+ assert tool.name == "search_images"
+ assert tool.description
+
+ def test_invoking_returns_json_with_path_score_and_caption(self, saved_index, image_paths):
+ from DeepImageSearch.agents.langchain_tool import create_langchain_tool
+
+ tool = create_langchain_tool(index_path=saved_index)
+ payload = json.loads(tool.invoke({"query": image_paths[0], "k": 2}))
+ assert len(payload) == 2
+ assert set(payload[0]) == {"image_path", "score", "caption"}
+ assert payload[0]["image_path"] == image_paths[0]
+ assert isinstance(payload[0]["score"], float)
+
+ def test_args_schema_exposes_query_k_and_mode(self, saved_index):
+ from DeepImageSearch.agents.langchain_tool import create_langchain_tool
+
+ fields = create_langchain_tool(index_path=saved_index).args_schema.model_fields
+ assert set(fields) == {"query", "k", "mode"}
+
+ def test_default_k_is_configurable(self, saved_index):
+ from DeepImageSearch.agents.langchain_tool import create_langchain_tool
+
+ tool = create_langchain_tool(index_path=saved_index, k=3)
+ assert tool.args_schema.model_fields["k"].default == 3
+
+ def test_missing_dependency_gives_an_actionable_error(self, saved_index, monkeypatch):
+ from DeepImageSearch.agents.langchain_tool import create_langchain_tool
+
+ monkeypatch.setitem(sys.modules, "langchain_core.tools", None)
+ with pytest.raises(ImportError, match=r"DeepImageSearch\[langchain\]"):
+ create_langchain_tool(index_path=saved_index)
+
+
+class TestMcpServer:
+ @pytest.fixture(autouse=True)
+ def requires_mcp(self):
+ pytest.importorskip("mcp", reason="install the [mcp] extra to run these")
+
+ def test_creates_a_server_exposing_both_tools(self, saved_index):
+ import anyio
+
+ from DeepImageSearch.agents.mcp_server import create_mcp_server
+
+ mcp = create_mcp_server(index_path=saved_index)
+ names = {t.name for t in anyio.run(mcp.list_tools)}
+ assert names == {"search_images", "get_index_info"}
+
+ def test_search_tool_returns_json_results(self, saved_index, image_paths):
+ import anyio
+
+ from DeepImageSearch.agents.mcp_server import create_mcp_server
+
+ mcp = create_mcp_server(index_path=saved_index)
+ result = anyio.run(lambda: mcp.call_tool("search_images", {"query": image_paths[0], "k": 2}))
+ payload = json.loads(_first_text(result))
+ assert len(payload) == 2
+ assert payload[0]["image_path"] == image_paths[0]
+ # image_path and caption are lifted out of the nested metadata blob
+ assert "image_path" not in payload[0]["metadata"]
+
+ def test_info_tool_reports_the_index(self, saved_index, image_paths):
+ import anyio
+
+ from DeepImageSearch.agents.mcp_server import create_mcp_server
+
+ mcp = create_mcp_server(index_path=saved_index)
+ info = json.loads(_first_text(anyio.run(lambda: mcp.call_tool("get_index_info", {}))))
+ assert info["total_images"] == len(image_paths)
+ assert info["vector_dimension"] == DIM
+ assert info["supports_text_search"] is True
+ assert info["vector_store"] == "faiss"
+
+ def test_missing_dependency_gives_an_actionable_error(self, saved_index, monkeypatch):
+ from DeepImageSearch.agents.mcp_server import create_mcp_server
+
+ # Block both the mcp >= 2.0 path and the 1.x fallback.
+ monkeypatch.setitem(sys.modules, "mcp.server.mcpserver", None)
+ monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", None)
+ with pytest.raises(ImportError, match=r"DeepImageSearch\[mcp\]"):
+ create_mcp_server(index_path=saved_index)
+
+ def test_cli_wires_arguments_through_and_runs(self, saved_index, monkeypatch):
+ from DeepImageSearch.agents import mcp_server
+
+ captured = {}
+
+ class FakeServer:
+ def run(self):
+ captured["ran"] = True
+
+ monkeypatch.setattr(mcp_server, "create_mcp_server", lambda **kwargs: captured.update(kwargs) or FakeServer())
+ monkeypatch.setattr(sys, "argv", ["deep-image-search-mcp", "--index-path", saved_index,
+ "--store-type", "faiss", "--device", "cpu"])
+ mcp_server.main()
+
+ assert captured["index_path"] == saved_index
+ assert captured["vector_store_type"] == "faiss"
+ assert captured["device"] == "cpu"
+ assert captured["ran"] is True
+
+ def test_cli_requires_an_index_path(self, monkeypatch):
+ from DeepImageSearch.agents import mcp_server
+
+ monkeypatch.setattr(sys, "argv", ["deep-image-search-mcp"])
+ with pytest.raises(SystemExit):
+ mcp_server.main()
+
+
+def _first_text(call_tool_result):
+ """FastMCP returns (content, ...) or a result object depending on version."""
+ content = call_tool_result[0] if isinstance(call_tool_result, tuple) else call_tool_result
+ if hasattr(content, "content"):
+ content = content.content
+ return content[0].text
diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py
new file mode 100644
index 0000000..a8abf42
--- /dev/null
+++ b/tests/test_bump_version.py
@@ -0,0 +1,128 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for scripts/bump_version.py.
+
+A missed substitution here ships a release tagged with one version and packaged
+as another, which PyPI will not let you take back — so the script fails loudly
+rather than silently skipping a file, and that behaviour is pinned below.
+"""
+
+import importlib.util
+import pathlib
+import re
+
+import pytest
+
+REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
+SCRIPT = REPO_ROOT / "scripts" / "bump_version.py"
+
+
+def load_script():
+ spec = importlib.util.spec_from_file_location("bump_version", SCRIPT)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+bump_version = load_script()
+
+
+@pytest.fixture
+def fake_repo(tmp_path):
+ (tmp_path / "DeepImageSearch").mkdir()
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "DeepImageSearch"\nversion = "3.0.2"\nrequires-python = ">=3.10"\n',
+ encoding="utf-8",
+ )
+ (tmp_path / "DeepImageSearch" / "__init__.py").write_text(
+ '"""docstring"""\n\n__version__ = "3.0.2"\n\nimport os\n', encoding="utf-8"
+ )
+ (tmp_path / "CITATION.cff").write_text(
+ 'cff-version: 1.2.0\ntitle: "DeepImageSearch"\nlicense: MIT\n'
+ 'version: 3.0.2\ndate-released: "2026-04-01"\n',
+ encoding="utf-8",
+ )
+ return tmp_path
+
+
+class TestBump:
+ def test_updates_all_three_files(self, fake_repo):
+ updated = bump_version.bump("3.1.0", "2026-09-01", root=fake_repo)
+ assert set(updated) == {"pyproject.toml", "DeepImageSearch/__init__.py", "CITATION.cff"}
+ assert 'version = "3.1.0"' in updated["pyproject.toml"]
+ assert '__version__ = "3.1.0"' in updated["DeepImageSearch/__init__.py"]
+ assert "version: 3.1.0" in updated["CITATION.cff"]
+
+ def test_updates_the_release_date(self, fake_repo):
+ updated = bump_version.bump("3.1.0", "2026-09-01", root=fake_repo)
+ assert 'date-released: "2026-09-01"' in updated["CITATION.cff"]
+
+ def test_leaves_other_content_alone(self, fake_repo):
+ updated = bump_version.bump("3.1.0", "2026-09-01", root=fake_repo)
+ assert 'requires-python = ">=3.10"' in updated["pyproject.toml"]
+ assert "import os" in updated["DeepImageSearch/__init__.py"]
+ assert "cff-version: 1.2.0" in updated["CITATION.cff"]
+
+ def test_does_not_touch_the_cff_schema_version(self, fake_repo):
+ # `cff-version:` also matches a careless `version:` pattern.
+ updated = bump_version.bump("3.1.0", "2026-09-01", root=fake_repo)
+ assert "cff-version: 1.2.0" in updated["CITATION.cff"]
+ assert "cff-version: 3.1.0" not in updated["CITATION.cff"]
+
+ def test_writes_nothing_itself(self, fake_repo):
+ bump_version.bump("3.1.0", "2026-09-01", root=fake_repo)
+ assert 'version = "3.0.2"' in (fake_repo / "pyproject.toml").read_text(encoding="utf-8")
+
+ def test_a_missing_version_line_is_fatal(self, fake_repo):
+ (fake_repo / "pyproject.toml").write_text("[project]\nname = 'x'\n", encoding="utf-8")
+ with pytest.raises(SystemExit, match="no version line"):
+ bump_version.bump("3.1.0", "2026-09-01", root=fake_repo)
+
+ def test_a_missing_file_is_fatal(self, fake_repo):
+ (fake_repo / "CITATION.cff").unlink()
+ with pytest.raises(FileNotFoundError):
+ bump_version.bump("3.1.0", "2026-09-01", root=fake_repo)
+
+
+class TestCli:
+ @pytest.mark.parametrize("bad", ["3.1", "v3.1.0", "3.1.0-rc1", "latest"])
+ def test_rejects_malformed_versions(self, bad):
+ with pytest.raises(SystemExit):
+ bump_version.main([bad])
+
+ def test_dry_run_writes_nothing(self, capsys, monkeypatch, fake_repo):
+ monkeypatch.setattr(bump_version, "REPO_ROOT", fake_repo)
+ assert bump_version.main(["3.1.0", "--dry-run"]) == 0
+ assert "dry run" in capsys.readouterr().out
+ assert 'version = "3.0.2"' in (fake_repo / "pyproject.toml").read_text(encoding="utf-8")
+
+ def test_writes_every_file_and_prints_next_steps(self, capsys, monkeypatch, fake_repo):
+ monkeypatch.setattr(bump_version, "REPO_ROOT", fake_repo)
+ assert bump_version.main(["3.1.0", "--date", "2026-09-01"]) == 0
+
+ assert 'version = "3.1.0"' in (fake_repo / "pyproject.toml").read_text(encoding="utf-8")
+ assert '__version__ = "3.1.0"' in (fake_repo / "DeepImageSearch" / "__init__.py").read_text(encoding="utf-8")
+ assert "version: 3.1.0" in (fake_repo / "CITATION.cff").read_text(encoding="utf-8")
+ assert "git tag v3.1.0" in capsys.readouterr().out
+
+ def test_defaults_the_date_to_today(self, monkeypatch, fake_repo):
+ import datetime
+
+ monkeypatch.setattr(bump_version, "REPO_ROOT", fake_repo)
+ bump_version.main(["3.1.0"])
+ today = datetime.date.today().isoformat()
+ assert f'date-released: "{today}"' in (fake_repo / "CITATION.cff").read_text(encoding="utf-8")
+
+
+def test_script_agrees_with_the_live_repo_layout():
+ """Guards against the real files drifting away from the script's patterns."""
+ import DeepImageSearch
+
+ updated = bump_version.bump("9.9.9", "2099-01-01", root=REPO_ROOT)
+ assert 'version = "9.9.9"' in updated["pyproject.toml"]
+ assert '__version__ = "9.9.9"' in updated["DeepImageSearch/__init__.py"]
+ assert "version: 9.9.9" in updated["CITATION.cff"]
+ # ...and the current version is still the one the package reports.
+ pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ assert re.search(r'^version = "([^"]+)"', pyproject, re.MULTILINE).group(1) == DeepImageSearch.__version__
diff --git a/tests/test_captioner.py b/tests/test_captioner.py
new file mode 100644
index 0000000..202eaca
--- /dev/null
+++ b/tests/test_captioner.py
@@ -0,0 +1,180 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for the LLM captioner.
+
+No network access: a fake `openai` module is installed in sys.modules so the
+OpenAI-SDK-shaped call chain (client.chat.completions.create) is exercised
+against canned responses.
+"""
+
+import base64
+import io
+import sys
+import types
+
+import pytest
+from PIL import Image
+
+from DeepImageSearch.core.captioner import (
+ DEFAULT_CAPTION_PROMPT,
+ DEFAULT_METADATA_PROMPT,
+ Captioner,
+ _image_to_base64,
+)
+from tests.conftest import make_image
+
+
+class FakeCompletions:
+ def __init__(self, responses, errors=None):
+ self.responses = list(responses)
+ self.errors = errors or {}
+ self.calls = []
+
+ def create(self, **kwargs):
+ self.calls.append(kwargs)
+ index = len(self.calls) - 1
+ if index in self.errors:
+ raise self.errors[index]
+ content = self.responses[index % len(self.responses)]
+ message = types.SimpleNamespace(content=content)
+ return types.SimpleNamespace(choices=[types.SimpleNamespace(message=message)])
+
+
+@pytest.fixture
+def fake_openai(monkeypatch):
+ """Install a fake `openai` module; returns a factory for building captioners."""
+ state = {}
+
+ class FakeOpenAI:
+ def __init__(self, api_key=None, base_url=None):
+ state["api_key"] = api_key
+ state["base_url"] = base_url
+ self.chat = types.SimpleNamespace(completions=state["completions"])
+
+ module = types.ModuleType("openai")
+ module.OpenAI = FakeOpenAI
+ monkeypatch.setitem(sys.modules, "openai", module)
+
+ def build(responses=("a caption",), errors=None, **kwargs):
+ state["completions"] = FakeCompletions(responses, errors)
+ captioner = Captioner(model="vision-model", api_key="secret",
+ base_url="https://example.invalid/v1", **kwargs)
+ return captioner, state
+
+ return build
+
+
+class TestImageEncoding:
+ def test_returns_decodable_base64_jpeg(self, tmp_path):
+ path = make_image(tmp_path / "a.png", (10, 20, 30))
+ encoded = _image_to_base64(path)
+ with Image.open(io.BytesIO(base64.standard_b64decode(encoded))) as img:
+ assert img.format == "JPEG"
+
+ def test_large_images_are_downscaled(self, tmp_path):
+ path = make_image(tmp_path / "big.png", size=(2048, 1024))
+ encoded = _image_to_base64(path, max_size=256)
+ with Image.open(io.BytesIO(base64.standard_b64decode(encoded))) as img:
+ assert max(img.size) == 256
+ assert img.size == (256, 128) # aspect ratio preserved
+
+ def test_small_images_are_left_alone(self, tmp_path):
+ path = make_image(tmp_path / "small.png", size=(64, 32))
+ encoded = _image_to_base64(path, max_size=1024)
+ with Image.open(io.BytesIO(base64.standard_b64decode(encoded))) as img:
+ assert img.size == (64, 32)
+
+ def test_greyscale_and_rgba_inputs_are_converted(self, tmp_path):
+ for mode in ("L", "RGBA"):
+ path = tmp_path / f"{mode}.png"
+ Image.new(mode, (32, 32)).save(path)
+ encoded = _image_to_base64(str(path))
+ with Image.open(io.BytesIO(base64.standard_b64decode(encoded))) as img:
+ assert img.mode == "RGB"
+
+
+class TestCaption:
+ def test_returns_the_model_response(self, fake_openai, tmp_path):
+ captioner, _ = fake_openai(responses=["a red square on white"])
+ assert captioner.caption(make_image(tmp_path / "a.png")) == "a red square on white"
+
+ def test_sends_model_and_token_limit(self, fake_openai, tmp_path):
+ captioner, state = fake_openai(max_tokens=123)
+ captioner.caption(make_image(tmp_path / "a.png"))
+ call = state["completions"].calls[0]
+ assert call["model"] == "vision-model"
+ assert call["max_tokens"] == 123
+
+ def test_sends_the_image_as_a_data_url_and_the_default_prompt(self, fake_openai, tmp_path):
+ captioner, state = fake_openai()
+ captioner.caption(make_image(tmp_path / "a.png"))
+ content = state["completions"].calls[0]["messages"][0]["content"]
+ image_part = next(p for p in content if p["type"] == "image_url")
+ text_part = next(p for p in content if p["type"] == "text")
+ assert image_part["image_url"]["url"].startswith("data:image/jpeg;base64,")
+ assert text_part["text"] == DEFAULT_CAPTION_PROMPT
+
+ def test_custom_prompt_overrides_the_default(self, fake_openai, tmp_path):
+ captioner, state = fake_openai()
+ captioner.caption(make_image(tmp_path / "a.png"), prompt="just the colours")
+ content = state["completions"].calls[0]["messages"][0]["content"]
+ assert next(p for p in content if p["type"] == "text")["text"] == "just the colours"
+
+ def test_credentials_are_passed_to_the_client(self, fake_openai, tmp_path):
+ _, state = fake_openai()
+ assert state["api_key"] == "secret"
+ assert state["base_url"] == "https://example.invalid/v1"
+
+
+class TestCaptionBatch:
+ def test_maps_every_path_to_a_caption(self, fake_openai, tmp_path):
+ captioner, _ = fake_openai(responses=["one", "two"])
+ paths = [make_image(tmp_path / "a.png"), make_image(tmp_path / "b.png")]
+ assert captioner.caption_batch(paths) == {paths[0]: "one", paths[1]: "two"}
+
+ def test_skips_failures_by_default(self, fake_openai, tmp_path):
+ captioner, _ = fake_openai(responses=["ok", "ok"], errors={0: RuntimeError("boom")})
+ paths = [make_image(tmp_path / "a.png"), make_image(tmp_path / "b.png")]
+ result = captioner.caption_batch(paths)
+ assert result[paths[0]] == ""
+ assert result[paths[1]] == "ok"
+
+ def test_raises_when_asked_to(self, fake_openai, tmp_path):
+ captioner, _ = fake_openai(responses=["ok"], errors={0: RuntimeError("boom")})
+ with pytest.raises(RuntimeError, match="boom"):
+ captioner.caption_batch([make_image(tmp_path / "a.png")], on_error="raise")
+
+ def test_empty_input_makes_no_calls(self, fake_openai):
+ captioner, state = fake_openai()
+ assert captioner.caption_batch([]) == {}
+ assert state["completions"].calls == []
+
+
+class TestExtractMetadata:
+ def test_parses_a_json_response(self, fake_openai, tmp_path):
+ captioner, _ = fake_openai(responses=['{"caption": "a cat", "tags": ["pet"]}'])
+ assert captioner.extract_metadata(make_image(tmp_path / "a.png")) == {
+ "caption": "a cat", "tags": ["pet"],
+ }
+
+ def test_strips_a_markdown_code_fence(self, fake_openai, tmp_path):
+ captioner, _ = fake_openai(responses=['```json\n{"caption": "a cat"}\n```'])
+ assert captioner.extract_metadata(make_image(tmp_path / "a.png")) == {"caption": "a cat"}
+
+ def test_falls_back_to_raw_text_on_invalid_json(self, fake_openai, tmp_path):
+ captioner, _ = fake_openai(responses=["not json at all"])
+ result = captioner.extract_metadata(make_image(tmp_path / "a.png"))
+ assert result == {"caption": "not json at all", "raw_response": True}
+
+ def test_uses_the_metadata_prompt(self, fake_openai, tmp_path):
+ captioner, state = fake_openai(responses=["{}"])
+ captioner.extract_metadata(make_image(tmp_path / "a.png"))
+ content = state["completions"].calls[0]["messages"][0]["content"]
+ assert next(p for p in content if p["type"] == "text")["text"] == DEFAULT_METADATA_PROMPT
+
+
+def test_missing_openai_dependency_gives_an_actionable_error(monkeypatch):
+ monkeypatch.setitem(sys.modules, "openai", None) # forces ImportError on `from openai import ...`
+ with pytest.raises(ImportError, match=r"DeepImageSearch\[llm\]"):
+ Captioner(model="m", api_key="k", base_url="u")
diff --git a/tests/test_embedding_backends.py b/tests/test_embedding_backends.py
new file mode 100644
index 0000000..f6b794b
--- /dev/null
+++ b/tests/test_embedding_backends.py
@@ -0,0 +1,200 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for the CLIP and timm embedding backends.
+
+Both normally download weights. Here `open_clip` and `timm` are replaced with
+fakes returning fixed tensors, so the code the backends actually own — device
+selection, batching, L2 normalisation, dtype, dimension probing — is exercised
+without touching the network.
+"""
+
+import sys
+import types
+
+import numpy as np
+import pytest
+import torch
+from PIL import Image
+
+from DeepImageSearch.core.embeddings import CLIPEmbedding, TimmEmbedding
+
+CLIP_DIM = 6
+TIMM_DIM = 5
+
+
+class FakeClipModel:
+ def __init__(self):
+ self.eval_called = False
+ self.image_batches = []
+ self.text_batches = []
+
+ def eval(self):
+ self.eval_called = True
+ return self
+
+ def encode_image(self, tensors):
+ self.image_batches.append(len(tensors))
+ # Rows of ascending magnitude, so normalisation is observable.
+ scale = torch.arange(1, len(tensors) + 1, dtype=torch.float32).unsqueeze(1)
+ return torch.ones(len(tensors), CLIP_DIM) * scale
+
+ def encode_text(self, tokens):
+ self.text_batches.append(len(tokens))
+ return torch.ones(len(tokens), CLIP_DIM) * 3.0
+
+
+@pytest.fixture
+def fake_open_clip(monkeypatch):
+ state = {"model": FakeClipModel(), "created_with": None, "tokenizer_for": None}
+
+ def create_model_and_transforms(model_name, pretrained=None, device=None):
+ state["created_with"] = {"model_name": model_name, "pretrained": pretrained, "device": device}
+ return state["model"], None, lambda img: torch.zeros(3, 4, 4)
+
+ def get_tokenizer(model_name):
+ state["tokenizer_for"] = model_name
+ return lambda batch: torch.zeros(len(batch), 3)
+
+ module = types.ModuleType("open_clip")
+ module.create_model_and_transforms = create_model_and_transforms
+ module.get_tokenizer = get_tokenizer
+ monkeypatch.setitem(sys.modules, "open_clip", module)
+ return state
+
+
+@pytest.fixture
+def fake_timm(monkeypatch):
+ state = {"created_with": None}
+
+ class FakeChild(torch.nn.Module):
+ def forward(self, x):
+ return torch.ones(x.shape[0], TIMM_DIM) * 2.0
+
+ class FakeBackbone(torch.nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.body = FakeChild()
+ self.classifier = torch.nn.Identity() # dropped by children()[:-1]
+
+ def create_model(model_name, pretrained=True):
+ state["created_with"] = {"model_name": model_name, "pretrained": pretrained}
+ return FakeBackbone()
+
+ module = types.ModuleType("timm")
+ module.create_model = create_model
+ monkeypatch.setitem(sys.modules, "timm", module)
+ return state
+
+
+def _ignore_device_placement(monkeypatch):
+ """Let the backends "allocate" on cuda/mps on a machine that has neither."""
+ real_zeros = torch.zeros
+ monkeypatch.setattr(torch, "zeros", lambda *a, **kw: real_zeros(*a, **{**kw, "device": "cpu"}))
+
+
+def images(count=1):
+ return [Image.new("RGB", (8, 8), (255, 0, 0)) for _ in range(count)]
+
+
+class TestClipEmbedding:
+ def test_probes_its_dimension_at_load_time(self, fake_open_clip):
+ assert CLIPEmbedding(device="cpu").dimension == CLIP_DIM
+
+ def test_puts_the_model_in_eval_mode(self, fake_open_clip):
+ CLIPEmbedding(device="cpu")
+ assert fake_open_clip["model"].eval_called is True
+
+ def test_forwards_model_name_and_weights_tag(self, fake_open_clip):
+ CLIPEmbedding(model_name="ViT-L-14", pretrained="laion2b", device="cpu")
+ assert fake_open_clip["created_with"]["model_name"] == "ViT-L-14"
+ assert fake_open_clip["created_with"]["pretrained"] == "laion2b"
+ assert fake_open_clip["tokenizer_for"] == "ViT-L-14"
+
+ def test_declares_text_support(self, fake_open_clip):
+ assert CLIPEmbedding(device="cpu").supports_text is True
+
+ def test_image_embeddings_are_unit_length_float32(self, fake_open_clip):
+ vectors = CLIPEmbedding(device="cpu").embed_images(images(3))
+ assert vectors.shape == (3, CLIP_DIM)
+ assert vectors.dtype == np.float32
+ assert np.allclose(np.linalg.norm(vectors, axis=1), 1.0, atol=1e-6)
+
+ def test_text_embeddings_are_unit_length_float32(self, fake_open_clip):
+ vectors = CLIPEmbedding(device="cpu").embed_texts(["a cat", "a dog"])
+ assert vectors.shape == (2, CLIP_DIM)
+ assert vectors.dtype == np.float32
+ assert np.allclose(np.linalg.norm(vectors, axis=1), 1.0, atol=1e-6)
+
+ def test_images_are_processed_in_batches(self, fake_open_clip):
+ embedding = CLIPEmbedding(device="cpu", batch_size=2)
+ probe_calls = len(fake_open_clip["model"].image_batches) # dimension probe
+ embedding.embed_images(images(5))
+ assert fake_open_clip["model"].image_batches[probe_calls:] == [2, 2, 1]
+
+ def test_texts_are_processed_in_batches(self, fake_open_clip):
+ CLIPEmbedding(device="cpu", batch_size=2).embed_texts(["a", "b", "c"])
+ assert fake_open_clip["model"].text_batches == [2, 1]
+
+ def test_batching_does_not_change_the_result(self, fake_open_clip):
+ one_shot = CLIPEmbedding(device="cpu", batch_size=64).embed_images(images(4))
+ batched = CLIPEmbedding(device="cpu", batch_size=2).embed_images(images(4))
+ assert np.allclose(one_shot, batched)
+
+ @pytest.mark.parametrize(
+ ("cuda", "mps", "expected"),
+ [(True, False, "cuda"), (False, True, "mps"), (False, False, "cpu")],
+ )
+ def test_device_auto_detection(self, fake_open_clip, monkeypatch, cuda, mps, expected):
+ monkeypatch.setattr(torch.cuda, "is_available", lambda: cuda)
+ monkeypatch.setattr(torch.backends.mps, "is_available", lambda: mps)
+ _ignore_device_placement(monkeypatch)
+ # Only the device *decision* is under test — no tensor really moves.
+ assert CLIPEmbedding().device == expected
+
+ def test_explicit_device_wins_over_detection(self, fake_open_clip, monkeypatch):
+ monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
+ assert CLIPEmbedding(device="cpu").device == "cpu"
+
+
+class TestTimmEmbedding:
+ def test_probes_its_dimension_at_load_time(self, fake_timm):
+ assert TimmEmbedding(device="cpu").dimension == TIMM_DIM
+
+ def test_does_not_claim_text_support(self, fake_timm):
+ embedding = TimmEmbedding(device="cpu")
+ assert embedding.supports_text is False
+ with pytest.raises(NotImplementedError):
+ embedding.embed_texts(["a cat"])
+
+ def test_forwards_model_name_and_pretrained_flag(self, fake_timm):
+ TimmEmbedding(model_name="resnet50", pretrained=False, device="cpu")
+ assert fake_timm["created_with"] == {"model_name": "resnet50", "pretrained": False}
+
+ def test_drops_the_classifier_head(self, fake_timm):
+ # children()[:-1] must leave the feature body only.
+ assert len(list(TimmEmbedding(device="cpu").model.children())) == 1
+
+ def test_embeddings_are_unit_length_float32(self, fake_timm):
+ vectors = TimmEmbedding(device="cpu").embed_images(images(2))
+ assert vectors.shape == (2, TIMM_DIM)
+ assert vectors.dtype == np.float32
+ assert np.allclose(np.linalg.norm(vectors, axis=1), 1.0, atol=1e-6)
+
+ def test_images_are_processed_in_batches(self, fake_timm):
+ vectors = TimmEmbedding(device="cpu", batch_size=2).embed_images(images(5))
+ assert vectors.shape == (5, TIMM_DIM)
+
+ def test_image_size_is_honoured(self, fake_timm):
+ assert TimmEmbedding(device="cpu", image_size=384).image_size == 384
+
+ @pytest.mark.parametrize(
+ ("cuda", "mps", "expected"),
+ [(True, False, "cuda"), (False, True, "mps"), (False, False, "cpu")],
+ )
+ def test_device_auto_detection(self, fake_timm, monkeypatch, cuda, mps, expected):
+ monkeypatch.setattr(torch.cuda, "is_available", lambda: cuda)
+ monkeypatch.setattr(torch.backends.mps, "is_available", lambda: mps)
+ monkeypatch.setattr(torch.nn.Module, "to", lambda self, device: self)
+ _ignore_device_placement(monkeypatch)
+ assert TimmEmbedding().device == expected
diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py
new file mode 100644
index 0000000..3915ef9
--- /dev/null
+++ b/tests/test_embeddings.py
@@ -0,0 +1,166 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for the embedding layer.
+
+CLIPEmbedding and TimmEmbedding download weights, so they are not instantiated
+here — the factory's routing logic is tested by monkeypatching the backends.
+"""
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from DeepImageSearch.core import embeddings as emb_module
+from DeepImageSearch.core.embeddings import (
+ CLIP_PRESETS,
+ BaseEmbedding,
+ CustomEmbedding,
+ EmbeddingManager,
+)
+
+
+class TestCustomEmbedding:
+ def test_normalises_output_to_unit_length(self):
+ emb = CustomEmbedding(extract_fn=lambda img: [3.0, 4.0], dimension=2)
+ vectors = emb.embed_images([Image.new("RGB", (4, 4))])
+ assert np.linalg.norm(vectors[0]) == pytest.approx(1.0, abs=1e-6)
+ assert vectors[0].tolist() == pytest.approx([0.6, 0.8])
+
+ def test_zero_vector_is_left_alone_rather_than_dividing_by_zero(self):
+ emb = CustomEmbedding(extract_fn=lambda img: [0.0, 0.0], dimension=2)
+ vectors = emb.embed_images([Image.new("RGB", (4, 4))])
+ assert vectors[0].tolist() == [0.0, 0.0]
+ assert not np.isnan(vectors).any()
+
+ def test_flattens_multidimensional_output(self):
+ emb = CustomEmbedding(extract_fn=lambda img: np.ones((1, 4)), dimension=4)
+ assert emb.embed_images([Image.new("RGB", (4, 4))]).shape == (1, 4)
+
+ def test_returns_float32(self):
+ emb = CustomEmbedding(extract_fn=lambda img: np.ones(4, dtype=np.float64), dimension=4)
+ assert emb.embed_images([Image.new("RGB", (4, 4))]).dtype == np.float32
+
+ def test_stacks_a_batch(self):
+ emb = CustomEmbedding(extract_fn=lambda img: [1.0, 0.0], dimension=2)
+ assert emb.embed_images([Image.new("RGB", (4, 4))] * 3).shape == (3, 2)
+
+ def test_extractor_receives_rgb_even_for_greyscale_input(self):
+ seen = {}
+
+ def extractor(img):
+ seen["mode"] = img.mode
+ return [1.0, 0.0]
+
+ CustomEmbedding(extract_fn=extractor, dimension=2).embed_images([Image.new("L", (4, 4))])
+ assert seen["mode"] == "RGB"
+
+ def test_does_not_claim_text_support(self):
+ emb = CustomEmbedding(extract_fn=lambda img: [1.0], dimension=1)
+ assert emb.supports_text is False
+ with pytest.raises(NotImplementedError):
+ emb.embed_text("a cat")
+
+
+class TestBaseEmbeddingConvenienceMethods:
+ def test_embed_image_returns_a_1d_vector(self, embedding):
+ vector = embedding.embed_image(Image.new("RGB", (4, 4), (255, 0, 0)))
+ assert vector.ndim == 1
+ assert vector.shape == (embedding.dimension,)
+
+ def test_embed_text_returns_a_1d_vector(self, embedding):
+ vector = embedding.embed_text("a red square")
+ assert vector.ndim == 1
+ assert vector.shape == (embedding.dimension,)
+
+ def test_text_embedding_raises_by_default(self):
+ class ImageOnly(BaseEmbedding):
+ dimension = 2
+
+ def embed_images(self, images):
+ return np.zeros((len(images), 2), dtype=np.float32)
+
+ with pytest.raises(NotImplementedError, match="does not support text"):
+ ImageOnly().embed_texts(["a cat"])
+
+ def test_base_embedding_cannot_be_instantiated_directly(self):
+ with pytest.raises(TypeError):
+ BaseEmbedding()
+
+
+class TestPresets:
+ def test_list_presets_matches_the_preset_table(self):
+ assert EmbeddingManager.list_presets() == CLIP_PRESETS
+
+ def test_list_presets_returns_a_defensive_copy(self):
+ presets = EmbeddingManager.list_presets()
+ presets["clip-vit-b-32"] = ("tampered", "tampered")
+ assert CLIP_PRESETS["clip-vit-b-32"] == ("ViT-B-32", "openai")
+
+ def test_documented_presets_are_present(self):
+ # These names appear in the README and Documents/; removing one breaks users.
+ for name in ["clip-vit-b-32", "clip-vit-b-16", "clip-vit-l-14", "siglip-vit-b-16"]:
+ assert name in CLIP_PRESETS
+
+ def test_every_preset_is_a_model_pretrained_pair(self):
+ for name, value in CLIP_PRESETS.items():
+ assert name == name.lower()
+ assert isinstance(value, tuple) and len(value) == 2
+ assert all(isinstance(part, str) and part for part in value)
+
+
+class TestFactoryRouting:
+ """EmbeddingManager.create() picks a backend from the name — verify the routing only."""
+
+ @pytest.fixture
+ def spy(self, monkeypatch):
+ calls = {}
+
+ class FakeCLIP:
+ def __init__(self, **kwargs):
+ calls["backend"] = "clip"
+ calls["kwargs"] = kwargs
+
+ class FakeTimm:
+ def __init__(self, **kwargs):
+ calls["backend"] = "timm"
+ calls["kwargs"] = kwargs
+
+ monkeypatch.setattr(emb_module, "CLIPEmbedding", FakeCLIP)
+ monkeypatch.setattr(emb_module, "TimmEmbedding", FakeTimm)
+ return calls
+
+ def test_preset_name_resolves_to_clip_with_preset_weights(self, spy):
+ EmbeddingManager.create("clip-vit-b-32")
+ assert spy["backend"] == "clip"
+ assert spy["kwargs"]["model_name"] == "ViT-B-32"
+ assert spy["kwargs"]["pretrained"] == "openai"
+
+ def test_preset_lookup_is_case_and_whitespace_insensitive(self, spy):
+ EmbeddingManager.create(" CLIP-ViT-B-32 ")
+ assert spy["kwargs"]["model_name"] == "ViT-B-32"
+
+ @pytest.mark.parametrize("name", ["ViT-B-32", "siglip-custom", "eva-something"])
+ def test_clip_like_names_route_to_clip(self, spy, name):
+ EmbeddingManager.create(name)
+ assert spy["backend"] == "clip"
+ assert spy["kwargs"]["model_name"] == name
+
+ @pytest.mark.parametrize("name", ["vgg19", "resnet50", "efficientnet_b0"])
+ def test_other_names_fall_back_to_timm(self, spy, name):
+ EmbeddingManager.create(name)
+ assert spy["backend"] == "timm"
+ assert spy["kwargs"]["model_name"] == name
+
+ def test_device_and_batch_size_are_forwarded(self, spy):
+ EmbeddingManager.create("clip-vit-b-32", device="cpu", batch_size=8)
+ assert spy["kwargs"]["device"] == "cpu"
+ assert spy["kwargs"]["batch_size"] == 8
+
+ def test_timm_specific_kwargs_are_forwarded(self, spy):
+ EmbeddingManager.create("resnet50", image_size=384)
+ assert spy["kwargs"]["image_size"] == 384
+
+ def test_default_model_is_a_known_preset(self, spy):
+ EmbeddingManager.create()
+ assert spy["kwargs"]["model_name"] == CLIP_PRESETS["clip-vit-b-32"][0]
diff --git a/tests/test_indexer_searcher.py b/tests/test_indexer_searcher.py
new file mode 100644
index 0000000..be7aa4f
--- /dev/null
+++ b/tests/test_indexer_searcher.py
@@ -0,0 +1,247 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Integration tests for the index → search pipeline.
+
+These wire the real Indexer, FAISSStore, JsonMetadataStore and Searcher
+together, substituting only the embedding backend so no weights are downloaded.
+"""
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from DeepImageSearch.core.indexer import Indexer, _path_to_id
+from DeepImageSearch.core.searcher import Searcher
+from DeepImageSearch.metadatastore.json_store import JsonMetadataStore
+from DeepImageSearch.vectorstores.faiss_store import FAISSStore
+from tests.conftest import DIM, make_image
+
+
+@pytest.fixture
+def pipeline(embedding):
+ """A ready-to-use (indexer, searcher, vector store, metadata store) bundle."""
+ store = FAISSStore(dimension=DIM)
+ records = JsonMetadataStore()
+ indexer = Indexer(embedding=embedding, vector_store=store, metadata_store=records)
+ searcher = Searcher(embedding=embedding, vector_store=store)
+ return indexer, searcher, store, records
+
+
+class TestIndexing:
+ def test_indexes_every_image(self, pipeline, image_paths):
+ indexer, _, store, records = pipeline
+ assert indexer.index(image_paths) == len(image_paths)
+ assert store.count() == len(image_paths)
+ assert records.count() == len(image_paths)
+
+ def test_records_carry_path_name_and_index(self, pipeline, image_paths):
+ indexer, _, _, records = pipeline
+ indexer.index(image_paths)
+ stored = records.list_all()
+ assert [r.image_index for r in stored] == list(range(len(image_paths)))
+ assert {r.image_path for r in stored} == set(image_paths)
+ assert all(r.image_name and r.indexed_at for r in stored)
+
+ def test_image_id_is_a_deterministic_function_of_path(self, pipeline, image_paths):
+ indexer, _, _, records = pipeline
+ indexer.index(image_paths)
+ assert records.get(_path_to_id(image_paths[0])) is not None
+
+ def test_empty_input_indexes_nothing(self, pipeline):
+ indexer, _, store, _ = pipeline
+ assert indexer.index([]) == 0
+ assert store.count() == 0
+
+ def test_unreadable_images_are_skipped_not_fatal(self, pipeline, image_paths, tmp_path):
+ indexer, _, store, _ = pipeline
+ broken = tmp_path / "broken.png"
+ broken.write_bytes(b"not a png")
+ assert indexer.index(image_paths + [str(broken)]) == len(image_paths)
+ assert store.count() == len(image_paths)
+
+ def test_batching_covers_every_image(self, embedding, image_paths):
+ store = FAISSStore(dimension=DIM)
+ indexer = Indexer(embedding=embedding, vector_store=store, batch_size=2)
+ assert indexer.index(image_paths) == len(image_paths)
+ assert embedding.embed_images_calls > 1 # genuinely batched
+ assert store.count() == len(image_paths)
+
+ def test_works_without_a_metadata_store(self, embedding, image_paths):
+ store = FAISSStore(dimension=DIM)
+ assert Indexer(embedding=embedding, vector_store=store).index(image_paths) == len(image_paths)
+
+ def test_mismatched_extra_metadata_length_raises(self, pipeline, image_paths):
+ indexer, _, _, _ = pipeline
+ with pytest.raises(ValueError, match="must match"):
+ indexer.index(image_paths, extra_metadata=[{"tag": "x"}])
+
+
+class TestIncrementalIndexing:
+ def test_add_images_continues_the_index_sequence(self, pipeline, image_paths, tmp_path):
+ indexer, _, store, records = pipeline
+ indexer.index(image_paths)
+
+ later = make_image(tmp_path / "later.png", (10, 20, 30))
+ indexer.add_images([later])
+
+ assert store.count() == len(image_paths) + 1
+ assert [r.image_index for r in records.list_all()] == list(range(len(image_paths) + 1))
+ assert records.get(_path_to_id(later)).image_index == len(image_paths)
+
+
+class TestSearch:
+ def test_image_search_ranks_the_query_image_first(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ results = searcher.search_by_image(image_paths[0], k=3)
+ assert results[0]["metadata"]["image_path"] == image_paths[0]
+
+ def test_results_expose_path_score_and_id(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ result = searcher.search_by_image(image_paths[0], k=1)[0]
+ assert set(result) == {"id", "score", "metadata"}
+ assert result["metadata"]["image_path"] in image_paths
+
+ def test_text_search_returns_k_results(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ assert len(searcher.search_by_text("a red square", k=2)) == 2
+
+ def test_text_search_rejects_image_only_backends(self, image_only_embedding, image_paths):
+ store = FAISSStore(dimension=DIM)
+ Indexer(embedding=image_only_embedding, vector_store=store).index(image_paths)
+ searcher = Searcher(embedding=image_only_embedding, vector_store=store)
+ with pytest.raises(ValueError, match="CLIP-family"):
+ searcher.search_by_text("a red square")
+
+ def test_precomputed_vector_query_is_used_as_is(self, pipeline, image_paths, embedding):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ vector = embedding.embed_image(Image.open(image_paths[0]))
+ assert searcher.search(vector, k=1)[0]["metadata"]["image_path"] == image_paths[0]
+
+ def test_pil_image_query(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ with Image.open(image_paths[0]) as img:
+ results = searcher.search(img, k=1)
+ assert results[0]["metadata"]["image_path"] == image_paths[0]
+
+ def test_unsupported_query_type_raises(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ with pytest.raises(TypeError, match="Unsupported query type"):
+ searcher.search(42)
+
+ def test_auto_mode_treats_a_path_as_an_image(self, pipeline, image_paths, embedding):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ before = embedding.embed_texts_calls
+ searcher.search(image_paths[0], k=1)
+ assert embedding.embed_texts_calls == before # went down the image path
+
+ def test_auto_mode_treats_a_sentence_as_text(self, pipeline, image_paths, embedding):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ before = embedding.embed_texts_calls
+ searcher.search("a photograph of a red square", k=1)
+ assert embedding.embed_texts_calls == before + 1
+
+ def test_text_mode_forces_text_even_for_pathlike_strings(self, pipeline, image_paths, embedding):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ before = embedding.embed_texts_calls
+ searcher.search(image_paths[0], k=1, mode="text")
+ assert embedding.embed_texts_calls == before + 1
+
+
+class TestFilteredSearch:
+ def test_filters_narrow_results_to_matching_metadata(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ tags = [{"tag": "keep"}] + [{"tag": "drop"}] * (len(image_paths) - 1)
+ indexer.index(image_paths, extra_metadata=tags)
+ results = searcher.search_by_image(image_paths[0], k=5, filters={"tag": "keep"})
+ assert len(results) == 1
+ assert results[0]["metadata"]["image_path"] == image_paths[0]
+
+ def test_extra_metadata_is_stored_on_the_record(self, pipeline, image_paths):
+ indexer, _, _, records = pipeline
+ indexer.index(image_paths, extra_metadata=[{"tag": f"t{i}"} for i in range(len(image_paths))])
+ assert records.get(_path_to_id(image_paths[0])).extra == {"tag": "t0"}
+
+
+class TestHybridSearch:
+ def test_combines_text_and_image_queries(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ results = searcher.search("a red square", k=2, mode="hybrid", image_query=image_paths[0])
+ assert len(results) == 2
+
+ def test_weighting_shifts_the_query_vector(self, pipeline, image_paths, embedding):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ text_heavy = searcher.search("a red square", k=3, mode="hybrid",
+ image_query=image_paths[0], text_weight=0.9)
+ image_heavy = searcher.search("a red square", k=3, mode="hybrid",
+ image_query=image_paths[0], text_weight=0.1)
+ assert image_heavy[0]["metadata"]["image_path"] == image_paths[0]
+ assert [r["score"] for r in text_heavy] != [r["score"] for r in image_heavy]
+
+ def test_accepts_a_pil_image_as_the_image_query(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ with Image.open(image_paths[0]) as img:
+ assert searcher.search("a red square", k=1, mode="hybrid", image_query=img)
+
+ def test_missing_image_query_raises(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ with pytest.raises(ValueError, match="image_query must be provided"):
+ searcher.search("a red square", k=1, mode="hybrid")
+
+ def test_non_text_primary_query_raises(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ with pytest.raises(ValueError, match="must be a text string"):
+ searcher.search(np.zeros(DIM, dtype=np.float32), k=1, mode="hybrid", image_query=image_paths[0])
+
+ def test_bad_image_query_type_raises(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ with pytest.raises(ValueError, match="image_query must be"):
+ searcher.search("a red square", k=1, mode="hybrid", image_query=123)
+
+ def test_rejected_on_image_only_backends(self, image_only_embedding, image_paths):
+ store = FAISSStore(dimension=DIM)
+ Indexer(embedding=image_only_embedding, vector_store=store).index(image_paths)
+ searcher = Searcher(embedding=image_only_embedding, vector_store=store)
+ with pytest.raises(ValueError, match="Hybrid search requires"):
+ searcher.search("a red square", mode="hybrid", image_query=image_paths[0])
+
+
+class TestBackwardCompatibleApi:
+ def test_get_similar_images_returns_index_to_path_mapping(self, pipeline, image_paths):
+ indexer, searcher, _, _ = pipeline
+ indexer.index(image_paths)
+ similar = searcher.get_similar_images(image_paths[0], number_of_images=2)
+ assert list(similar) == [0, 1]
+ assert similar[0] == image_paths[0]
+
+
+class TestPersistenceAcrossSessions:
+ def test_saved_index_returns_the_same_results_after_reload(self, pipeline, image_paths, tmp_path, embedding):
+ indexer, _, store, records = pipeline
+ indexer.index(image_paths)
+ store.save(str(tmp_path))
+ records.save(str(tmp_path))
+
+ reloaded_store = FAISSStore(dimension=DIM)
+ reloaded_store.load(str(tmp_path))
+ reloaded_records = JsonMetadataStore()
+ reloaded_records.load(str(tmp_path))
+
+ results = Searcher(embedding=embedding, vector_store=reloaded_store).search_by_image(image_paths[0], k=1)
+ assert results[0]["metadata"]["image_path"] == image_paths[0]
+ assert reloaded_records.count() == len(image_paths)
diff --git a/tests/test_loader.py b/tests/test_loader.py
new file mode 100644
index 0000000..8762737
--- /dev/null
+++ b/tests/test_loader.py
@@ -0,0 +1,123 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""Tests for DeepImageSearch.data.loader.Load_Data."""
+
+import csv
+
+import pytest
+
+from DeepImageSearch.data.loader import VALID_IMAGE_EXTENSIONS, Load_Data
+from tests.conftest import make_image
+
+
+class TestFromFolder:
+ def test_recursive_finds_nested_images(self, image_dir):
+ paths = Load_Data().from_folder([str(image_dir)])
+ assert len(paths) == 4
+ assert any("yellow.png" in p for p in paths)
+
+ def test_non_recursive_skips_nested(self, image_dir):
+ paths = Load_Data().from_folder([str(image_dir)], recursive=False)
+ assert len(paths) == 3
+ assert not any("yellow.png" in p for p in paths)
+
+ def test_ignores_non_image_extensions(self, image_dir):
+ (image_dir / "notes.txt").write_text("not an image")
+ paths = Load_Data().from_folder([str(image_dir)])
+ assert not any(p.endswith(".txt") for p in paths)
+
+ def test_skips_corrupt_image_when_validating(self, image_dir):
+ (image_dir / "broken.png").write_bytes(b"definitely not a png")
+ paths = Load_Data().from_folder([str(image_dir)], validate=True)
+ assert not any("broken.png" in p for p in paths)
+
+ def test_keeps_corrupt_image_when_not_validating(self, image_dir):
+ (image_dir / "broken.png").write_bytes(b"definitely not a png")
+ paths = Load_Data().from_folder([str(image_dir)], validate=False)
+ assert any("broken.png" in p for p in paths)
+
+ def test_missing_folder_is_skipped_not_fatal(self, image_dir):
+ paths = Load_Data().from_folder([str(image_dir), "/no/such/folder"])
+ assert len(paths) == 4
+
+ def test_file_passed_as_folder_is_skipped(self, tmp_path, image_dir):
+ a_file = make_image(tmp_path / "loose.png")
+ paths = Load_Data().from_folder([a_file])
+ assert paths == []
+
+ def test_empty_list_raises(self):
+ with pytest.raises(ValueError, match="cannot be empty"):
+ Load_Data().from_folder([])
+
+ def test_non_list_raises(self, image_dir):
+ with pytest.raises(TypeError, match="must be a list"):
+ Load_Data().from_folder(str(image_dir))
+
+ def test_all_documented_extensions_are_lowercase(self):
+ assert all(ext == ext.lower() and ext.startswith(".") for ext in VALID_IMAGE_EXTENSIONS)
+
+ def test_uppercase_extension_is_matched(self, tmp_path):
+ folder = tmp_path / "upper"
+ folder.mkdir()
+ make_image(folder / "SHOUT.PNG")
+ assert len(Load_Data().from_folder([str(folder)])) == 1
+
+
+class TestFromCsv:
+ def _write_csv(self, path, rows, column="image"):
+ with open(path, "w", newline="", encoding="utf-8") as f:
+ writer = csv.DictWriter(f, fieldnames=[column, "label"])
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+ return str(path)
+
+ def test_reads_existing_paths(self, tmp_path, image_paths):
+ csv_path = self._write_csv(
+ tmp_path / "data.csv",
+ [{"image": p, "label": "x"} for p in image_paths],
+ )
+ assert Load_Data().from_csv(csv_path, "image") == image_paths
+
+ def test_skips_missing_and_blank_paths(self, tmp_path, image_paths):
+ rows = [{"image": image_paths[0], "label": "x"},
+ {"image": "/no/such/image.png", "label": "y"},
+ {"image": " ", "label": "z"}]
+ csv_path = self._write_csv(tmp_path / "data.csv", rows)
+ assert Load_Data().from_csv(csv_path, "image") == [image_paths[0]]
+
+ def test_strips_surrounding_whitespace(self, tmp_path, image_paths):
+ csv_path = self._write_csv(tmp_path / "data.csv", [{"image": f" {image_paths[0]} ", "label": "x"}])
+ assert Load_Data().from_csv(csv_path, "image") == [image_paths[0]]
+
+ def test_missing_file_raises(self):
+ with pytest.raises(FileNotFoundError):
+ Load_Data().from_csv("/no/such/file.csv", "image")
+
+ def test_unknown_column_raises(self, tmp_path, image_paths):
+ csv_path = self._write_csv(tmp_path / "data.csv", [{"image": image_paths[0], "label": "x"}])
+ with pytest.raises(ValueError, match="not found"):
+ Load_Data().from_csv(csv_path, "picture")
+
+
+class TestFromList:
+ def test_validates_and_returns_paths(self, image_paths):
+ assert Load_Data().from_list(image_paths) == image_paths
+
+ def test_skips_missing_files(self, image_paths):
+ result = Load_Data().from_list(image_paths + ["/no/such/image.png"])
+ assert result == image_paths
+
+ def test_skips_corrupt_when_validating(self, tmp_path, image_paths):
+ broken = tmp_path / "broken.png"
+ broken.write_bytes(b"not a png")
+ assert str(broken) not in Load_Data().from_list(image_paths + [str(broken)])
+
+ def test_keeps_corrupt_when_not_validating(self, tmp_path, image_paths):
+ broken = tmp_path / "broken.png"
+ broken.write_bytes(b"not a png")
+ result = Load_Data().from_list(image_paths + [str(broken)], validate=False)
+ assert str(broken) in result
+
+ def test_empty_list_returns_empty(self):
+ assert Load_Data().from_list([]) == []
diff --git a/tests/test_metadata_store.py b/tests/test_metadata_store.py
new file mode 100644
index 0000000..9d9feec
--- /dev/null
+++ b/tests/test_metadata_store.py
@@ -0,0 +1,179 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""Tests for ImageRecord and JsonMetadataStore."""
+
+import json
+import os
+
+import pytest
+
+from DeepImageSearch.metadatastore.base import BaseMetadataStore, ImageRecord
+from DeepImageSearch.metadatastore.json_store import RECORDS_FILENAME, JsonMetadataStore
+
+
+def record(index: int, image_id: str = None, **kwargs) -> ImageRecord:
+ return ImageRecord(
+ image_id=image_id or f"id{index}",
+ image_index=index,
+ image_name=f"img{index}.png",
+ image_path=f"/images/img{index}.png",
+ indexed_at="2026-01-01T00:00:00+00:00",
+ **kwargs,
+ )
+
+
+class TestImageRecord:
+ def test_roundtrip_through_dict(self):
+ original = record(1, caption="a cat", extra={"tag": "pet"})
+ assert ImageRecord.from_dict(original.to_dict()) == original
+
+ def test_from_dict_tolerates_missing_optional_fields(self):
+ rec = ImageRecord.from_dict({
+ "image_id": "abc",
+ "image_index": 0,
+ "image_name": "a.png",
+ "image_path": "/a.png",
+ })
+ assert rec.caption is None
+ assert rec.indexed_at == ""
+ assert rec.extra == {}
+
+ def test_from_dict_requires_core_fields(self):
+ with pytest.raises(KeyError):
+ ImageRecord.from_dict({"image_id": "abc"})
+
+ def test_extra_defaults_are_not_shared_between_instances(self):
+ first, second = record(1), record(2)
+ first.extra["tag"] = "only-first"
+ assert second.extra == {}
+
+
+class TestJsonMetadataStore:
+ def test_add_and_get(self):
+ store = JsonMetadataStore()
+ store.add([record(0), record(1)])
+ assert store.count() == 2
+ assert store.get("id0").image_name == "img0.png"
+
+ def test_get_unknown_id_returns_none(self):
+ assert JsonMetadataStore().get("nope") is None
+
+ def test_get_by_index(self):
+ store = JsonMetadataStore()
+ store.add([record(0), record(1)])
+ assert store.get_by_index(1).image_id == "id1"
+ assert store.get_by_index(99) is None
+
+ def test_add_same_id_twice_updates_in_place(self):
+ store = JsonMetadataStore()
+ store.add([record(0)])
+ store.add([record(0, caption="updated")])
+ assert store.count() == 1
+ assert store.get("id0").caption == "updated"
+
+ def test_list_all_is_sorted_by_index(self):
+ store = JsonMetadataStore()
+ store.add([record(2), record(0), record(1)])
+ assert [r.image_index for r in store.list_all()] == [0, 1, 2]
+
+ def test_delete_removes_only_named_ids(self):
+ store = JsonMetadataStore()
+ store.add([record(0), record(1)])
+ store.delete(["id0"])
+ assert store.count() == 1
+ assert store.get("id0") is None
+
+ def test_delete_unknown_id_is_a_no_op(self):
+ store = JsonMetadataStore()
+ store.add([record(0)])
+ store.delete(["never-added"])
+ assert store.count() == 1
+
+ def test_next_index_on_empty_store(self):
+ assert JsonMetadataStore().next_index() == 0
+
+ def test_next_index_follows_highest_existing(self):
+ store = JsonMetadataStore()
+ store.add([record(0), record(7)])
+ assert store.next_index() == 8
+
+ def test_next_index_after_deleting_the_highest(self):
+ # Reusing a freed index would collide with vectors already in the store,
+ # but next_index() is defined off what remains — pin the actual behaviour.
+ store = JsonMetadataStore()
+ store.add([record(0), record(7)])
+ store.delete(["id7"])
+ assert store.next_index() == 1
+
+
+class TestPersistence:
+ def test_save_then_load_roundtrip(self, tmp_path):
+ store = JsonMetadataStore()
+ store.add([record(0, caption="a cat", extra={"tag": "pet"}), record(1)])
+ store.save(str(tmp_path))
+
+ reloaded = JsonMetadataStore()
+ reloaded.load(str(tmp_path))
+ assert reloaded.count() == 2
+ assert reloaded.get("id0").caption == "a cat"
+ assert reloaded.get("id0").extra == {"tag": "pet"}
+
+ def test_save_creates_directory_and_file(self, tmp_path):
+ target = tmp_path / "nested" / "deeper"
+ store = JsonMetadataStore()
+ store.add([record(0)])
+ store.save(str(target))
+ assert (target / RECORDS_FILENAME).exists()
+
+ def test_saved_file_is_a_json_array_of_records(self, tmp_path):
+ store = JsonMetadataStore()
+ store.add([record(0)])
+ store.save(str(tmp_path))
+ data = json.loads((tmp_path / RECORDS_FILENAME).read_text(encoding="utf-8"))
+ assert isinstance(data, list)
+ assert data[0]["image_id"] == "id0"
+
+ def test_load_from_missing_file_starts_fresh(self, tmp_path):
+ store = JsonMetadataStore()
+ store.load(str(tmp_path))
+ assert store.count() == 0
+
+ def test_load_replaces_existing_records(self, tmp_path):
+ saved = JsonMetadataStore()
+ saved.add([record(0)])
+ saved.save(str(tmp_path))
+
+ store = JsonMetadataStore()
+ store.add([record(5, image_id="stale")])
+ store.load(str(tmp_path))
+ assert store.get("stale") is None
+ assert store.count() == 1
+
+ def test_save_handles_non_ascii_captions(self, tmp_path):
+ store = JsonMetadataStore()
+ store.add([record(0, caption="chat noir — 猫")])
+ store.save(str(tmp_path))
+ reloaded = JsonMetadataStore()
+ reloaded.load(str(tmp_path))
+ assert reloaded.get("id0").caption == "chat noir — 猫"
+
+ def test_save_is_idempotent(self, tmp_path):
+ store = JsonMetadataStore()
+ store.add([record(0)])
+ store.save(str(tmp_path))
+ first = (tmp_path / RECORDS_FILENAME).read_text(encoding="utf-8")
+ store.save(str(tmp_path))
+ assert (tmp_path / RECORDS_FILENAME).read_text(encoding="utf-8") == first
+
+
+def test_json_store_implements_the_full_interface():
+ abstract = BaseMetadataStore.__abstractmethods__
+ assert abstract # guard against the ABC losing its @abstractmethod markers
+ assert not abstract - set(dir(JsonMetadataStore))
+ assert not JsonMetadataStore.__abstractmethods__
+
+
+def test_records_filename_is_stable():
+ # Downstream users load this file directly; renaming it is a breaking change.
+ assert RECORDS_FILENAME == "image_records.json"
+ assert not os.path.isabs(RECORDS_FILENAME)
diff --git a/tests/test_openmp_guard.py b/tests/test_openmp_guard.py
new file mode 100644
index 0000000..5b1bfba
--- /dev/null
+++ b/tests/test_openmp_guard.py
@@ -0,0 +1,133 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""Unit tests for the macOS OpenMP duplicate-runtime mitigation."""
+
+import os
+import sys
+import types
+
+import pytest
+
+from DeepImageSearch import _openmp
+
+
+class FakeFaiss:
+ def __init__(self):
+ self.threads = None
+
+ def omp_set_num_threads(self, n):
+ self.threads = n
+
+
+@pytest.fixture
+def fake_faiss():
+ return FakeFaiss()
+
+
+class TestDetection:
+ def test_non_macos_platforms_are_never_affected(self, monkeypatch):
+ monkeypatch.setattr(sys, "platform", "linux")
+ assert _openmp.has_duplicate_libomp() is False
+
+ def test_reports_true_when_two_distinct_runtimes_exist(self, monkeypatch):
+ monkeypatch.setattr(sys, "platform", "darwin")
+ monkeypatch.setattr(_openmp, "_vendored_libomp_paths", lambda: ["/a/libomp.dylib", "/b/libomp.dylib"])
+ assert _openmp.has_duplicate_libomp() is True
+
+ def test_reports_false_when_both_resolve_to_one_file(self, monkeypatch):
+ # This is what the documented symlink fix produces.
+ monkeypatch.setattr(sys, "platform", "darwin")
+ monkeypatch.setattr(_openmp, "_vendored_libomp_paths", lambda: ["/a/libomp.dylib", "/a/libomp.dylib"])
+ assert _openmp.has_duplicate_libomp() is False
+
+ def test_reports_false_when_only_one_package_vendors_one(self, monkeypatch):
+ monkeypatch.setattr(sys, "platform", "darwin")
+ monkeypatch.setattr(_openmp, "_vendored_libomp_paths", lambda: ["/a/libomp.dylib"])
+ assert _openmp.has_duplicate_libomp() is False
+
+ def test_missing_packages_are_tolerated(self, monkeypatch):
+ monkeypatch.setattr(_openmp, "find_spec", lambda name: None)
+ assert _openmp._vendored_libomp_paths() == []
+
+ def test_unimportable_packages_are_tolerated(self, monkeypatch):
+ def explode(name):
+ raise ValueError("no spec")
+
+ monkeypatch.setattr(_openmp, "find_spec", explode)
+ assert _openmp._vendored_libomp_paths() == []
+
+ def test_namespace_packages_without_an_origin_are_skipped(self, monkeypatch):
+ monkeypatch.setattr(_openmp, "find_spec", lambda name: types.SimpleNamespace(origin=None))
+ assert _openmp._vendored_libomp_paths() == []
+
+
+class TestEnvironmentConfiguration:
+ def test_sets_the_duplicate_override_on_macos(self, monkeypatch):
+ monkeypatch.setattr(sys, "platform", "darwin")
+ monkeypatch.delenv("KMP_DUPLICATE_LIB_OK", raising=False)
+ _openmp.configure_environment()
+ assert os.environ["KMP_DUPLICATE_LIB_OK"] == "TRUE"
+
+ def test_leaves_other_platforms_alone(self, monkeypatch):
+ monkeypatch.setattr(sys, "platform", "linux")
+ monkeypatch.delenv("KMP_DUPLICATE_LIB_OK", raising=False)
+ _openmp.configure_environment()
+ assert "KMP_DUPLICATE_LIB_OK" not in os.environ
+
+ def test_an_explicit_user_setting_wins(self, monkeypatch):
+ monkeypatch.setattr(sys, "platform", "darwin")
+ monkeypatch.setenv("KMP_DUPLICATE_LIB_OK", "FALSE")
+ _openmp.configure_environment()
+ assert os.environ["KMP_DUPLICATE_LIB_OK"] == "FALSE"
+
+
+class TestFaissConfiguration:
+ def test_pins_to_one_thread_when_duplicated(self, monkeypatch, fake_faiss):
+ monkeypatch.delenv("DEEPIMAGESEARCH_FAISS_THREADS", raising=False)
+ monkeypatch.setattr(_openmp, "has_duplicate_libomp", lambda: True)
+ _openmp.configure_faiss(fake_faiss)
+ assert fake_faiss.threads == 1
+
+ def test_leaves_a_healthy_environment_untouched(self, monkeypatch, fake_faiss):
+ monkeypatch.delenv("DEEPIMAGESEARCH_FAISS_THREADS", raising=False)
+ monkeypatch.setattr(_openmp, "has_duplicate_libomp", lambda: False)
+ _openmp.configure_faiss(fake_faiss)
+ assert fake_faiss.threads is None
+
+ def test_env_override_sets_an_explicit_thread_count(self, monkeypatch, fake_faiss):
+ monkeypatch.setenv("DEEPIMAGESEARCH_FAISS_THREADS", "4")
+ monkeypatch.setattr(_openmp, "has_duplicate_libomp", lambda: True)
+ _openmp.configure_faiss(fake_faiss)
+ assert fake_faiss.threads == 4
+
+ def test_env_override_of_zero_restores_faiss_defaults(self, monkeypatch, fake_faiss):
+ monkeypatch.setenv("DEEPIMAGESEARCH_FAISS_THREADS", "0")
+ monkeypatch.setattr(_openmp, "has_duplicate_libomp", lambda: True)
+ _openmp.configure_faiss(fake_faiss)
+ assert fake_faiss.threads is None # never touched, so faiss keeps its own default
+
+ def test_warning_is_a_single_line(self, monkeypatch, fake_faiss, caplog):
+ monkeypatch.delenv("DEEPIMAGESEARCH_FAISS_THREADS", raising=False)
+ monkeypatch.setattr(_openmp, "has_duplicate_libomp", lambda: True)
+ with caplog.at_level("WARNING", logger=_openmp.__name__):
+ _openmp.configure_faiss(fake_faiss)
+
+ warnings = [r for r in caplog.records if r.levelname == "WARNING"]
+ assert len(warnings) == 1
+ message = warnings[0].getMessage()
+ assert "libomp" in message
+ assert "DEEPIMAGESEARCH_FAISS_THREADS" in message
+ # Importing the package must not dump a paragraph into stderr.
+ assert message.count("\n") == 0
+
+ def test_full_instructions_are_available_at_info(self, monkeypatch, fake_faiss, caplog):
+ monkeypatch.delenv("DEEPIMAGESEARCH_FAISS_THREADS", raising=False)
+ monkeypatch.setattr(_openmp, "has_duplicate_libomp", lambda: True)
+ with caplog.at_level("INFO", logger=_openmp.__name__):
+ _openmp.configure_faiss(fake_faiss)
+ assert "ln -s" in caplog.text # the symlink recipe
+
+
+def test_hint_names_both_packages():
+ hint = _openmp.duplicate_libomp_hint()
+ assert "torch" in hint and "faiss" in hint
diff --git a/tests/test_optional_vectorstores.py b/tests/test_optional_vectorstores.py
new file mode 100644
index 0000000..e35c513
--- /dev/null
+++ b/tests/test_optional_vectorstores.py
@@ -0,0 +1,148 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Contract tests for the optional vector store backends.
+
+Chroma and Qdrant are behind extras, so each class skips unless its dependency
+is installed. Both run against a real in-process instance — no server needed.
+The point is that every backend honours the same BaseVectorStore contract as
+FAISS, since SearchEngine swaps between them by name.
+"""
+
+import numpy as np
+import pytest
+
+from DeepImageSearch.vectorstores.base import BaseVectorStore
+
+DIM = 4
+
+
+def unit(*values) -> np.ndarray:
+ arr = np.asarray(values, dtype=np.float32)
+ return arr / np.linalg.norm(arr)
+
+
+IDS = ["a", "b", "c"]
+VECTORS = np.vstack([unit(1, 0, 0, 0), unit(0, 1, 0, 0), unit(0, 0, 1, 0)])
+METADATA = [{"colour": "red"}, {"colour": "green"}, {"colour": "red"}]
+
+
+class VectorStoreContract:
+ """Shared expectations; subclasses provide a `store` fixture."""
+
+ def test_count_reflects_added_vectors(self, store):
+ assert store.count() == 3
+
+ def test_search_returns_nearest_first(self, store):
+ results = store.search(unit(1, 0, 0, 0), k=3)
+ assert results[0]["id"] == "a"
+
+ def test_result_shape_matches_the_contract(self, store):
+ result = store.search(unit(1, 0, 0, 0), k=1)[0]
+ assert set(result) == {"id", "score", "metadata"}
+ assert isinstance(result["score"], float)
+ assert result["metadata"]["colour"] == "red"
+
+ def test_search_respects_k(self, store):
+ assert len(store.search(unit(1, 0, 0, 0), k=2)) == 2
+
+ def test_equality_filter(self, store):
+ results = store.search(unit(1, 0, 0, 0), k=3, filters={"colour": "red"})
+ assert {r["id"] for r in results} == {"a", "c"}
+
+ def test_filter_with_no_matches_returns_nothing(self, store):
+ assert store.search(unit(1, 0, 0, 0), k=3, filters={"colour": "puce"}) == []
+
+ def test_delete_removes_the_vector(self, store):
+ store.delete(["a"])
+ assert store.count() == 2
+ assert "a" not in {r["id"] for r in store.search(unit(1, 0, 0, 0), k=3)}
+
+ def test_implements_the_full_interface(self, store):
+ assert not type(store).__abstractmethods__
+ assert isinstance(store, BaseVectorStore)
+
+
+class TestChromaStore(VectorStoreContract):
+ @pytest.fixture
+ def store(self):
+ pytest.importorskip("chromadb", reason="install the [chroma] extra to run these")
+ from DeepImageSearch.vectorstores.chroma_store import ChromaStore
+
+ store = ChromaStore(collection_name="test_contract")
+ store.add(IDS, VECTORS, METADATA)
+ yield store
+ store.client.delete_collection("test_contract")
+
+ def test_non_primitive_metadata_is_stringified(self):
+ pytest.importorskip("chromadb")
+ from DeepImageSearch.vectorstores.chroma_store import ChromaStore
+
+ store = ChromaStore(collection_name="test_coercion")
+ try:
+ store.add(["a"], unit(1, 0, 0, 0).reshape(1, -1), [{"tags": ["x", "y"], "n": 3}])
+ meta = store.search(unit(1, 0, 0, 0), k=1)[0]["metadata"]
+ assert meta["tags"] == "['x', 'y']" # coerced, not dropped
+ assert meta["n"] == 3 # primitives kept as-is
+ finally:
+ store.client.delete_collection("test_coercion")
+
+ def test_persistent_client_writes_to_disk(self, tmp_path):
+ pytest.importorskip("chromadb")
+ from DeepImageSearch.vectorstores.chroma_store import ChromaStore
+
+ store = ChromaStore(collection_name="persisted", persist_directory=str(tmp_path))
+ store.add(IDS, VECTORS, METADATA)
+ store.save(str(tmp_path)) # documented as a no-op; must not raise
+
+ reopened = ChromaStore(collection_name="persisted", persist_directory=str(tmp_path))
+ assert reopened.count() == 3
+
+
+class TestQdrantStore(VectorStoreContract):
+ @pytest.fixture
+ def store(self):
+ pytest.importorskip("qdrant_client", reason="install the [qdrant] extra to run these")
+ from DeepImageSearch.vectorstores.qdrant_store import QdrantStore
+
+ store = QdrantStore(collection_name="test_contract", dimension=DIM)
+ store.add(IDS, VECTORS, METADATA)
+ return store
+
+ def test_original_string_ids_survive_the_uuid_round_trip(self, store):
+ # Qdrant requires numeric/UUID point ids, so the real id rides in the payload.
+ results = store.search(unit(1, 0, 0, 0), k=3)
+ assert {r["id"] for r in results} == set(IDS)
+ assert all("_original_id" not in r["metadata"] for r in results)
+
+ def test_reindexing_the_same_id_updates_in_place(self, store):
+ store.add(["a"], unit(0, 0, 0, 1).reshape(1, -1), [{"colour": "blue"}])
+ assert store.count() == 3
+ assert store.search(unit(0, 0, 0, 1), k=1)[0]["id"] == "a"
+
+ def test_local_path_storage_persists(self, tmp_path):
+ pytest.importorskip("qdrant_client")
+ from DeepImageSearch.vectorstores.qdrant_store import QdrantStore
+
+ store = QdrantStore(collection_name="persisted", path=str(tmp_path), dimension=DIM)
+ store.add(IDS, VECTORS, METADATA)
+ store.save(str(tmp_path)) # documented as a no-op; must not raise
+ store.client.close()
+
+ reopened = QdrantStore(collection_name="persisted", path=str(tmp_path), dimension=DIM)
+ assert reopened.count() == 3
+ reopened.client.close()
+
+
+@pytest.mark.parametrize(
+ ("module_name", "class_name"),
+ [("chromadb", "ChromaStore"), ("qdrant_client", "QdrantStore")],
+)
+def test_optional_store_is_exported_exactly_when_its_dependency_is_installed(module_name, class_name):
+ import importlib
+
+ from DeepImageSearch import vectorstores
+
+ installed = importlib.util.find_spec(module_name) is not None
+ assert (class_name in vectorstores.__all__) is installed
+ assert hasattr(vectorstores, class_name) is installed
diff --git a/tests/test_package.py b/tests/test_package.py
new file mode 100644
index 0000000..7f990bd
--- /dev/null
+++ b/tests/test_package.py
@@ -0,0 +1,121 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""Package-level guarantees: public API surface, version consistency, licensing."""
+
+import os
+import pathlib
+import re
+import sys
+
+import pytest
+
+import DeepImageSearch
+
+REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
+
+
+def test_version_is_a_semver_string():
+ assert re.fullmatch(r"\d+\.\d+\.\d+", DeepImageSearch.__version__)
+
+
+def test_version_matches_pyproject():
+ pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ declared = re.search(r'^version = "([^"]+)"', pyproject, re.MULTILINE).group(1)
+ assert DeepImageSearch.__version__ == declared
+
+
+def test_version_matches_citation_file():
+ citation = (REPO_ROOT / "CITATION.cff").read_text(encoding="utf-8")
+ declared = re.search(r"^version: (.+)$", citation, re.MULTILINE).group(1).strip()
+ assert DeepImageSearch.__version__ == declared
+
+
+@pytest.mark.parametrize("name", DeepImageSearch.__all__)
+def test_every_exported_name_is_importable(name):
+ assert getattr(DeepImageSearch, name, None) is not None
+
+
+def test_documented_entry_points_are_exported():
+ # The README's quick start depends on exactly these names.
+ for name in ["SearchEngine", "Load_Data", "Search_Setup"]:
+ assert name in DeepImageSearch.__all__
+
+
+def test_subpackages_import_cleanly():
+ import DeepImageSearch.core # noqa: F401
+ import DeepImageSearch.data # noqa: F401
+ import DeepImageSearch.metadatastore as metadatastore
+ import DeepImageSearch.vectorstores as vectorstores
+
+ assert "FAISSStore" in vectorstores.__all__
+ assert "JsonMetadataStore" in metadatastore.__all__
+
+
+def test_optional_backends_are_not_hard_requirements():
+ # Chroma/Qdrant/Postgres live behind extras; importing the package without
+ # them installed must still work.
+ import DeepImageSearch.metadatastore as metadatastore
+ import DeepImageSearch.vectorstores as vectorstores
+
+ assert "BaseVectorStore" in vectorstores.__all__
+ assert "BaseMetadataStore" in metadatastore.__all__
+
+
+@pytest.mark.skipif(sys.platform != "darwin", reason="OpenMP duplicate-runtime clash is macOS-specific")
+def test_macos_openmp_workaround_is_applied_on_import():
+ # torch and faiss-cpu vendor separate libomp copies; without this the first
+ # FAISS search aborts the process with "OMP: Error #15".
+ assert os.environ.get("KMP_DUPLICATE_LIB_OK") == "TRUE"
+
+
+def test_faiss_search_survives_torch_being_loaded():
+ """End-to-end guard for the OpenMP clash: this aborts the process if it regresses."""
+ import faiss
+ import numpy as np
+ import torch # noqa: F401 — must be loaded for the clash to be possible
+
+ index = faiss.IndexFlatIP(8)
+ index.add(np.ones((2, 8), dtype=np.float32))
+ scores, indices = index.search(np.ones((1, 8), dtype=np.float32), 1)
+ assert indices[0][0] in (0, 1)
+
+
+@pytest.mark.skipif(sys.platform != "darwin", reason="OpenMP duplicate-runtime clash is macOS-specific")
+def test_faiss_is_pinned_to_one_thread_when_runtimes_are_duplicated():
+ """
+ KMP_DUPLICATE_LIB_OK alone only converts the abort into a segfault inside
+ FAISS's parallel routines, so the thread pin has to be in place too.
+ """
+ import faiss
+
+ from DeepImageSearch._openmp import has_duplicate_libomp
+
+ if has_duplicate_libomp():
+ assert faiss.omp_get_max_threads() == 1
+
+
+def test_ivf_training_survives_torch_being_loaded():
+ """IVF k-means is the OpenMP-heaviest path — it segfaults if the guard regresses."""
+ import numpy as np
+ import torch # noqa: F401
+
+ from DeepImageSearch.vectorstores.faiss_store import FAISSStore
+
+ store = FAISSStore(dimension=8, index_type="ivf")
+ rng = np.random.default_rng(0)
+ vectors = rng.random((4000, 8)).astype(np.float32)
+ vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
+ store.add([str(i) for i in range(4000)], vectors)
+
+ assert store.index.is_trained
+ assert store.count() == 4000
+ assert store.search(vectors[0], k=1)[0]["id"] == "0"
+
+
+def test_every_source_file_declares_its_license():
+ missing = [
+ str(path.relative_to(REPO_ROOT))
+ for path in sorted((REPO_ROOT / "DeepImageSearch").rglob("*.py"))
+ if "SPDX-License-Identifier: MIT" not in path.read_text(encoding="utf-8")
+ ]
+ assert missing == []
diff --git a/tests/test_plotting_and_routing.py b/tests/test_plotting_and_routing.py
new file mode 100644
index 0000000..5cda057
--- /dev/null
+++ b/tests/test_plotting_and_routing.py
@@ -0,0 +1,182 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+The remaining branches: matplotlib plotting, FAISS IVF/HNSW index types, and
+the store-type routing shared by SearchEngine and ImageSearchTool.
+
+Plotting runs on the non-interactive Agg backend with plt.show() stubbed, so
+nothing opens a window in CI.
+"""
+
+import numpy as np
+import pytest
+
+import matplotlib # isort: skip
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt # noqa: E402
+
+from DeepImageSearch import search_engine as se_module # noqa: E402
+from DeepImageSearch.agents import tool_interface as ti_module # noqa: E402
+from DeepImageSearch.agents.tool_interface import ImageSearchTool # noqa: E402
+from DeepImageSearch.core.indexer import Indexer # noqa: E402
+from DeepImageSearch.core.searcher import Searcher # noqa: E402
+from DeepImageSearch.search_engine import SearchEngine # noqa: E402
+from DeepImageSearch.vectorstores.faiss_store import FAISSStore # noqa: E402
+from tests.conftest import DIM, DummyEmbedding # noqa: E402
+
+
+@pytest.fixture
+def headless(monkeypatch):
+ """Count draw calls without opening a window."""
+ shown = []
+ monkeypatch.setattr(plt, "show", lambda *a, **k: shown.append(True))
+ yield shown
+ plt.close("all")
+
+
+@pytest.fixture
+def searcher(embedding, image_paths):
+ store = FAISSStore(dimension=DIM)
+ Indexer(embedding=embedding, vector_store=store).index(image_paths)
+ return Searcher(embedding=embedding, vector_store=store)
+
+
+class TestPlotting:
+ def test_plots_the_query_and_the_results(self, searcher, image_paths, headless):
+ searcher.plot_similar_images(image_paths[0], number_of_images=3)
+ assert len(headless) == 2 # query figure, then the results grid
+
+ def test_survives_a_result_whose_file_has_vanished(self, embedding, image_paths, tmp_path, headless):
+ from tests.conftest import make_image
+
+ doomed = make_image(tmp_path / "doomed.png", (3, 3, 3))
+ store = FAISSStore(dimension=DIM)
+ Indexer(embedding=embedding, vector_store=store).index(image_paths + [doomed])
+ import os
+
+ os.remove(doomed)
+
+ # A deleted image must not take the whole plot down.
+ Searcher(embedding, store).plot_similar_images(image_paths[0], number_of_images=4)
+ assert len(headless) == 2
+
+ def test_handles_results_without_a_stored_path(self, embedding, image_paths, headless):
+ store = FAISSStore(dimension=DIM)
+ Indexer(embedding=embedding, vector_store=store).index(image_paths)
+ store._metadata = [{} for _ in store._metadata] # metadata stripped
+ Searcher(embedding, store).plot_similar_images(image_paths[0], number_of_images=2)
+ assert len(headless) == 2
+
+
+class TestFaissIndexTypes:
+ def test_ivf_index_trains_before_adding(self):
+ store = FAISSStore(dimension=DIM, index_type="ivf")
+ assert store.index.is_trained is False
+
+ rng = np.random.default_rng(0)
+ vectors = rng.random((256, DIM)).astype(np.float32)
+ vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
+ store.add([str(i) for i in range(256)], vectors)
+
+ assert store.index.is_trained is True
+ assert store.count() == 256
+
+ def test_ivf_search_sets_nprobe_and_returns_results(self):
+ store = FAISSStore(dimension=DIM, index_type="ivf")
+ rng = np.random.default_rng(1)
+ vectors = rng.random((256, DIM)).astype(np.float32)
+ vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
+ store.add([str(i) for i in range(256)], vectors)
+
+ results = store.search(vectors[0], k=5)
+ assert results
+ assert store.index.nprobe == min(20, store.index.nlist)
+
+ def test_hnsw_index_round_trips(self, tmp_path):
+ store = FAISSStore(dimension=DIM, index_type="hnsw")
+ vectors = np.eye(DIM, dtype=np.float32)[:3]
+ store.add(["a", "b", "c"], vectors, [{"n": i} for i in range(3)])
+ assert store.search(vectors[0], k=1)[0]["id"] == "a"
+
+ store.save(str(tmp_path))
+ reloaded = FAISSStore(dimension=DIM)
+ reloaded.load(str(tmp_path))
+ assert reloaded.index_type == "hnsw"
+ assert reloaded.count() == 3
+
+
+class TestStoreRouting:
+ """SearchEngine and ImageSearchTool both map a backend name to a store class."""
+
+ @pytest.fixture(autouse=True)
+ def no_model_downloads(self, monkeypatch):
+ for module in (se_module, ti_module):
+ monkeypatch.setattr(
+ module.EmbeddingManager, "create",
+ staticmethod(lambda *a, **k: DummyEmbedding()),
+ )
+
+ def test_engine_builds_a_chroma_store(self, tmp_path):
+ pytest.importorskip("chromadb", reason="install the [chroma] extra")
+ engine = SearchEngine(vector_store="chroma", index_dir=str(tmp_path / "chroma"))
+ assert type(engine.vector_store).__name__ == "ChromaStore"
+
+ def test_engine_builds_a_qdrant_store(self, tmp_path):
+ pytest.importorskip("qdrant_client", reason="install the [qdrant] extra")
+ engine = SearchEngine(vector_store="qdrant", index_dir=str(tmp_path / "qdrant"))
+ assert type(engine.vector_store).__name__ == "QdrantStore"
+ engine.vector_store.client.close()
+
+ def test_engine_indexes_and_searches_through_chroma(self, tmp_path, image_paths):
+ pytest.importorskip("chromadb", reason="install the [chroma] extra")
+ engine = SearchEngine(vector_store="chroma", index_dir=str(tmp_path / "chroma"))
+ engine.index(image_paths)
+ assert engine.count == len(image_paths)
+ assert engine.search_by_image(image_paths[0], k=1)[0]["metadata"]["image_path"] == image_paths[0]
+
+ def test_tool_builds_a_chroma_store(self, tmp_path):
+ pytest.importorskip("chromadb", reason="install the [chroma] extra")
+ tool = ImageSearchTool(index_path=str(tmp_path / "chroma"), vector_store_type="chroma")
+ assert type(tool.vector_store).__name__ == "ChromaStore"
+
+ def test_tool_builds_a_qdrant_store(self, tmp_path):
+ pytest.importorskip("qdrant_client", reason="install the [qdrant] extra")
+ tool = ImageSearchTool(index_path=str(tmp_path / "qdrant"), vector_store_type="qdrant")
+ assert type(tool.vector_store).__name__ == "QdrantStore"
+ tool.vector_store.client.close()
+
+
+def test_chroma_load_reopens_the_collection(tmp_path, image_paths, embedding):
+ pytest.importorskip("chromadb", reason="install the [chroma] extra")
+ from DeepImageSearch.vectorstores.chroma_store import ChromaStore
+
+ store = ChromaStore(collection_name="reopened", persist_directory=str(tmp_path))
+ Indexer(embedding=embedding, vector_store=store).index(image_paths)
+
+ fresh = ChromaStore(collection_name="reopened", persist_directory=str(tmp_path))
+ fresh.load(str(tmp_path))
+ assert fresh.count() == len(image_paths)
+
+
+def test_chroma_add_without_metadata(tmp_path):
+ pytest.importorskip("chromadb", reason="install the [chroma] extra")
+ from DeepImageSearch.vectorstores.chroma_store import ChromaStore
+
+ store = ChromaStore(collection_name="no_meta", persist_directory=str(tmp_path))
+ store.add(["a"], np.eye(4, dtype=np.float32)[:1])
+ assert store.count() == 1
+ assert store.search(np.eye(4, dtype=np.float32)[0], k=1)[0]["metadata"] == {}
+
+
+def test_qdrant_load_reopens_local_storage(tmp_path, image_paths, embedding):
+ pytest.importorskip("qdrant_client", reason="install the [qdrant] extra")
+ from DeepImageSearch.vectorstores.qdrant_store import QdrantStore
+
+ store = QdrantStore(collection_name="reopened", path=str(tmp_path), dimension=DIM)
+ Indexer(embedding=embedding, vector_store=store).index(image_paths)
+ store.client.close()
+
+ fresh = QdrantStore(collection_name="reopened", path=str(tmp_path), dimension=DIM)
+ assert fresh.count() == len(image_paths)
+ fresh.client.close()
diff --git a/tests/test_postgres_store.py b/tests/test_postgres_store.py
new file mode 100644
index 0000000..040319c
--- /dev/null
+++ b/tests/test_postgres_store.py
@@ -0,0 +1,265 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for PostgresMetadataStore.
+
+A running PostgreSQL server is not required (and would make CI flaky): a fake
+`psycopg2` module records the SQL and parameters the store emits and replays
+canned rows. That covers the parts the store owns — statement shape, parameter
+order, table-name substitution, and row-to-record mapping.
+"""
+
+import json
+import sys
+import types
+
+import pytest
+
+from DeepImageSearch.metadatastore.base import BaseMetadataStore, ImageRecord
+
+ROW = ("id0", 0, "img0.png", "/images/img0.png", "a caption", "2026-01-01T00:00:00+00:00", {"tag": "pet"})
+
+
+class FakeCursor:
+ def __init__(self, log, rows):
+ self.log = log
+ self.rows = rows
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def execute(self, sql, params=None):
+ self.log.append((" ".join(sql.split()), params))
+
+ def fetchone(self):
+ return self.rows["one"]
+
+ def fetchall(self):
+ return self.rows["all"]
+
+
+class FakeConnection:
+ def __init__(self, log, rows):
+ self.log = log
+ self.rows = rows
+ self.autocommit = False
+ self.closed = False
+
+ def cursor(self):
+ return FakeCursor(self.log, self.rows)
+
+ def close(self):
+ self.closed = True
+
+
+@pytest.fixture
+def pg(monkeypatch):
+ """Install a fake psycopg2; returns a factory for (store, log, rows)."""
+ state = {"log": [], "rows": {"one": None, "all": []}, "connections": []}
+
+ def connect(connection_string):
+ state["connection_string"] = connection_string
+ conn = FakeConnection(state["log"], state["rows"])
+ state["connections"].append(conn)
+ return conn
+
+ module = types.ModuleType("psycopg2")
+ module.connect = connect
+ monkeypatch.setitem(sys.modules, "psycopg2", module)
+
+ def build(**kwargs):
+ from DeepImageSearch.metadatastore.postgres_store import PostgresMetadataStore
+
+ store = PostgresMetadataStore(connection_string="postgresql://u:p@localhost/db", **kwargs)
+ state["log"].clear() # drop the CREATE TABLE noise
+ return store, state
+
+ return build
+
+
+def record(index=0, **kwargs):
+ return ImageRecord(
+ image_id=f"id{index}",
+ image_index=index,
+ image_name=f"img{index}.png",
+ image_path=f"/images/img{index}.png",
+ indexed_at="2026-01-01T00:00:00+00:00",
+ **kwargs,
+ )
+
+
+class TestConnection:
+ def test_connects_with_the_given_string_and_sets_autocommit(self, pg):
+ store, state = pg()
+ assert state["connection_string"] == "postgresql://u:p@localhost/db"
+ assert store.conn.autocommit is True
+
+ def test_creates_the_table_by_default(self, monkeypatch, pg):
+ from DeepImageSearch.metadatastore.postgres_store import PostgresMetadataStore
+
+ state = {"log": [], "rows": {"one": None, "all": []}, "connections": []}
+ module = types.ModuleType("psycopg2")
+ module.connect = lambda cs: FakeConnection(state["log"], state["rows"])
+ monkeypatch.setitem(sys.modules, "psycopg2", module)
+
+ PostgresMetadataStore(connection_string="postgresql://x")
+ assert any("CREATE TABLE IF NOT EXISTS" in sql for sql, _ in state["log"])
+
+ def test_auto_create_can_be_disabled(self, monkeypatch):
+ from DeepImageSearch.metadatastore.postgres_store import PostgresMetadataStore
+
+ state = {"log": [], "rows": {"one": None, "all": []}}
+ module = types.ModuleType("psycopg2")
+ module.connect = lambda cs: FakeConnection(state["log"], state["rows"])
+ monkeypatch.setitem(sys.modules, "psycopg2", module)
+
+ PostgresMetadataStore(connection_string="postgresql://x", auto_create=False)
+ assert state["log"] == []
+
+ def test_missing_driver_gives_an_actionable_error(self, monkeypatch):
+ from DeepImageSearch.metadatastore.postgres_store import PostgresMetadataStore
+
+ monkeypatch.setitem(sys.modules, "psycopg2", None)
+ with pytest.raises(ImportError, match=r"DeepImageSearch\[postgres\]"):
+ PostgresMetadataStore(connection_string="postgresql://x")
+
+
+class TestWrites:
+ def test_add_upserts_each_record_with_fields_in_column_order(self, pg):
+ store, state = pg()
+ store.add([record(0, caption="a cat", extra={"tag": "pet"})])
+
+ sql, params = state["log"][0]
+ assert sql.startswith("INSERT INTO image_records")
+ assert "ON CONFLICT (image_id) DO UPDATE" in sql
+ assert params[:6] == ("id0", 0, "img0.png", "/images/img0.png", "a cat", "2026-01-01T00:00:00+00:00")
+ assert json.loads(params[6]) == {"tag": "pet"}
+
+ def test_empty_extra_is_stored_as_null(self, pg):
+ store, state = pg()
+ store.add([record(0)])
+ assert state["log"][0][1][6] is None
+
+ def test_add_issues_one_statement_per_record(self, pg):
+ store, state = pg()
+ store.add([record(0), record(1), record(2)])
+ assert len(state["log"]) == 3
+
+ def test_delete_uses_one_placeholder_per_id(self, pg):
+ store, state = pg()
+ store.delete(["a", "b", "c"])
+ sql, params = state["log"][0]
+ assert "WHERE image_id IN (%s, %s, %s)" in sql
+ assert params == ("a", "b", "c")
+
+ def test_delete_with_no_ids_touches_the_database(self, pg):
+ store, state = pg()
+ store.delete([])
+ assert state["log"] == []
+
+
+class TestReads:
+ def test_get_maps_a_row_to_a_record(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = ROW
+ result = store.get("id0")
+ assert result == ImageRecord(*ROW)
+ assert state["log"][0][1] == ("id0",)
+
+ def test_get_returns_none_when_absent(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = None
+ assert store.get("missing") is None
+
+ def test_null_extra_becomes_an_empty_dict(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = ROW[:6] + (None,)
+ assert store.get("id0").extra == {}
+
+ def test_get_by_index_queries_the_index_column(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = ROW
+ assert store.get_by_index(0).image_id == "id0"
+ assert "WHERE image_index = %s" in state["log"][0][0]
+
+ def test_list_all_is_ordered_by_index(self, pg):
+ store, state = pg()
+ state["rows"]["all"] = [ROW, ("id1", 1) + ROW[2:]]
+ records = store.list_all()
+ assert [r.image_index for r in records] == [0, 1]
+ assert "ORDER BY image_index" in state["log"][0][0]
+
+ def test_count_reads_the_scalar(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = (7,)
+ assert store.count() == 7
+ assert "SELECT COUNT(*)" in state["log"][0][0]
+
+ def test_count_of_an_empty_table(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = None
+ assert store.count() == 0
+
+ def test_next_index_follows_the_maximum(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = (7,)
+ assert store.next_index() == 8
+ assert "SELECT MAX(image_index)" in state["log"][0][0]
+
+ def test_next_index_on_an_empty_table_is_zero(self, pg):
+ store, state = pg()
+ state["rows"]["one"] = (None,)
+ assert store.next_index() == 0
+
+
+class TestCustomTableName:
+ def test_statements_target_the_configured_table(self, pg):
+ store, state = pg(table_name="my_images")
+ state["rows"]["one"] = (0,)
+ store.count()
+ state["rows"]["one"] = ROW
+ store.get("id0")
+ state["rows"]["all"] = [ROW]
+ store.list_all()
+ assert all("my_images" in sql for sql, _ in state["log"])
+ assert not any("FROM image_records" in sql for sql, _ in state["log"])
+
+
+class TestLifecycle:
+ def test_save_and_load_are_no_ops(self, pg):
+ store, state = pg()
+ store.save("/anywhere")
+ store.load("/anywhere")
+ assert state["log"] == []
+
+ def test_close_closes_the_connection(self, pg):
+ store, _ = pg()
+ store.close()
+ assert store.conn.closed is True
+
+ def test_close_is_idempotent(self, pg):
+ store, _ = pg()
+ store.close()
+ store.close()
+ assert store.conn.closed is True
+
+ def test_del_after_a_failed_init_does_not_raise(self, monkeypatch):
+ """__del__ runs even when __init__ bailed before setting self.conn."""
+ from DeepImageSearch.metadatastore.postgres_store import PostgresMetadataStore
+
+ monkeypatch.setitem(sys.modules, "psycopg2", None)
+ with pytest.raises(ImportError):
+ PostgresMetadataStore(connection_string="postgresql://x")
+
+ half_built = PostgresMetadataStore.__new__(PostgresMetadataStore)
+ half_built.__del__() # must not raise AttributeError
+
+
+def test_implements_the_full_interface():
+ from DeepImageSearch.metadatastore.postgres_store import PostgresMetadataStore
+
+ assert not PostgresMetadataStore.__abstractmethods__
+ assert not BaseMetadataStore.__abstractmethods__ - set(dir(PostgresMetadataStore))
diff --git a/tests/test_search_engine.py b/tests/test_search_engine.py
new file mode 100644
index 0000000..b44b4e4
--- /dev/null
+++ b/tests/test_search_engine.py
@@ -0,0 +1,280 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for the high-level SearchEngine facade.
+
+SearchEngine builds a real embedding backend in __init__, so every test here
+patches EmbeddingManager.create to hand back the deterministic DummyEmbedding.
+"""
+
+import numpy as np
+import pytest
+
+from DeepImageSearch import search_engine as se_module
+from DeepImageSearch.metadatastore.json_store import JsonMetadataStore
+from DeepImageSearch.search_engine import SearchEngine
+from DeepImageSearch.vectorstores.base import BaseVectorStore
+from DeepImageSearch.vectorstores.faiss_store import FAISSStore
+from tests.conftest import DIM, DummyEmbedding, make_image
+
+
+@pytest.fixture(autouse=True)
+def no_model_downloads(monkeypatch):
+ """Every SearchEngine in this module gets the dummy embedding."""
+ monkeypatch.setattr(
+ se_module.EmbeddingManager, "create",
+ staticmethod(lambda *args, **kwargs: DummyEmbedding()),
+ )
+
+
+@pytest.fixture
+def engine(tmp_path):
+ return SearchEngine(index_dir=str(tmp_path / "index"))
+
+
+class TestConstruction:
+ def test_defaults_to_faiss_and_json_stores(self, engine):
+ assert isinstance(engine.vector_store, FAISSStore)
+ assert isinstance(engine.metadata_store, JsonMetadataStore)
+ assert engine.count == 0
+
+ def test_vector_store_dimension_follows_the_embedding(self, engine):
+ assert engine.vector_store.dimension == DIM
+
+ def test_accepts_an_injected_vector_store(self, tmp_path):
+ store = FAISSStore(dimension=DIM, index_type="hnsw")
+ engine = SearchEngine(vector_store=store, index_dir=str(tmp_path))
+ assert engine.vector_store is store
+
+ def test_accepts_an_injected_metadata_store(self, tmp_path):
+ records = JsonMetadataStore()
+ engine = SearchEngine(metadata_store=records, index_dir=str(tmp_path))
+ assert engine.metadata_store is records
+
+ def test_unknown_vector_store_name_raises(self, tmp_path):
+ with pytest.raises(ValueError, match="Unknown vector_store"):
+ SearchEngine(vector_store="pinecone", index_dir=str(tmp_path))
+
+ def test_reopening_an_index_dir_loads_the_existing_index(self, tmp_path, image_paths):
+ index_dir = str(tmp_path / "index")
+ first = SearchEngine(index_dir=index_dir)
+ first.index(image_paths)
+
+ reopened = SearchEngine(index_dir=index_dir)
+ assert reopened.count == len(image_paths)
+ assert len(reopened.get_records()) == len(image_paths)
+
+ def test_no_captioner_without_full_credentials(self, tmp_path):
+ assert SearchEngine(index_dir=str(tmp_path)).captioner is None
+
+ def test_partial_captioner_credentials_are_ignored(self, tmp_path):
+ engine = SearchEngine(index_dir=str(tmp_path), captioner_model="m", captioner_api_key="k")
+ assert engine.captioner is None
+
+ def test_captioner_built_when_all_credentials_present(self, tmp_path, monkeypatch):
+ built = {}
+
+ class FakeCaptioner:
+ def __init__(self, **kwargs):
+ built.update(kwargs)
+
+ monkeypatch.setattr(se_module, "Captioner", FakeCaptioner)
+ engine = SearchEngine(
+ index_dir=str(tmp_path),
+ captioner_model="vision-model",
+ captioner_api_key="secret",
+ captioner_base_url="https://example.invalid/v1",
+ )
+ assert isinstance(engine.captioner, FakeCaptioner)
+ assert built == {
+ "model": "vision-model",
+ "api_key": "secret",
+ "base_url": "https://example.invalid/v1",
+ }
+ assert engine.indexer.captioner is engine.captioner
+
+
+class TestPathResolution:
+ def test_a_folder_is_expanded_to_its_images(self, engine, image_dir):
+ resolved = engine._resolve_image_paths(str(image_dir))
+ assert len(resolved) == 4
+
+ def test_a_single_path_is_wrapped_in_a_list(self, engine, image_paths):
+ assert engine._resolve_image_paths(image_paths[0]) == [image_paths[0]]
+
+ def test_a_list_is_passed_through(self, engine, image_paths):
+ assert engine._resolve_image_paths(image_paths) == image_paths
+
+
+class TestIndexing:
+ def test_index_accepts_a_folder(self, engine, image_dir):
+ assert engine.index(str(image_dir)) == 4
+ assert engine.count == 4
+
+ def test_index_accepts_a_list(self, engine, image_paths):
+ assert engine.index(image_paths) == len(image_paths)
+
+ def test_index_persists_by_default(self, tmp_path, image_paths):
+ index_dir = tmp_path / "index"
+ SearchEngine(index_dir=str(index_dir)).index(image_paths)
+ assert (index_dir / "index.faiss").exists()
+ assert (index_dir / "image_records.json").exists()
+
+ def test_save_can_be_deferred(self, tmp_path, image_paths):
+ index_dir = tmp_path / "index"
+ SearchEngine(index_dir=str(index_dir)).index(image_paths, save=False)
+ assert not (index_dir / "index.faiss").exists()
+
+ def test_metadata_is_attached_to_records(self, engine, image_paths):
+ engine.index(image_paths, metadata=[{"album": "trip"} for _ in image_paths])
+ assert all(r["extra"] == {"album": "trip"} for r in engine.get_records())
+
+ def test_add_images_extends_the_index(self, engine, image_paths, tmp_path):
+ engine.index(image_paths)
+ later = make_image(tmp_path / "later.png", (7, 7, 7))
+ assert engine.add_images([later]) == 1
+ assert engine.count == len(image_paths) + 1
+
+ def test_captions_are_requested_when_asked(self, tmp_path, image_paths, monkeypatch):
+ class FakeCaptioner:
+ def __init__(self, **kwargs):
+ self.seen = None
+
+ def caption_batch(self, paths, prompt=None):
+ self.seen = (list(paths), prompt)
+ return {p: "a caption" for p in paths}
+
+ monkeypatch.setattr(se_module, "Captioner", FakeCaptioner)
+ engine = SearchEngine(
+ index_dir=str(tmp_path),
+ captioner_model="m", captioner_api_key="k", captioner_base_url="u",
+ )
+ engine.index(image_paths, generate_captions=True, caption_prompt="describe it")
+
+ assert engine.captioner.seen == (image_paths, "describe it")
+ assert all(r["caption"] == "a caption" for r in engine.get_records())
+
+
+class TestSearch:
+ def test_search_by_image_finds_the_query(self, engine, image_paths):
+ engine.index(image_paths)
+ assert engine.search_by_image(image_paths[0], k=1)[0]["metadata"]["image_path"] == image_paths[0]
+
+ def test_search_by_text_returns_results(self, engine, image_paths):
+ engine.index(image_paths)
+ assert len(engine.search_by_text("a red square", k=2)) == 2
+
+ def test_search_dispatches_by_query_type(self, engine, image_paths):
+ engine.index(image_paths)
+ assert engine.search(image_paths[0], k=1)[0]["metadata"]["image_path"] == image_paths[0]
+ assert len(engine.search("a red square", k=2)) == 2
+ assert len(engine.search(np.ones(DIM, dtype=np.float32), k=2)) == 2
+
+ def test_hybrid_search_through_the_facade(self, engine, image_paths):
+ engine.index(image_paths)
+ results = engine.search("a red square", k=2, mode="hybrid", image_query=image_paths[0])
+ assert len(results) == 2
+
+ def test_filters_are_forwarded(self, engine, image_paths):
+ tags = [{"tag": "keep"}] + [{"tag": "drop"}] * (len(image_paths) - 1)
+ engine.index(image_paths, metadata=tags)
+ assert len(engine.search_by_image(image_paths[0], k=5, filters={"tag": "keep"})) == 1
+
+ def test_get_similar_images_v2_shape(self, engine, image_paths):
+ engine.index(image_paths)
+ similar = engine.get_similar_images(image_paths[0], number_of_images=2)
+ assert list(similar) == [0, 1]
+ assert similar[0] == image_paths[0]
+
+ def test_plot_similar_images_delegates_to_the_searcher(self, engine, image_paths, monkeypatch):
+ engine.index(image_paths)
+ calls = []
+ monkeypatch.setattr(engine.searcher, "plot_similar_images",
+ lambda path, n: calls.append((path, n)))
+ engine.plot_similar_images(image_paths[0], number_of_images=3)
+ assert calls == [(image_paths[0], 3)]
+
+
+class TestRecords:
+ def test_get_records_returns_dicts(self, engine, image_paths):
+ engine.index(image_paths)
+ records = engine.get_records()
+ assert len(records) == len(image_paths)
+ assert set(records[0]) >= {"image_id", "image_index", "image_name", "image_path"}
+
+ def test_get_record_by_id(self, engine, image_paths):
+ engine.index(image_paths)
+ image_id = engine.get_records()[0]["image_id"]
+ assert engine.get_record(image_id)["image_id"] == image_id
+
+ def test_get_record_returns_none_for_unknown_id(self, engine, image_paths):
+ engine.index(image_paths)
+ assert engine.get_record("no-such-id") is None
+
+
+class TestPersistence:
+ def test_explicit_save_then_load_round_trip(self, tmp_path, image_paths):
+ index_dir = str(tmp_path / "index")
+ engine = SearchEngine(index_dir=index_dir)
+ engine.index(image_paths, save=False)
+ engine.save()
+
+ fresh = SearchEngine(index_dir=index_dir)
+ fresh.load()
+ assert fresh.count == len(image_paths)
+ assert fresh.search_by_image(image_paths[0], k=1)[0]["metadata"]["image_path"] == image_paths[0]
+
+
+class TestIntrospection:
+ def test_info_reports_the_configuration(self, engine, image_paths):
+ engine.index(image_paths)
+ info = engine.info()
+ assert info["indexed_images"] == len(image_paths)
+ assert info["dimension"] == DIM
+ assert info["vector_store"] == "FAISSStore"
+ assert info["metadata_store"] == "JsonMetadataStore"
+ assert info["supports_text_search"] is True
+
+ def test_supports_text_search_follows_the_backend(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(
+ se_module.EmbeddingManager, "create",
+ staticmethod(lambda *a, **k: DummyEmbedding(supports_text=False)),
+ )
+ assert SearchEngine(index_dir=str(tmp_path)).supports_text_search is False
+
+ def test_repr_mentions_model_and_count(self, engine, image_paths):
+ engine.index(image_paths)
+ text = repr(engine)
+ assert "SearchEngine(" in text
+ assert f"images={len(image_paths)}" in text
+
+
+def test_custom_store_subclass_is_accepted_without_a_name_lookup(tmp_path):
+ """A user-supplied BaseVectorStore must bypass _create_store entirely."""
+
+ class RecordingStore(BaseVectorStore):
+ def __init__(self):
+ self.added = []
+
+ def add(self, ids, vectors, metadata=None):
+ self.added.append(ids)
+
+ def search(self, query_vector, k=10, filters=None):
+ return []
+
+ def delete(self, ids):
+ pass
+
+ def count(self):
+ return len(self.added)
+
+ def save(self, path):
+ pass
+
+ def load(self, path):
+ pass
+
+ store = RecordingStore()
+ engine = SearchEngine(vector_store=store, index_dir=str(tmp_path))
+ engine.index([], save=False)
+ assert engine.vector_store is store
diff --git a/tests/test_v2_compat.py b/tests/test_v2_compat.py
new file mode 100644
index 0000000..86bed51
--- /dev/null
+++ b/tests/test_v2_compat.py
@@ -0,0 +1,119 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""
+Tests for the v2-compatible Search_Setup shim.
+
+This is the API v2 users still call, so its surface is pinned here: constructor
+validation, index-on-disk layout, and the {index: path} return shape.
+"""
+
+import os
+
+import pytest
+
+from DeepImageSearch import DeepImageSearch as v2_module
+from DeepImageSearch.DeepImageSearch import Search_Setup
+from tests.conftest import DummyEmbedding, make_image
+
+
+@pytest.fixture(autouse=True)
+def no_model_downloads(monkeypatch):
+ monkeypatch.setattr(
+ v2_module.EmbeddingManager, "create",
+ staticmethod(lambda *args, **kwargs: DummyEmbedding()),
+ )
+
+
+@pytest.fixture
+def setup(tmp_path, image_paths):
+ return Search_Setup(image_list=image_paths, metadata_dir=str(tmp_path / "metadata-files"))
+
+
+class TestConstruction:
+ def test_empty_image_list_raises(self, tmp_path):
+ with pytest.raises(ValueError, match="cannot be empty"):
+ Search_Setup(image_list=[], metadata_dir=str(tmp_path))
+
+ def test_non_list_raises(self, tmp_path, image_paths):
+ with pytest.raises(TypeError, match="must be a list"):
+ Search_Setup(image_list=image_paths[0], metadata_dir=str(tmp_path))
+
+ def test_image_count_truncates_the_list(self, tmp_path, image_paths):
+ setup = Search_Setup(image_list=image_paths, image_count=2, metadata_dir=str(tmp_path))
+ assert setup.image_list == image_paths[:2]
+
+ def test_image_count_none_keeps_everything(self, setup, image_paths):
+ assert setup.image_list == image_paths
+
+ def test_index_dir_is_namespaced_by_model(self, tmp_path, image_paths):
+ metadata_dir = tmp_path / "metadata-files"
+ setup = Search_Setup(image_list=image_paths, model_name="vgg19", metadata_dir=str(metadata_dir))
+ assert setup._index_dir == os.path.join(str(metadata_dir), "vgg19")
+ assert os.path.isdir(setup._index_dir)
+
+
+class TestIndexing:
+ def test_run_index_writes_the_index(self, setup, image_paths):
+ setup.run_index()
+ assert setup.vector_store.count() == len(image_paths)
+ assert os.path.exists(os.path.join(setup._index_dir, "index.faiss"))
+
+ def test_run_index_is_skipped_when_an_index_exists(self, setup, image_paths):
+ setup.run_index()
+ setup.run_index() # must not double up
+ assert setup.vector_store.count() == len(image_paths)
+
+ def test_force_reindex_rebuilds_from_scratch(self, setup, image_paths):
+ setup.run_index()
+ setup.run_index(force_reindex=True)
+ assert setup.vector_store.count() == len(image_paths)
+
+ def test_force_reindex_rewires_indexer_and_searcher(self, setup):
+ setup.run_index()
+ setup.run_index(force_reindex=True)
+ # A stale Searcher pointing at the discarded store would silently
+ # return results from the old index.
+ assert setup.searcher.vector_store is setup.vector_store
+ assert setup.indexer.vector_store is setup.vector_store
+
+ def test_add_images_to_index_extends_and_persists(self, setup, tmp_path, image_paths):
+ setup.run_index()
+ later = make_image(tmp_path / "later.png", (9, 9, 9))
+ setup.add_images_to_index([later])
+ assert setup.vector_store.count() == len(image_paths) + 1
+
+ def test_an_existing_index_is_reloaded_on_construction(self, tmp_path, image_paths):
+ metadata_dir = str(tmp_path / "metadata-files")
+ Search_Setup(image_list=image_paths, metadata_dir=metadata_dir).run_index()
+
+ reopened = Search_Setup(image_list=image_paths, metadata_dir=metadata_dir)
+ assert reopened.vector_store.count() == len(image_paths)
+
+
+class TestSearch:
+ def test_get_similar_images_returns_index_to_path(self, setup, image_paths):
+ setup.run_index()
+ similar = setup.get_similar_images(image_paths[0], number_of_images=2)
+ assert list(similar) == [0, 1]
+ assert similar[0] == image_paths[0]
+
+ def test_plot_similar_images_delegates(self, setup, image_paths, monkeypatch):
+ setup.run_index()
+ calls = []
+ monkeypatch.setattr(setup.searcher, "plot_similar_images", lambda p, n: calls.append((p, n)))
+ setup.plot_similar_images(image_paths[0], number_of_images=4)
+ assert calls == [(image_paths[0], 4)]
+
+ def test_metadata_file_summarises_the_index(self, setup, image_paths):
+ setup.run_index()
+ meta = setup.get_image_metadata_file()
+ assert meta["total_images"] == len(image_paths)
+ assert meta["model"] == "vgg19"
+ assert meta["index_dir"] == setup._index_dir
+
+
+def test_module_still_exports_the_v2_names():
+ assert set(v2_module.__all__) == {"Load_Data", "Search_Setup"}
+ # `from DeepImageSearch.DeepImageSearch import Load_Data` is the v2 import path.
+ for name in v2_module.__all__:
+ assert getattr(v2_module, name, None) is not None
diff --git a/tests/test_vectorstores.py b/tests/test_vectorstores.py
new file mode 100644
index 0000000..4122170
--- /dev/null
+++ b/tests/test_vectorstores.py
@@ -0,0 +1,159 @@
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2021 Nilesh Verma
+"""Tests for the FAISS vector store and the BaseVectorStore contract."""
+
+import numpy as np
+import pytest
+
+from DeepImageSearch.vectorstores.base import BaseVectorStore
+from DeepImageSearch.vectorstores.faiss_store import FAISSStore
+
+DIM = 4
+
+
+def unit(*values) -> np.ndarray:
+ arr = np.asarray(values, dtype=np.float32)
+ return arr / np.linalg.norm(arr)
+
+
+@pytest.fixture
+def populated_store():
+ """Three orthogonal unit vectors, so nearest-neighbour order is unambiguous."""
+ store = FAISSStore(dimension=DIM)
+ store.add(
+ ids=["a", "b", "c"],
+ vectors=np.vstack([unit(1, 0, 0, 0), unit(0, 1, 0, 0), unit(0, 0, 1, 0)]),
+ metadata=[{"colour": "red"}, {"colour": "green"}, {"colour": "red"}],
+ )
+ return store
+
+
+class TestConstruction:
+ @pytest.mark.parametrize("index_type", ["flat", "hnsw"])
+ def test_supported_index_types_build(self, index_type):
+ store = FAISSStore(dimension=DIM, index_type=index_type)
+ assert store.count() == 0
+ assert store.index.d == DIM
+
+ def test_unknown_index_type_raises(self):
+ with pytest.raises(ValueError, match="Unknown index_type"):
+ FAISSStore(dimension=DIM, index_type="quantum")
+
+
+class TestAdd:
+ def test_count_reflects_added_vectors(self, populated_store):
+ assert populated_store.count() == 3
+
+ def test_metadata_defaults_to_empty_dicts(self):
+ store = FAISSStore(dimension=DIM)
+ store.add(ids=["a"], vectors=unit(1, 0, 0, 0).reshape(1, -1))
+ assert store.search(unit(1, 0, 0, 0), k=1)[0]["metadata"] == {}
+
+ def test_accepts_non_contiguous_float64_input(self):
+ store = FAISSStore(dimension=DIM)
+ vectors = np.eye(4, dtype=np.float64)[:2] # float64, and a view
+ store.add(ids=["a", "b"], vectors=vectors)
+ assert store.count() == 2
+
+
+class TestSearch:
+ def test_returns_nearest_first(self, populated_store):
+ results = populated_store.search(unit(1, 0, 0, 0), k=3)
+ assert results[0]["id"] == "a"
+ assert results[0]["score"] == pytest.approx(1.0, abs=1e-5)
+
+ def test_respects_k(self, populated_store):
+ assert len(populated_store.search(unit(1, 0, 0, 0), k=2)) == 2
+
+ def test_result_shape(self, populated_store):
+ result = populated_store.search(unit(1, 0, 0, 0), k=1)[0]
+ assert set(result) == {"id", "score", "metadata"}
+ assert isinstance(result["score"], float)
+
+ def test_empty_store_returns_no_results(self):
+ assert FAISSStore(dimension=DIM).search(unit(1, 0, 0, 0), k=5) == []
+
+ def test_k_larger_than_index_is_clamped(self, populated_store):
+ assert len(populated_store.search(unit(1, 0, 0, 0), k=99)) == 3
+
+ def test_accepts_a_row_vector(self, populated_store):
+ results = populated_store.search(unit(1, 0, 0, 0).reshape(1, -1), k=1)
+ assert results[0]["id"] == "a"
+
+
+class TestFilters:
+ def test_equality_filter(self, populated_store):
+ results = populated_store.search(unit(1, 0, 0, 0), k=3, filters={"colour": "red"})
+ assert {r["id"] for r in results} == {"a", "c"}
+
+ def test_list_filter_matches_any_value(self, populated_store):
+ results = populated_store.search(unit(1, 0, 0, 0), k=3, filters={"colour": ["green", "red"]})
+ assert len(results) == 3
+
+ def test_filter_on_absent_key_excludes_the_record(self, populated_store):
+ assert populated_store.search(unit(1, 0, 0, 0), k=3, filters={"missing": "x"}) == []
+
+ def test_filters_are_combined_with_and(self, populated_store):
+ results = populated_store.search(unit(1, 0, 0, 0), k=3, filters={"colour": "red", "missing": 1})
+ assert results == []
+
+ def test_filtered_search_still_honours_k(self, populated_store):
+ assert len(populated_store.search(unit(1, 0, 0, 0), k=1, filters={"colour": "red"})) == 1
+
+
+class TestDelete:
+ def test_removes_vector_and_metadata(self, populated_store):
+ populated_store.delete(["a"])
+ assert populated_store.count() == 2
+ assert "a" not in {r["id"] for r in populated_store.search(unit(1, 0, 0, 0), k=3)}
+
+ def test_surviving_records_keep_their_metadata(self, populated_store):
+ populated_store.delete(["a"])
+ results = populated_store.search(unit(0, 1, 0, 0), k=1)
+ assert results[0]["id"] == "b"
+ assert results[0]["metadata"] == {"colour": "green"}
+
+ def test_deleting_unknown_id_is_a_no_op(self, populated_store):
+ populated_store.delete(["never-added"])
+ assert populated_store.count() == 3
+
+ def test_deleting_everything_empties_the_store(self, populated_store):
+ populated_store.delete(["a", "b", "c"])
+ assert populated_store.count() == 0
+ assert populated_store.search(unit(1, 0, 0, 0), k=1) == []
+
+
+class TestPersistence:
+ def test_save_load_roundtrip_preserves_results(self, populated_store, tmp_path):
+ populated_store.save(str(tmp_path))
+
+ reloaded = FAISSStore(dimension=DIM)
+ reloaded.load(str(tmp_path))
+
+ assert reloaded.count() == 3
+ assert reloaded.dimension == DIM
+ assert reloaded.index_type == "flat"
+ results = reloaded.search(unit(0, 0, 1, 0), k=1)
+ assert results[0]["id"] == "c"
+ assert results[0]["metadata"] == {"colour": "red"}
+
+ def test_save_creates_missing_directories(self, populated_store, tmp_path):
+ target = tmp_path / "nested" / "deeper"
+ populated_store.save(str(target))
+ assert (target / "index.faiss").exists()
+ assert (target / "metadata.json").exists()
+
+ def test_reloaded_store_accepts_more_vectors(self, populated_store, tmp_path):
+ populated_store.save(str(tmp_path))
+ reloaded = FAISSStore(dimension=DIM)
+ reloaded.load(str(tmp_path))
+ reloaded.add(ids=["d"], vectors=unit(0, 0, 0, 1).reshape(1, -1), metadata=[{"colour": "blue"}])
+ assert reloaded.count() == 4
+ assert reloaded.search(unit(0, 0, 0, 1), k=1)[0]["id"] == "d"
+
+
+def test_faiss_store_implements_the_full_interface():
+ abstract = BaseVectorStore.__abstractmethods__
+ assert abstract
+ assert not abstract - set(dir(FAISSStore))
+ assert not FAISSStore.__abstractmethods__