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 @@ -

Deep Image Search Logo

+

Deep Image Search Logo

**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__