From 46946bf11c35a118dd3fb01366f6ad6c1478f1c3 Mon Sep 17 00:00:00 2001 From: Bimantoro Maesa Date: Sat, 7 Mar 2026 15:45:46 +0700 Subject: [PATCH] Add backend catalog and pip installer for non-HuggingFace OCR models - Implemented a static catalog of non-HuggingFace OCR backends in `backend_catalog.py`, mapping model names to installation metadata. - Created a pip installer in `pip_installer.py` that securely installs packages from the catalog. - Added tests for the catalog and installer functionalities, ensuring correct behavior for known backends and error handling. --- .github/copilot-instructions.md | 27 ++- Dockerfile | 20 +++ README.md | 62 +++++-- docs/api.md | 209 ++++++++++++++++++----- docs/deployment.md | 124 +++++++++++++- examples/images/a01-122-02.jpg | Bin 0 -> 68432 bytes examples/rest_infer.py | 29 +++- examples/rest_vlm.py | 16 +- examples/ws_video_infer.py | 41 +++-- mataserver/api/v1/models.py | 56 ++++-- mataserver/core/backend_catalog.py | 63 +++++++ mataserver/core/model_loader.py | 23 ++- mataserver/core/models.py | 34 +++- mataserver/core/pip_installer.py | 46 +++++ mataserver/core/pull.py | 42 ++++- mataserver/main.py | 29 +++- mataserver/models/registry.py | 58 ++++--- pyproject.toml | 3 + tests/test_api/test_models.py | 78 +++++++++ tests/test_cli.py | 215 ++++++++++++++++++++++++ tests/test_core/test_backend_catalog.py | 93 ++++++++++ tests/test_core/test_pip_installer.py | 103 ++++++++++++ tests/test_core/test_pull.py | 113 ++++++++++++- tests/test_models/test_registry.py | 90 +++++++++- 24 files changed, 1422 insertions(+), 152 deletions(-) create mode 100644 examples/images/a01-122-02.jpg create mode 100644 mataserver/core/backend_catalog.py create mode 100644 mataserver/core/pip_installer.py create mode 100644 tests/test_core/test_backend_catalog.py create mode 100644 tests/test_core/test_pip_installer.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a609248..4131026 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,7 +21,7 @@ Circular dependency is intentionally broken by patching `memory._on_evict = load All services live on `app.state`: `settings`, `registry`, `runtime_manager`, `session_manager`. Access them in route handlers via `request.app.state.`. -**ModelRegistry** maps HuggingFace repo IDs → tasks in a JSON sidecar (`data_dir/model_registry.json`). Models are fetched from the HF cache via `huggingface_hub`; the registry is populated by `POST /v1/models/pull`. +**ModelRegistry** maps model IDs → `{task, source}` in a JSON sidecar (`data_dir/model_registry.json`). HuggingFace models (`source="hf"`) are fetched via `huggingface_hub`; pip-based OCR backends (`source="pip"`) are installed via `mataserver/core/pip_installer.py`. The registry supports both old flat format (`{"model": "task"}`) and new dict-of-dicts format (`{"model": {"task": "...", "source": "..."}}`), auto-migrating on read. The registry is populated by `POST /v1/models/pull`. ## Key Conventions @@ -102,4 +102,27 @@ Two-step: `POST /v1/sessions` creates a session → `WS /v1/stream/{session_id}` | `mataserver/schemas/requests.py` | `InferParams`, `to_mata_kwargs()`, `SUPPORTED_TASKS` | | `mataserver/core/result_converter.py` | MATA `VisionResult` → `InferResponse` dispatch | | `mataserver/api/deps.py` | Auth dependencies (HTTP + WebSocket) | -| `mataserver/models/registry.py` | Persistent HF model ID → task map | +| `mataserver/models/registry.py` | Persistent model ID → task + source map | +| `mataserver/core/backend_catalog.py` | Static catalog of pip-based OCR backends | +| `mataserver/core/pip_installer.py` | Pip install helper for non-HF backends | + +### Backend Catalog (pip-based backends) + +`mataserver/core/backend_catalog.py` is a **static Python catalog** (not JSON/YAML) that maps short backend names to installation metadata. This prevents arbitrary pip installs from user input. + +```python +from mataserver.core.backend_catalog import lookup, is_cataloged, get_source_type + +entry = lookup("easyocr") # CatalogEntry or None +is_cataloged("easyocr") # True +get_source_type("easyocr") # "pip" +get_source_type("org/model") # "hf" +``` + +Currently cataloged pip backends: `easyocr`, `paddleocr`, `tesseract`. + +When adding a new pip backend: + +1. Add a `CatalogEntry` to `_CATALOG` in `backend_catalog.py`. +2. `pull.py` and `mataserver/api/v1/models.py` dispatch automatically. +3. Register a result converter with `@_register("ocr")` in `result_converter.py` if needed. diff --git a/Dockerfile b/Dockerfile index 8638d6c..0882c53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,4 +72,24 @@ ENV MATA_SERVER_PORT=8110 ENV MATA_SERVER_DATA_DIR=/var/lib/mataserver ENV PYTHONPATH=/usr/local/lib/python3.11/site-packages +# Optional: Pre-install OCR backends into the image at build time. +# Pre-baking avoids runtime pip installs and removes the need for outbound internet +# access in the container. Uncomment the backends you need. +# +# EasyOCR: +# RUN pip install --no-cache-dir easyocr +# +# PaddleOCR: +# RUN pip install --no-cache-dir paddlepaddle paddleocr +# +# Tesseract (requires system binary + Python binding): +# RUN apt-get update && apt-get install -y --no-install-recommends tesseract-ocr \ +# && rm -rf /var/lib/apt/lists/* \ +# && pip install --no-cache-dir pytesseract +# +# After installing pip packages, register each backend so it appears in the registry: +# RUN mataserver pull easyocr --task ocr +# RUN mataserver pull paddleocr --task ocr +# RUN mataserver pull tesseract --task ocr + ENTRYPOINT ["mataserver", "serve"] diff --git a/README.md b/README.md index 27e32b1..5908df1 100644 --- a/README.md +++ b/README.md @@ -93,16 +93,16 @@ curl http://localhost:8110/v1/health The `mataserver` console script provides commands for server management and model operations. -| Command | Description | -| ------------------------------ | ---------------------------------------------- | -| `mataserver serve` | Start the inference server | -| `mataserver pull --task T` | Download and register a model from HuggingFace | -| `mataserver list` | List all registered models (alias: `ls`) | -| `mataserver show ` | Show detailed info for a model | -| `mataserver rm ` | Remove a model from the registry | -| `mataserver load ` | Preload a model into memory (alias: `warmup`) | -| `mataserver stop ` | Unload a model from memory | -| `mataserver version` | Print version (also: `mataserver -v`) | +| Command | Description | +| ------------------------------ | ------------------------------------------------------------------ | +| `mataserver serve` | Start the inference server | +| `mataserver pull --task T` | Download/install and register a model (HuggingFace or pip backend) | +| `mataserver list` | List all registered models (alias: `ls`) | +| `mataserver show ` | Show detailed info for a model | +| `mataserver rm ` | Remove a model from the registry | +| `mataserver load ` | Preload a model into memory (alias: `warmup`) | +| `mataserver stop ` | Unload a model from memory | +| `mataserver version` | Print version (also: `mataserver -v`) | For full usage details, argument references, and examples, see [docs/api.md](docs/api.md#cli). @@ -167,17 +167,53 @@ curl http://localhost:8110/v1/health { "status": "ok", "version": "0.1.0", "gpu_available": false } ``` -### Pull a model from HuggingFace +### Pull a model ```bash +# HuggingFace model curl -X POST http://localhost:8110/v1/models/pull \ -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ - -d '{"source": "hf://datamata/rtdetr-l"}' + -d '{"model": "datamata/rtdetr-l", "task": "detect"}' ``` ```json -{ "status": "pulling", "model": "datamata/rtdetr-l" } +{ "status": "pulled", "model": "datamata/rtdetr-l" } +``` + +```bash +# Pip-based OCR backend +curl -X POST http://localhost:8110/v1/models/pull \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"model": "easyocr", "task": "ocr"}' +``` + +Or via the CLI: + +```bash +# HuggingFace Task Detection model (Example: RT-DETR ResNet-18 backbone) +mataserver pull PekingU/rtdetr_r18vd --task detect + +# HuggingFace Task Classification model (Example: ResNet-50) +mataserver pull microsoft/resnet-50 --task classify + +# HuggingFace Task Segmentation model (Example: Mask2Former Swin-Tiny trained on COCO) +mataserver pull facebook/mask2former-swin-tiny-coco-instance --task segment + +# HuggingFace Task Depth model (Example: Depth Anything V2 Small) +mataserver pull depth-anything/Depth-Anything-V2-Small-hf --task depth + +# HuggingFace Task Visual Language Model (VLM) +mataserver pull Qwen/Qwen3-VL-2B-Instruct --task vlm + +# HuggingFace OCR model +mataserver pull stepfun-ai/GOT-OCR-2.0-hf --task ocr + +# Pip-installed OCR backends +mataserver pull easyocr --task ocr +mataserver pull paddleocr --task ocr +mataserver pull tesseract --task ocr # requires tesseract system binary ``` ### Single-shot inference (base64 JSON) diff --git a/docs/api.md b/docs/api.md index 404f4bf..a216582 100644 --- a/docs/api.md +++ b/docs/api.md @@ -46,7 +46,10 @@ mataserver serve ### `mataserver pull` -Download a HuggingFace model into the local cache and register it with the server so it appears in `GET /v1/models` and can be used for inference. +Download a model and register it with the server so it appears in `GET /v1/models` and can be used for inference. Supports two backend types: + +- **HuggingFace models** — downloaded via `huggingface_hub.snapshot_download()` and stored in the standard HF cache (`~/.cache/huggingface`). These are identified by a `org/repo-name` slash-separated ID. +- **Pip-based OCR backends** — installed via `pip` into the current Python environment. These are identified by a short backend name (e.g. `easyocr`, `paddleocr`, `tesseract`). ```bash mataserver pull --task @@ -54,24 +57,35 @@ mataserver pull --task | Argument | Description | | ---------- | ----------------------------------------------------------------------------------------------- | -| `MODEL_ID` | HuggingFace repo ID, e.g. `facebook/detr-resnet-50` | +| `MODEL_ID` | HuggingFace repo ID (`org/name`) or pip backend name (`easyocr`, `paddleocr`, `tesseract`) | | `--task` | Inference task. One of: `classify`, `depth`, `detect`, `ocr`, `pose`, `segment`, `track`, `vlm` | -The model is downloaded via `huggingface_hub.snapshot_download()` and stored in the standard HuggingFace cache directory (`~/.cache/huggingface` by default, or the path set by `HF_HUB_CACHE`). The model ID and task are then written to `model_registry.json` in `MATA_SERVER_DATA_DIR`. +After a successful pull the model ID, task, and source type are written to `model_registry.json` in `MATA_SERVER_DATA_DIR`. **Examples**: ```bash -# Object detection +# Object detection (HuggingFace) mataserver pull facebook/detr-resnet-50 --task=detect -# Image classification +# Image classification (HuggingFace) mataserver pull google/vit-base-patch16-224 --task=classify -# Depth estimation +# Depth estimation (HuggingFace) mataserver pull LiheYoung/depth-anything-base-hf --task=depth + +# OCR — HuggingFace models +mataserver pull stepfun-ai/GOT-OCR-2.0-hf --task ocr +mataserver pull microsoft/trocr-base-printed --task ocr + +# OCR — pip-based backends +mataserver pull easyocr --task ocr +mataserver pull paddleocr --task ocr +mataserver pull tesseract --task ocr # also requires the tesseract system binary ``` +> **Pip OCR backends**: `easyocr`, `paddleocr`, and `tesseract` are installed as Python packages into the active virtual environment rather than downloaded from HuggingFace. `tesseract` additionally requires the `tesseract-ocr` system binary; if it is not found on `PATH` a warning is printed but the pull still succeeds. See [OCR Backends](#ocr-backends) for details. + **Exit codes**: | Code | Meaning | @@ -98,8 +112,9 @@ mataserver list | Column | Description | | ----------- | ---------------------------------------------------------------- | -| `MODEL` | HuggingFace repo ID | +| `MODEL` | HuggingFace repo ID or pip backend name | | `TASK` | Inference task (`detect`, `segment`, etc.) | +| `SOURCE` | `hf` for HuggingFace models, `pip` for pip-based backends | | `SIZE (MB)` | On-disk size in MB from the HuggingFace cache, or `—` if unknown | If no models are registered, prints `"No models registered."`. @@ -109,10 +124,12 @@ If no models are registered, prints `"No models registered."`. ```bash mataserver list -MODEL TASK SIZE (MB) -------------------------------------------------------- -facebook/detr-resnet-50 detect 167.3 -google/vit-base-patch16-224 classify 327.5 +MODEL TASK SOURCE SIZE (MB) +-------------------------------------------------------------- +facebook/detr-resnet-50 detect hf 167.3 +google/vit-base-patch16-224 classify hf 327.5 +easyocr ocr pip — +tesseract ocr pip — ``` **Exit codes**: @@ -137,24 +154,58 @@ mataserver show **Output fields**: -| Field | Description | -| --------------- | ----------------------------- | -| `model` | HuggingFace repo ID | -| `task` | Registered inference task | -| `size` | On-disk size in MB, or `—` | -| `last_accessed` | Timestamp of last use, or `—` | +| Field | Description | +| --------------- | ------------------------------------------------------------------- | +| `model` | HuggingFace repo ID or pip backend name | +| `task` | Registered inference task | +| `source` | `hf` (HuggingFace) or `pip` (pip-based backend) | +| `size` | On-disk size in MB from HF cache, or `—` (pip models have no cache) | +| `last_accessed` | Timestamp of last HF cache access, or `—` (pip models) | +| `pip_packages` | _(pip only)_ Comma-separated list of installed pip packages | +| `installed` | _(pip only)_ `yes` / `no` — whether the package is importable | +| `system_binary` | _(pip only, if applicable)_ Binary name and whether it was found | -**Example**: +**Example — HuggingFace model**: ```bash mataserver show facebook/detr-resnet-50 model: facebook/detr-resnet-50 task: detect + source: hf size: 167.30 MB last_accessed: 2026-03-05 14:22:01 ``` +**Example — pip backend**: + +```bash +mataserver show easyocr + + model: easyocr + task: ocr + source: pip + size: — + last_accessed: — + pip_packages: easyocr + installed: yes +``` + +**Example — Tesseract (with system binary check)**: + +```bash +mataserver show tesseract + + model: tesseract + task: ocr + source: pip + size: — + last_accessed: — + pip_packages: pytesseract + installed: yes + system_binary: tesseract (yes) +``` + **Exit codes**: | Code | Meaning | @@ -176,7 +227,7 @@ mataserver rm | ---------- | ----------------------------------------------- | | `MODEL_ID` | HuggingFace repo ID to remove from the registry | -**Example**: +**Example — HuggingFace model**: ```bash mataserver rm facebook/detr-resnet-50 @@ -184,6 +235,14 @@ Removed 'facebook/detr-resnet-50' from the registry. Note: model weights on disk (HF cache) were not deleted. ``` +**Example — pip backend**: + +```bash +mataserver rm easyocr +Removed 'easyocr' from the registry. +Note: pip packages were not uninstalled. Remove manually if needed. +``` + **Exit codes**: | Code | Meaning | @@ -282,6 +341,59 @@ mataserver 0.6.0 --- +## OCR Backends + +The `ocr` task supports two categories of model backend: + +### HuggingFace OCR models + +These are pulled like any other model — weights are downloaded into the HuggingFace cache and `source` is recorded as `"hf"`: + +| Model ID | Notes | +| ---------------------------------- | -------------------------------------- | +| `stepfun-ai/GOT-OCR-2.0-hf` | GOT-OCR2 — general-purpose OCR | +| `microsoft/trocr-base-printed` | TrOCR — optimised for printed text | +| `microsoft/trocr-base-handwritten` | TrOCR — optimised for handwritten text | + +### Pip-based OCR backends + +These are installed as Python packages (and optionally require a system binary). `source` is recorded as `"pip"`. They do **not** occupy space in the HuggingFace cache. + +| Backend name | Pip packages installed | System binary required | Notes | +| ------------ | --------------------------- | ---------------------- | --------------------------------------- | +| `easyocr` | `easyocr` | None | Supports 80+ languages | +| `paddleocr` | `paddlepaddle`, `paddleocr` | None | High accuracy; larger install | +| `tesseract` | `pytesseract` | `tesseract-ocr` | Requires system binary (see note below) | + +> **Tesseract system binary**: `mataserver pull tesseract --task ocr` installs the `pytesseract` Python wrapper but **not** the `tesseract-ocr` binary itself. Install it separately: +> +> - **Debian/Ubuntu**: `apt-get install -y tesseract-ocr` +> - **macOS**: `brew install tesseract` +> - **Windows**: Download from [UB-Mannheim/tesseract](https://github.com/UB-Mannheim/tesseract/wiki) +> +> If the binary is not found at pull time, a warning is printed but the backend is still registered. + +### Checking backend status + +```bash +mataserver show easyocr + source: pip + pip_packages: easyocr + installed: yes + +mataserver show tesseract + source: pip + pip_packages: pytesseract + installed: yes + system_binary: tesseract (yes) +``` + +### Removing a pip backend + +`mataserver rm ` removes the registration entry only. The pip packages are **not** uninstalled automatically; remove them manually with `pip uninstall ` if desired. + +--- + ## Authentication Most endpoints require a Bearer token in the `Authorization` header. @@ -381,24 +493,36 @@ List all models that are currently installed (i.e. present in the HuggingFace ca { "model": "PekingU/rtdetr_v2_r101vd", "task": "detect", + "source": "hf", "state": "idle", "size_mb": 421.0, "memory_mb": 512.0, "loaded_at": 1709550000.0, "last_used": 1709553600.0 + }, + { + "model": "easyocr", + "task": "ocr", + "source": "pip", + "state": "unloaded", + "size_mb": null, + "memory_mb": null, + "loaded_at": null, + "last_used": null } ] ``` -| Field | Type | Description | -| ----------- | -------------- | --------------------------------------------------- | -| `model` | string | HuggingFace repo ID (e.g. `"org/name"`) | -| `task` | string | Inference task (`detect`, `segment`, `classify`, …) | -| `state` | string | Current lifecycle state (see table below) | -| `size_mb` | number \| null | On-disk size in MB from the HuggingFace cache | -| `memory_mb` | number \| null | Allocated memory in MB (null when unloaded) | -| `loaded_at` | number \| null | Unix timestamp of when the model was loaded | -| `last_used` | number \| null | Unix timestamp of the most recent inference call | +| Field | Type | Description | +| ----------- | -------------- | ------------------------------------------------------------- | +| `model` | string | HuggingFace repo ID or pip backend name | +| `task` | string | Inference task (`detect`, `segment`, `classify`, …) | +| `source` | string | `"hf"` for HuggingFace models, `"pip"` for pip-based backends | +| `state` | string | Current lifecycle state (see table below) | +| `size_mb` | number \| null | On-disk size in MB from the HF cache; `null` for pip backends | +| `memory_mb` | number \| null | Allocated memory in MB (null when unloaded) | +| `loaded_at` | number \| null | Unix timestamp of when the model was loaded | +| `last_used` | number \| null | Unix timestamp of the most recent inference call | Model `state` values: @@ -444,7 +568,7 @@ curl -H "Authorization: Bearer $KEY" \ ### `POST /v1/models/pull` -Download a model from HuggingFace into the default HuggingFace cache (`~/.cache/huggingface`) and register it with the server. The operation runs asynchronously; the 202 response is returned once the download completes. +Download or install a model and register it with the server. Supports both HuggingFace models (downloaded into `~/.cache/huggingface`) and pip-based OCR backends (installed into the active Python environment). The 202 response is returned once the operation completes. **Request body**: @@ -455,10 +579,10 @@ Download a model from HuggingFace into the default HuggingFace cache (`~/.cache/ } ``` -| Field | Type | Description | -| ------- | ------ | --------------------------------------------- | -| `model` | string | HuggingFace repo ID (e.g. `"org/model-name"`) | -| `task` | string | Inference task (`detect`, `segment`, …) | +| Field | Type | Description | +| ------- | ------ | ----------------------------------------------------------------------------- | +| `model` | string | HuggingFace repo ID (`"org/model-name"`) or pip backend name (`"easyocr"`, …) | +| `task` | string | Inference task (`detect`, `segment`, `ocr`, …) | **Response `202 Accepted`**: @@ -468,19 +592,26 @@ Download a model from HuggingFace into the default HuggingFace cache (`~/.cache/ **Error responses**: -| Code | Condition | -| ---- | ----------------------------------------------------------- | -| 400 | Pull failed (network error, model not found on HuggingFace) | -| 409 | A pull for the same model is already in progress | -| 500 | Unexpected server error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------ | +| 400 | Pull failed (network error, model not found, task mismatch, pip install error) | +| 409 | A pull for the same model is already in progress | +| 500 | Unexpected server error | -**Example**: +**Examples**: ```bash +# HuggingFace model curl -X POST http://localhost:8110/v1/models/pull \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"model": "PekingU/rtdetr_v2_r101vd", "task": "detect"}' + +# Pip-based OCR backend +curl -X POST http://localhost:8110/v1/models/pull \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/json" \ + -d '{"model": "easyocr", "task": "ocr"}' ``` --- diff --git a/docs/deployment.md b/docs/deployment.md index 0d50712..31a7005 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -14,6 +14,7 @@ This guide covers production deployment of MATASERVER using Docker, Docker Compo 6. [Environment Variable Configuration](#6-environment-variable-configuration) 7. [Health Check & Monitoring](#7-health-check--monitoring) 8. [Data Directory & Volume Management](#8-data-directory--volume-management) +9. [Pre-installing OCR Backends](#9-pre-installing-ocr-backends) --- @@ -491,12 +492,123 @@ docker run --rm \ ### Disk Space Planning -| Item | Typical Size | -| ---------------------------------- | ----------------------------- | -| Server image | ~2-2.5 GB | -| Small vision model (ONNX) | 20-100 MB | -| Large VLM | 2-10 GB | -| Blob cache (HuggingFace downloads) | Varies; can be cleared safely | +| Item | Typical Size | +| ----------------------------------- | ----------------------------- | +| Server image | ~2-2.5 GB | +| Small vision model (ONNX) | 20-100 MB | +| Large VLM | 2-10 GB | +| Blob cache (HuggingFace downloads) | Varies; can be cleared safely | +| EasyOCR weights (auto-downloaded) | ~500 MB | +| PaddleOCR weights (auto-downloaded) | ~100-500 MB | + +--- + +## 9. Pre-installing OCR Backends + +MATASERVER supports three pip-based OCR backends — `easyocr`, `paddleocr`, and `tesseract` — in addition to HuggingFace OCR models such as `stepfun-ai/GOT-OCR-2.0-hf` and `microsoft/trocr-base-printed`. + +Pip backends are installed at runtime via `mataserver pull --task ocr`, which runs `pip install` inside the container. In production container images this is suboptimal: + +- It adds latency on the first `pull` request (pip resolves and downloads packages). +- It requires outbound internet access from the container at runtime. +- Layer caching is lost every container restart. + +The recommended approach is to **pre-bake** the pip packages into the image at build time. + +### Supported OCR Backends + +| Backend | Source | pip packages | System dependency | +| ----------- | ------ | ------------------------ | --------------------- | +| `easyocr` | pip | `easyocr` | none | +| `paddleocr` | pip | `paddlepaddle paddleocr` | none | +| `tesseract` | pip | `pytesseract` | `tesseract-ocr` (apt) | + +HuggingFace OCR models use the standard `mataserver pull --task ocr` path and are not affected by this section. + +### Pre-baking OCR Backends into a Docker Image + +Extend the runtime stage of the Dockerfile with the backends you need: + +```dockerfile +# ── EasyOCR ────────────────────────────────────────────────────────────────── +RUN pip install --no-cache-dir easyocr +RUN mataserver pull easyocr --task ocr + +# ── PaddleOCR ──────────────────────────────────────────────────────────────── +RUN pip install --no-cache-dir paddlepaddle paddleocr +RUN mataserver pull paddleocr --task ocr + +# ── Tesseract ──────────────────────────────────────────────────────────────── +# Step 1: install system binary (apt) +RUN apt-get update && apt-get install -y --no-install-recommends tesseract-ocr \ + && rm -rf /var/lib/apt/lists/* +# Step 2: install Python bindings +RUN pip install --no-cache-dir pytesseract +# Step 3: register the backend +RUN mataserver pull tesseract --task ocr +``` + +The `mataserver pull` commands register each backend in the model registry baked into the image. The container starts with the backends already available — no internet access is needed at runtime. + +> **Note**: EasyOCR and PaddleOCR download their model weights on first inference (not during `pull`). If you need fully air-gapped operation, pre-download the weights by running a warmup inference during the build stage. + +### Dockerfile: Commented Pre-install Block + +For reference, see the commented block near the end of the provided `Dockerfile`: + +```dockerfile +# Optional: Pre-install OCR backends +# Uncomment the backends you need: +# RUN pip install --no-cache-dir easyocr +# RUN pip install --no-cache-dir paddlepaddle paddleocr +# RUN apt-get update && apt-get install -y --no-install-recommends tesseract-ocr \ +# && rm -rf /var/lib/apt/lists/* \ +# && pip install --no-cache-dir pytesseract +``` + +### Tesseract System Dependency + +Tesseract requires the `tesseract-ocr` system binary. Install it: + +**Ubuntu / Debian (bare metal or inside a Dockerfile)**: + +```bash +sudo apt-get update && sudo apt-get install -y tesseract-ocr +``` + +**Alpine Linux**: + +```bash +apk add --no-cache tesseract-ocr +``` + +The Python binding `pytesseract` is a thin wrapper around the binary — it will fail at inference time if `tesseract` is not on `PATH`. + +After installing the system binary, register the backend: + +```bash +mataserver pull tesseract --task ocr +``` + +If the binary is not found when `mataserver pull tesseract` runs, a warning is logged but the pull succeeds (the system binary is checked again at inference time by the MATA adapter). + +### Verifying the Pre-installed Backends + +After building your custom image, confirm the backends are registered: + +```bash +docker run --rm mataserver-custom mataserver list +``` + +Expected output: + +``` +MODEL TASK SOURCE SIZE (MB) +----------------------------------------- +easyocr ocr pip — +paddleocr ocr pip — +tesseract ocr pip — +``` Monitor available space: diff --git a/examples/images/a01-122-02.jpg b/examples/images/a01-122-02.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ce1e481fa088f716074973a381a9889782855467 GIT binary patch literal 68432 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

lj?=-&H19BX^-HIWn4P7gUrwA6- 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 literal 0 HcmV?d00001 diff --git a/examples/rest_infer.py b/examples/rest_infer.py index 24aaa2d..6db380b 100644 --- a/examples/rest_infer.py +++ b/examples/rest_infer.py @@ -8,6 +8,9 @@ python examples/rest_infer.py --image examples/images/coco_cat_remote.jpg --task detect --model PekingU/rtdetr_r18vd python examples/rest_infer.py --image examples/images/coco_cat_remote.jpg --task classify --model google/efficientnet-b0 python examples/rest_infer.py --image examples/images/coco_cat_remote.jpg --task segment --model facebook/mask2former-swin-tiny-coco-instance + python examples/rest_infer.py --image examples/images/coco_cat_remote.jpg --task depth --model depth-anything/Depth-Anything-V2-Small-hf + + python examples/rest_infer.py --image examples/images/a01-122-02.jpg --task ocr --model easyocr # Zero-shot detection with text prompts python examples/rest_infer.py --image examples/images/coco_cat_remote.jpg --task detect --model google/owlv2-base-patch16-ensemble --prompts "cat,remote,dog,car" @@ -15,8 +18,7 @@ """ import argparse -import base64 -import json +import base64 from pathlib import Path import requests @@ -87,17 +89,30 @@ def main(image_path: Path, task: str | None, model: str | None, prompts: str | N for s in segments[:5]: print(f" [{s['confidence']:.2f}] {s['label']} bbox={s['bbox']}") + elif t == "depth": + depths = result.get("depth_map") or [] + print(f"Depth map shape: {depths}") + + elif t == "ocr": + ocr_result = result.get("text") or [] + print(f" OCR text results: {ocr_result}") + print(f" Full response keys: {list(result.keys())}") if __name__ == "__main__": - parser = argparse.ArgumentParser(description="REST inference — detect / classify / segment") + parser = argparse.ArgumentParser(description="REST inference — detect / classify / segment / depth / ocr") parser.add_argument("--image", type=Path, required=True, help="Path to an image file") - parser.add_argument("--task", choices=["detect", "classify", "segment"], default=None, - help="Run a single task (default: run all three)") + parser.add_argument( + "--task", + choices=["detect", "classify", "segment", "depth", "ocr"], + default=None, + help="Run a single task (default: run all three)", + ) parser.add_argument("--model", default=None, help="Override the model ref") - parser.add_argument("--prompts", default=None, - help="Comma-separated text prompts for zero-shot tasks") + parser.add_argument( + "--prompts", default=None, help="Comma-separated text prompts for zero-shot tasks" + ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8110) args = parser.parse_args() diff --git a/examples/rest_vlm.py b/examples/rest_vlm.py index cbabd5b..2b32b46 100644 --- a/examples/rest_vlm.py +++ b/examples/rest_vlm.py @@ -2,6 +2,7 @@ Usage: python examples/rest_vlm.py --image examples/images/coco_cat_remote.jpg --prompt "What do you see?" + python examples/rest_vlm.py --image examples/images/coco_cat_remote.jpg --prompt "List all objects you can identify in this image." --output_mode "detect" # With extra generation options python examples/rest_vlm.py --image examples/images/coco_cat_remote.jpg \ @@ -16,7 +17,7 @@ import requests BASE_URL = "http://127.0.0.1:8110/v1" -DEFAULT_MODEL = "Qwen/Qwen2.5-VL-3B-Instruct" +DEFAULT_MODEL = "Qwen/Qwen3-VL-2B-Instruct" def infer_vlm( @@ -25,6 +26,7 @@ def infer_vlm( prompt: str, max_tokens: int | None = None, temperature: float | None = None, + output_mode: str | None = None, ) -> dict: """POST /v1/infer for a VLM task and return the parsed response.""" image_b64 = base64.b64encode(image_path.read_bytes()).decode() @@ -34,6 +36,8 @@ def infer_vlm( params["max_tokens"] = max_tokens if temperature is not None: params["temperature"] = temperature + if output_mode is not None: + params["output_mode"] = output_mode payload = { "model": model, @@ -53,13 +57,14 @@ def main( prompt: str, max_tokens: int | None, temperature: float | None, + output_mode: str, ) -> None: - print(f"\n--- VLM Inference ---") + print("\n--- VLM Inference ---") print(f" Model : {model}") print(f" Prompt : {prompt!r}") - + print(f" Output Mode : {output_mode}") try: - result = infer_vlm(model, image_path, prompt, max_tokens, temperature) + result = infer_vlm(model, image_path, prompt, max_tokens, temperature, output_mode) except requests.HTTPError as exc: print(f" ERROR {exc.response.status_code}: {exc.response.text}") return @@ -75,6 +80,7 @@ def main( parser.add_argument("--model", default=DEFAULT_MODEL, help="VLM model ref") parser.add_argument("--max-tokens", type=int, default=None, help="Max tokens to generate") parser.add_argument("--temperature", type=float, default=None, help="Sampling temperature") + parser.add_argument("--output_mode", default="text", help="Output mode of the VLM") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8110) args = parser.parse_args() @@ -85,4 +91,4 @@ def main( print(f"ERROR: image not found: {args.image}") raise SystemExit(1) - main(args.image, args.model, args.prompt, args.max_tokens, args.temperature) + main(args.image, args.model, args.prompt, args.max_tokens, args.temperature, args.output_mode) diff --git a/examples/ws_video_infer.py b/examples/ws_video_infer.py index 77970d6..cdbb910 100644 --- a/examples/ws_video_infer.py +++ b/examples/ws_video_infer.py @@ -30,7 +30,7 @@ # --------------------------------------------------------------------------- # Binary frame wire format (must match mataserver/streaming/protocol.py) # --------------------------------------------------------------------------- -HEADER_FORMAT = ">IdB" # big-endian: uint32 frame_id, float64 timestamp, uint8 encoding +HEADER_FORMAT = ">IdB" # big-endian: uint32 frame_id, float64 timestamp, uint8 encoding HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 13 bytes ENCODING_JPEG = 0 @@ -62,7 +62,9 @@ async def stream_video( headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} # 1. Create a streaming session - print(f"\n[1/3] Creating session model={model!r} task={task!r} frame_policy={frame_policy!r}") + print( + f"\n[1/3] Creating session model={model!r} task={task!r} frame_policy={frame_policy!r}" + ) async with aiohttp.ClientSession(headers=headers) as http: async with http.post( f"{base_url}/v1/sessions", @@ -98,7 +100,6 @@ async def stream_video( ws_session = aiohttp.ClientSession() try: async with ws_session.ws_connect(ws_url) as ws: - # Background task: receive and print inference results async def receive_loop() -> None: nonlocal received, drops, errors @@ -109,7 +110,9 @@ async def receive_loop() -> None: drops += 1 continue if "error" in payload: - print(f" [frame {payload.get('frame_id', '?')}] ERROR: {payload['error']}") + print( + f" [frame {payload.get('frame_id', '?')}] ERROR: {payload['error']}" + ) errors += 1 else: fid = payload.get("frame_id", "?") @@ -155,7 +158,7 @@ async def receive_loop() -> None: except asyncio.CancelledError: pass - print(f"\n Sent : {sent} frames in {elapsed:.2f}s ({sent/elapsed:.1f} fps)") + print(f"\n Sent : {sent} frames in {elapsed:.2f}s ({sent / elapsed:.1f} fps)") print(f" Received: {received} results | {drops} dropped | {errors} errors") finally: cap.release() @@ -187,7 +190,9 @@ async def receive_loop() -> None: parser.add_argument("--fps-limit", type=float, default=0, help="Max send fps (0 = native)") parser.add_argument("--api-key", default=None, help="API key (omit if auth_mode=none)") parser.add_argument( - "--frame-policy", choices=["latest", "queue"], default="latest", + "--frame-policy", + choices=["latest", "queue"], + default="latest", help="Frame-handling policy (default: latest)", ) parser.add_argument("--host", default="127.0.0.1") @@ -198,14 +203,16 @@ async def receive_loop() -> None: print(f"ERROR: video not found: {args.video}") raise SystemExit(1) - asyncio.run(stream_video( - host=args.host, - port=args.port, - model=args.model, - task=args.task, - video_path=args.video, - max_frames=args.max_frames, - fps_limit=args.fps_limit, - frame_policy=args.frame_policy, - api_key=args.api_key, - )) + asyncio.run( + stream_video( + host=args.host, + port=args.port, + model=args.model, + task=args.task, + video_path=args.video, + max_frames=args.max_frames, + fps_limit=args.fps_limit, + frame_policy=args.frame_policy, + api_key=args.api_key, + ) + ) diff --git a/mataserver/api/v1/models.py b/mataserver/api/v1/models.py index 713c77c..ae8d882 100644 --- a/mataserver/api/v1/models.py +++ b/mataserver/api/v1/models.py @@ -6,6 +6,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from mataserver.api.deps import verify_api_key +from mataserver.core import pip_installer +from mataserver.core.backend_catalog import lookup as catalog_lookup from mataserver.core.pull import download_model from mataserver.schemas.requests import ( PullRequest, @@ -44,13 +46,13 @@ async def get_model(model_id: str, request: Request): @router.post("/models/pull", status_code=status.HTTP_202_ACCEPTED) async def pull_model(body: PullRequest, request: Request): - """Download a model from HuggingFace into the local cache and register it. + """Download or install a model and register it. - The model is downloaded via ``huggingface_hub.snapshot_download()`` which - stores weights in the standard HuggingFace cache (``~/.cache/huggingface`` - by default, or the path set by ``HF_HUB_CACHE``). The model ID and task - are then persisted in the server's registry so the model appears in - ``GET /v1/models`` and can be loaded for inference. + For HuggingFace models, weights are downloaded via + ``huggingface_hub.snapshot_download()``. For cataloged pip backends + (e.g. ``easyocr``, ``paddleocr``, ``tesseract``), the required packages + are installed via pip. In both cases the model ID and task are persisted + in the registry so the model appears in ``GET /v1/models``. """ if body.model in _pulling: raise HTTPException( @@ -60,17 +62,41 @@ async def pull_model(body: PullRequest, request: Request): _pulling.add(body.model) try: - try: - await asyncio.to_thread(download_model, body.model) - except Exception as exc: - logger.exception("Failed to download model %s", body.model) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Download failed for {body.model!r}: {exc}", - ) from exc + catalog_entry = catalog_lookup(body.model) + + if catalog_entry is not None: + # Pip-based backend + if body.task != catalog_entry.task: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"{catalog_entry.display_name} only supports task " + f"{catalog_entry.task!r}, got {body.task!r}" + ), + ) + try: + await asyncio.to_thread(pip_installer.install_packages, catalog_entry.pip_packages) + except Exception as exc: + logger.exception("Failed to install %s", body.model) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Install failed for {body.model!r}: {exc}", + ) from exc + source = "pip" + else: + # HuggingFace model — existing path + try: + await asyncio.to_thread(download_model, body.model) + except Exception as exc: + logger.exception("Failed to download model %s", body.model) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Download failed for {body.model!r}: {exc}", + ) from exc + source = "hf" registry = request.app.state.registry - await registry.register(body.model, body.task) + await registry.register(body.model, body.task, source=source) finally: _pulling.discard(body.model) diff --git a/mataserver/core/backend_catalog.py b/mataserver/core/backend_catalog.py new file mode 100644 index 0000000..eac293a --- /dev/null +++ b/mataserver/core/backend_catalog.py @@ -0,0 +1,63 @@ +"""Static catalog of non-HuggingFace OCR backends. + +Maps short names (e.g. "easyocr") to pip installation metadata. +Models NOT in this catalog are assumed to be HuggingFace repos +and follow the existing snapshot_download() path. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CatalogEntry: + """Installation metadata for a pip-based backend.""" + + task: str + pip_packages: tuple[str, ...] + verify_import: str + display_name: str + system_binary: str | None = None + estimated_memory_mb: float = 256.0 + + +_CATALOG: dict[str, CatalogEntry] = { + "easyocr": CatalogEntry( + task="ocr", + pip_packages=("easyocr",), + verify_import="easyocr", + display_name="EasyOCR", + estimated_memory_mb=300.0, + ), + "paddleocr": CatalogEntry( + task="ocr", + pip_packages=("paddlepaddle", "paddleocr"), + verify_import="paddleocr", + display_name="PaddleOCR", + estimated_memory_mb=400.0, + ), + "tesseract": CatalogEntry( + task="ocr", + pip_packages=("pytesseract",), + verify_import="pytesseract", + display_name="Tesseract OCR", + system_binary="tesseract", + estimated_memory_mb=100.0, + ), +} + + +def lookup(model: str) -> CatalogEntry | None: + """Return catalog entry if *model* is a known pip backend, else ``None``.""" + return _CATALOG.get(model) + + +def is_cataloged(model: str) -> bool: + """Return ``True`` if *model* is a known pip backend.""" + return model in _CATALOG + + +def get_source_type(model: str) -> str: + """Return ``"pip"`` if *model* is a cataloged backend, else ``"hf"``.""" + return "pip" if model in _CATALOG else "hf" diff --git a/mataserver/core/model_loader.py b/mataserver/core/model_loader.py index 8706017..fb4c0cd 100644 --- a/mataserver/core/model_loader.py +++ b/mataserver/core/model_loader.py @@ -17,13 +17,20 @@ def _estimate_memory_mb(model: str) -> float: - """Estimate model memory (MB) from its HuggingFace cache entry. + """Estimate model memory (MB) from catalog or HuggingFace cache entry. - Uses ``huggingface_hub.scan_cache_dir()`` to look up the on-disk size of - the model and applies a 1.2× overhead multiplier to account for runtime - buffers. Falls back to ``_DEFAULT_MEMORY_MB`` when the model is not yet - in the HF cache or the library is unavailable. + For pip-backed backends (e.g. easyocr, tesseract), returns the + ``estimated_memory_mb`` from the backend catalog. For HuggingFace models, + uses ``huggingface_hub.scan_cache_dir()`` and applies a 1.2× overhead + multiplier. Falls back to ``_DEFAULT_MEMORY_MB`` when the model is not + found in either source. """ + from mataserver.core.backend_catalog import lookup # noqa: PLC0415 + + catalog_entry = lookup(model) + if catalog_entry is not None: + return catalog_entry.estimated_memory_mb + try: info = scan_cache_dir() for repo in info.repos: @@ -92,7 +99,11 @@ async def load( # For segment task, request polygon output from the adapter so # callers receive plain coordinates instead of opaque RLE blobs. - adapter_kwargs: dict = {"device": device} + # Pip-backed OCR backends (e.g. easyocr) don't accept a 'device' + # kwarg — their Reader.__init__() uses gpu=True/False internally. + from mataserver.core.backend_catalog import lookup as _catalog_lookup # noqa: PLC0415 + + adapter_kwargs: dict = {} if _catalog_lookup(model) is not None else {"device": device} if task == "segment": adapter_kwargs["use_polygon"] = True diff --git a/mataserver/core/models.py b/mataserver/core/models.py index d136a4a..6fdea27 100644 --- a/mataserver/core/models.py +++ b/mataserver/core/models.py @@ -103,12 +103,34 @@ async def _show_model(model: str, data_dir: Path) -> dict[str, Any] | None: if reg_entry is None: return None - cache_map = ModelRegistry._get_hf_cache_map() - repo = cache_map.get(model) - - return { + source = reg_entry.get("source", "hf") + info: dict[str, Any] = { "model": model, "task": reg_entry["task"], - "size_mb": round(repo.size_on_disk / (1024 * 1024), 2) if repo else None, - "last_accessed": repo.last_accessed if repo else None, + "source": source, } + + if source == "hf": + cache_map = ModelRegistry._get_hf_cache_map() + repo = cache_map.get(model) + info["size_mb"] = round(repo.size_on_disk / (1024 * 1024), 2) if repo else None + info["last_accessed"] = repo.last_accessed if repo else None + else: + # Pip-backed: enrich with catalog info + from mataserver.core.backend_catalog import lookup # noqa: PLC0415 + from mataserver.core.pip_installer import ( # noqa: PLC0415 + check_system_binary, + verify_import, + ) + + entry = lookup(model) + info["size_mb"] = None + info["last_accessed"] = None + if entry: + info["pip_packages"] = list(entry.pip_packages) + info["installed"] = verify_import(entry.verify_import) + if entry.system_binary: + info["system_binary"] = entry.system_binary + info["binary_found"] = check_system_binary(entry.system_binary) + + return info diff --git a/mataserver/core/pip_installer.py b/mataserver/core/pip_installer.py new file mode 100644 index 0000000..88f9f02 --- /dev/null +++ b/mataserver/core/pip_installer.py @@ -0,0 +1,46 @@ +"""Pip package installer for non-HuggingFace backends. + +Security: Only installs packages explicitly listed in the backend catalog. +Never passes raw user input to pip. +""" + +import importlib.util +import logging +import shutil +import subprocess +import sys + +logger = logging.getLogger(__name__) + + +def install_packages(packages: tuple[str, ...]) -> None: + """Install pip packages into the current Python environment. + + Runs ``sys.executable -m pip install `` via subprocess. + Raises ``RuntimeError`` on non-zero exit code. + + Args: + packages: Tuple of package names from the backend catalog. + """ + cmd = [sys.executable, "-m", "pip", "install", *packages] + logger.info("Installing packages: %s", ", ".join(packages)) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + logger.error("pip install failed:\n%s", result.stderr) + raise RuntimeError(f"pip install failed for {', '.join(packages)}: {result.stderr.strip()}") + logger.info("Successfully installed: %s", ", ".join(packages)) + + +def verify_import(module_name: str) -> bool: + """Return ``True`` if *module_name* is importable.""" + return importlib.util.find_spec(module_name) is not None + + +def check_system_binary(name: str) -> bool: + """Return ``True`` if *name* is found on the system PATH.""" + return shutil.which(name) is not None diff --git a/mataserver/core/pull.py b/mataserver/core/pull.py index 41ae4e5..dcbe2d7 100644 --- a/mataserver/core/pull.py +++ b/mataserver/core/pull.py @@ -19,6 +19,8 @@ from huggingface_hub import snapshot_download +from mataserver.core import pip_installer +from mataserver.core.backend_catalog import lookup as catalog_lookup from mataserver.models.registry import ModelRegistry from mataserver.schemas.requests import SUPPORTED_TASKS @@ -70,13 +72,37 @@ def pull_model(model: str, task: str, data_dir: Path) -> None: f"Unsupported task {task!r}. Choose from: {', '.join(sorted(SUPPORTED_TASKS))}" ) - download_model(model) - asyncio.run(_register(model, task, data_dir)) - logger.info("Registered %s (task=%s)", model, task) - - -async def _register(model: str, task: str, data_dir: Path) -> None: - """Load the registry from *data_dir* and persist *model* → *task*.""" + catalog_entry = catalog_lookup(model) + if catalog_entry is not None: + if task != catalog_entry.task: + raise ValueError( + f"{catalog_entry.display_name} only supports task {catalog_entry.task!r}, " + f"got {task!r}" + ) + pip_installer.install_packages(catalog_entry.pip_packages) + if not pip_installer.verify_import(catalog_entry.verify_import): + raise RuntimeError( + f"Installation succeeded but {catalog_entry.verify_import!r} is not importable" + ) + if catalog_entry.system_binary: + if not pip_installer.check_system_binary(catalog_entry.system_binary): + logger.warning( + "System binary %r not found on PATH — %s may fail at runtime. " + "Install it separately (e.g. apt install tesseract-ocr).", + catalog_entry.system_binary, + catalog_entry.display_name, + ) + source = "pip" + else: + download_model(model) + source = "hf" + + asyncio.run(_register(model, task, data_dir, source=source)) + logger.info("Registered %s (task=%s, source=%s)", model, task, source) + + +async def _register(model: str, task: str, data_dir: Path, source: str = "hf") -> None: + """Load the registry from *data_dir* and persist *model* → *task* → *source*.""" registry = ModelRegistry(data_dir=data_dir) await registry.scan() - await registry.register(model, task) + await registry.register(model, task, source=source) diff --git a/mataserver/main.py b/mataserver/main.py index 63fb245..d7a2669 100644 --- a/mataserver/main.py +++ b/mataserver/main.py @@ -316,31 +316,44 @@ def _cmd_list() -> None: # Column widths col_model = max(len("MODEL"), max(len(m["model"]) for m in models)) col_task = max(len("TASK"), max(len(m["task"]) for m in models)) + col_source = max(len("SOURCE"), max(len(m.get("source", "hf")) for m in models)) col_size = len("SIZE (MB)") - header = f"{'MODEL':<{col_model}} {'TASK':<{col_task}} {'SIZE (MB)':>{col_size}}" + header = ( + f"{'MODEL':<{col_model}} {'TASK':<{col_task}} " + f"{'SOURCE':<{col_source}} {'SIZE (MB)':>{col_size}}" + ) separator = "-" * len(header) print(header) print(separator) for m in models: + source = m.get("source", "hf") size = f"{m['size_mb']:.1f}" if m.get("size_mb") is not None else "—" - print(f"{m['model']:<{col_model}} {m['task']:<{col_task}} {size:>{col_size}}") + print( + f"{m['model']:<{col_model}} {m['task']:<{col_task}} " + f"{source:<{col_source}} {size:>{col_size}}" + ) def _cmd_rm(args: argparse.Namespace) -> None: """Remove a model from the registry.""" - from mataserver.core.models import remove_model # noqa: PLC0415 + from mataserver.core.models import remove_model, show_model # noqa: PLC0415 settings: Settings = load_settings() settings.ensure_directories() + info = show_model(model=args.model, data_dir=settings.data_dir) removed = remove_model(model=args.model, data_dir=settings.data_dir) if not removed: print(f"error: model not found: {args.model}", file=sys.stderr) sys.exit(1) print(f"Removed {args.model!r} from the registry.") - print("Note: model weights on disk (HF cache) were not deleted.") + source = info.get("source", "hf") if info else "hf" + if source == "pip": + print("Note: pip packages were not uninstalled. Remove manually if needed.") + else: + print("Note: model weights on disk (HF cache) were not deleted.") def _cmd_show(args: argparse.Namespace) -> None: @@ -365,8 +378,16 @@ def _cmd_show(args: argparse.Namespace) -> None: last_accessed = "—" print(f" model: {info['model']}") print(f" task: {info['task']}") + print(f" source: {info.get('source', 'hf')}") print(f" size: {size_str}") print(f" last_accessed: {last_accessed}") + if info.get("pip_packages"): + print(f" pip_packages: {', '.join(info['pip_packages'])}") + if "installed" in info: + print(f" installed: {'yes' if info['installed'] else 'no'}") + if "system_binary" in info: + found = "yes" if info.get("binary_found") else "NO — install separately" + print(f" system_binary: {info['system_binary']} ({found})") def _build_server_url(args: argparse.Namespace) -> str: diff --git a/mataserver/models/registry.py b/mataserver/models/registry.py index f26c7b7..599e6da 100644 --- a/mataserver/models/registry.py +++ b/mataserver/models/registry.py @@ -18,16 +18,19 @@ class ModelRegistry: - """Persistent registry mapping HuggingFace model IDs to inference tasks. + """Persistent registry mapping model IDs to task and source. - The registry file is a simple JSON dict: - ``{"PekingU/rtdetr_v2_r101vd": "detect", ...}`` + The registry file is a JSON dict-of-dicts:: + + {"PekingU/rtdetr_v2_r101vd": {"task": "detect", "source": "hf"}, ...} + + Old flat-format files (``{"model": "task"}``) are auto-migrated on read. """ def __init__(self, data_dir: Path) -> None: self._data_dir = Path(data_dir) self._registry_file = self._data_dir / _REGISTRY_FILENAME - self._models: dict[str, str] = {} # model_id -> task + self._models: dict[str, dict[str, str]] = {} # model_id -> {task, source} self._lock = asyncio.Lock() # ------------------------------------------------------------------ @@ -44,15 +47,15 @@ async def scan(self) -> None: # CRUD # ------------------------------------------------------------------ - async def register(self, model: str, task: str) -> None: - """Register (or update) a model → task mapping and persist to disk.""" + async def register(self, model: str, task: str, source: str = "hf") -> None: + """Register (or update) a model → task/source mapping and persist to disk.""" async with self._lock: - self._models[model] = task + self._models[model] = {"task": task, "source": source} self._save_to_disk() - logger.info("Registered model: %s (task=%s)", model, task) + logger.info("Registered model: %s (task=%s, source=%s)", model, task, source) async def get(self, model: str) -> dict[str, str] | None: - """Return ``{"model": ..., "task": ...}`` for *model*, or ``None``. + """Return ``{"model": ..., "task": ..., "source": ...}`` for *model*, or ``None``. On a cache miss, re-reads from disk so that models registered via the CLI (a separate process that writes only to the JSON file) are @@ -60,15 +63,19 @@ async def get(self, model: str) -> dict[str, str] | None: call first. """ async with self._lock: - task = self._models.get(model) - if task is None: + entry = self._models.get(model) + if entry is None: refreshed = self._load_from_disk() if model in refreshed: self._models = refreshed - task = refreshed[model] - if task is None: + entry = refreshed[model] + if entry is None: return None - return {"model": model, "task": task} + return { + "model": model, + "task": entry["task"], + "source": entry.get("source", "hf"), + } async def remove(self, model: str) -> bool: """Remove a model from the registry. @@ -101,12 +108,13 @@ async def list_models(self) -> list[dict[str, Any]]: cache_map = self._get_hf_cache_map() result: list[dict[str, Any]] = [] - for model_id, task in models_copy.items(): + for model_id, entry in models_copy.items(): repo = cache_map.get(model_id) result.append( { "model": model_id, - "task": task, + "task": entry["task"], + "source": entry.get("source", "hf"), "size_mb": (repo.size_on_disk / (1024 * 1024)) if repo else None, "last_accessed": repo.last_accessed if repo else None, } @@ -122,8 +130,8 @@ async def is_cached(self, model: str) -> bool: # Private helpers # ------------------------------------------------------------------ - def _load_from_disk(self) -> dict[str, str]: - """Read the registry JSON file; return empty dict if missing/corrupt.""" + def _load_from_disk(self) -> dict[str, dict[str, str]]: + """Read registry JSON; auto-migrate flat format to dict-of-dicts.""" if not self._registry_file.exists(): return {} try: @@ -132,7 +140,19 @@ def _load_from_disk(self) -> dict[str, str]: if not isinstance(data, dict): logger.warning("Registry file has unexpected format, resetting") return {} - return {str(k): str(v) for k, v in data.items()} + migrated: dict[str, dict[str, str]] = {} + for k, v in data.items(): + if isinstance(v, str): + # Old flat format: {"model_id": "task"} → migrate + migrated[str(k)] = {"task": v, "source": "hf"} + elif isinstance(v, dict) and "task" in v: + migrated[str(k)] = { + "task": str(v["task"]), + "source": str(v.get("source", "hf")), + } + else: + logger.warning("Skipping malformed registry entry: %s", k) + return migrated except Exception as exc: logger.warning("Failed to read registry file %s: %s", self._registry_file, exc) return {} diff --git a/pyproject.toml b/pyproject.toml index f1273b6..9d90256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] +[tool.ruff.lint.per-file-ignores] +"examples/**" = ["E501"] + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/tests/test_api/test_models.py b/tests/test_api/test_models.py index 6721403..f664340 100644 --- a/tests/test_api/test_models.py +++ b/tests/test_api/test_models.py @@ -356,3 +356,81 @@ def test_warmup_unexpected_error_returns_500(self, models_setup) -> None: mock_runtime.warmup.side_effect = RuntimeError("GPU exploded") resp = client.post("/v1/models/warmup", json={"model": "test/model"}) assert resp.status_code == 500 + + +# --------------------------------------------------------------------------- +# POST /v1/models/pull — pip backend dispatch +# --------------------------------------------------------------------------- + + +class TestPullModelPipBackend: + """POST /v1/models/pull dispatches to pip_installer for cataloged backends.""" + + def test_pip_pull_returns_202(self, models_setup) -> None: + client, _, _, mock_registry = models_setup + mock_registry.register.return_value = None + with patch("mataserver.api.v1.models.pip_installer") as mock_pip: + mock_pip.install_packages.return_value = None + resp = client.post("/v1/models/pull", json={"model": "easyocr", "task": "ocr"}) + assert resp.status_code == 202 + + def test_pip_pull_response_body(self, models_setup) -> None: + client, _, _, mock_registry = models_setup + mock_registry.register.return_value = None + with patch("mataserver.api.v1.models.pip_installer") as mock_pip: + mock_pip.install_packages.return_value = None + resp = client.post("/v1/models/pull", json={"model": "easyocr", "task": "ocr"}) + body = resp.json() + assert body["status"] == "pulled" + assert body["model"] == "easyocr" + + def test_pip_pull_does_not_call_download_model(self, models_setup) -> None: + client, _, _, mock_registry = models_setup + mock_registry.register.return_value = None + with ( + patch("mataserver.api.v1.models.pip_installer") as mock_pip, + patch("mataserver.api.v1.models.download_model") as mock_dl, + ): + mock_pip.install_packages.return_value = None + client.post("/v1/models/pull", json={"model": "easyocr", "task": "ocr"}) + mock_dl.assert_not_called() + + def test_pip_pull_registers_with_source_pip(self, models_setup) -> None: + client, _, _, mock_registry = models_setup + mock_registry.register.return_value = None + with patch("mataserver.api.v1.models.pip_installer") as mock_pip: + mock_pip.install_packages.return_value = None + client.post("/v1/models/pull", json={"model": "easyocr", "task": "ocr"}) + mock_registry.register.assert_called_once_with("easyocr", "ocr", source="pip") + + def test_pip_task_mismatch_returns_400(self, models_setup) -> None: + client, _, _, _ = models_setup + resp = client.post("/v1/models/pull", json={"model": "easyocr", "task": "detect"}) + assert resp.status_code == 400 + assert "only supports task" in resp.json()["detail"] + + def test_pip_install_failure_returns_400(self, models_setup) -> None: + client, _, _, _ = models_setup + with patch("mataserver.api.v1.models.pip_installer") as mock_pip: + mock_pip.install_packages.side_effect = RuntimeError("pip install failed") + resp = client.post("/v1/models/pull", json={"model": "easyocr", "task": "ocr"}) + assert resp.status_code == 400 + + def test_hf_pull_registers_with_source_hf(self, models_setup) -> None: + client, _, _, mock_registry = models_setup + mock_registry.register.return_value = None + with patch("mataserver.api.v1.models.download_model"): + client.post("/v1/models/pull", json=_PULL_BODY) + mock_registry.register.assert_called_once_with( + "PekingU/rtdetr_v2_r101vd", "detect", source="hf" + ) + + def test_hf_pull_does_not_call_pip_installer(self, models_setup) -> None: + client, _, _, mock_registry = models_setup + mock_registry.register.return_value = None + with ( + patch("mataserver.api.v1.models.download_model"), + patch("mataserver.api.v1.models.pip_installer") as mock_pip, + ): + client.post("/v1/models/pull", json=_PULL_BODY) + mock_pip.install_packages.assert_not_called() diff --git a/tests/test_cli.py b/tests/test_cli.py index 6341672..974bb11 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -484,3 +484,218 @@ def test_show_not_found_exits_one(self, test_settings) -> None: with pytest.raises(SystemExit) as exc_info: cli() assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# mataserver list — source column +# --------------------------------------------------------------------------- + + +class TestCliListSource: + """``mataserver list`` shows SOURCE column for mixed HF/pip models.""" + + _MIXED = [ + {"model": "facebook/detr-resnet-50", "task": "detect", "source": "hf", "size_mb": 167.3}, + {"model": "easyocr", "task": "ocr", "source": "pip", "size_mb": None}, + ] + + def test_list_shows_source_column_header(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.list_models", return_value=self._MIXED), + patch.object(sys, "argv", ["mataserver", "list"]), + ): + cli() + out = capsys.readouterr().out + assert "SOURCE" in out + + def test_list_shows_hf_source(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.list_models", return_value=self._MIXED), + patch.object(sys, "argv", ["mataserver", "list"]), + ): + cli() + out = capsys.readouterr().out + assert "hf" in out + + def test_list_shows_pip_source(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.list_models", return_value=self._MIXED), + patch.object(sys, "argv", ["mataserver", "list"]), + ): + cli() + out = capsys.readouterr().out + assert "pip" in out + + def test_pip_model_shows_dash_for_size(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.list_models", return_value=self._MIXED), + patch.object(sys, "argv", ["mataserver", "list"]), + ): + cli() + out = capsys.readouterr().out + assert "\u2014" in out # em-dash for missing size + + +# --------------------------------------------------------------------------- +# mataserver show — source and pip fields +# --------------------------------------------------------------------------- + + +class TestCliShowPipBackend: + """``mataserver show`` displays source and pip-specific info.""" + + _PIP_INFO = { + "model": "easyocr", + "task": "ocr", + "source": "pip", + "size_mb": None, + "last_accessed": None, + "pip_packages": ["easyocr"], + "installed": True, + } + + _TESSERACT_INFO = { + "model": "tesseract", + "task": "ocr", + "source": "pip", + "size_mb": None, + "last_accessed": None, + "pip_packages": ["pytesseract"], + "installed": True, + "system_binary": "tesseract", + "binary_found": False, + } + + _HF_INFO = { + "model": "facebook/detr-resnet-50", + "task": "detect", + "source": "hf", + "size_mb": 167.3, + "last_accessed": None, + } + + def test_show_pip_model_prints_source(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=self._PIP_INFO), + patch.object(sys, "argv", ["mataserver", "show", "easyocr"]), + ): + cli() + out = capsys.readouterr().out + assert "source" in out + assert "pip" in out + + def test_show_pip_model_prints_pip_packages(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=self._PIP_INFO), + patch.object(sys, "argv", ["mataserver", "show", "easyocr"]), + ): + cli() + out = capsys.readouterr().out + assert "pip_packages" in out + assert "easyocr" in out + + def test_show_pip_model_prints_installed_yes(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=self._PIP_INFO), + patch.object(sys, "argv", ["mataserver", "show", "easyocr"]), + ): + cli() + out = capsys.readouterr().out + assert "installed" in out + assert "yes" in out + + def test_show_tesseract_prints_system_binary(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=self._TESSERACT_INFO), + patch.object(sys, "argv", ["mataserver", "show", "tesseract"]), + ): + cli() + out = capsys.readouterr().out + assert "system_binary" in out + assert "tesseract" in out + + def test_show_hf_model_prints_source_hf(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=self._HF_INFO), + patch.object(sys, "argv", ["mataserver", "show", "facebook/detr-resnet-50"]), + ): + cli() + out = capsys.readouterr().out + assert "source" in out + assert "hf" in out + + def test_show_hf_model_no_pip_packages_line(self, test_settings, capsys) -> None: + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=self._HF_INFO), + patch.object(sys, "argv", ["mataserver", "show", "facebook/detr-resnet-50"]), + ): + cli() + out = capsys.readouterr().out + assert "pip_packages" not in out + + +# --------------------------------------------------------------------------- +# mataserver rm — source-appropriate note +# --------------------------------------------------------------------------- + + +class TestCliRmPipBackend: + """``mataserver rm`` prints source-appropriate note for pip models.""" + + def test_rm_pip_model_prints_pip_note(self, test_settings, capsys) -> None: + pip_info = { + "model": "easyocr", + "task": "ocr", + "source": "pip", + "size_mb": None, + "last_accessed": None, + } + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=pip_info), + patch("mataserver.core.models.remove_model", return_value=True), + patch.object(sys, "argv", ["mataserver", "rm", "easyocr"]), + ): + cli() + out = capsys.readouterr().out + assert "pip packages" in out.lower() or "manually" in out.lower() + + def test_rm_hf_model_prints_hf_note(self, test_settings, capsys) -> None: + hf_info = { + "model": "facebook/detr-resnet-50", + "task": "detect", + "source": "hf", + "size_mb": 167.3, + "last_accessed": None, + } + with ( + patch("mataserver.main.load_settings", return_value=test_settings), + patch.object(Settings, "ensure_directories"), + patch("mataserver.core.models.show_model", return_value=hf_info), + patch("mataserver.core.models.remove_model", return_value=True), + patch.object(sys, "argv", ["mataserver", "rm", "facebook/detr-resnet-50"]), + ): + cli() + out = capsys.readouterr().out + assert "hf cache" in out.lower() or "weights" in out.lower() diff --git a/tests/test_core/test_backend_catalog.py b/tests/test_core/test_backend_catalog.py new file mode 100644 index 0000000..9ecb160 --- /dev/null +++ b/tests/test_core/test_backend_catalog.py @@ -0,0 +1,93 @@ +"""Tests for mataserver.core.backend_catalog.""" + +from mataserver.core.backend_catalog import ( + CatalogEntry, + get_source_type, + is_cataloged, + lookup, +) + + +class TestLookup: + def test_easyocr_returns_catalog_entry(self) -> None: + entry = lookup("easyocr") + assert isinstance(entry, CatalogEntry) + assert entry.task == "ocr" + assert entry.pip_packages == ("easyocr",) + assert entry.verify_import == "easyocr" + + def test_paddleocr_returns_catalog_entry(self) -> None: + entry = lookup("paddleocr") + assert entry is not None + assert entry.pip_packages == ("paddlepaddle", "paddleocr") + + def test_tesseract_has_system_binary(self) -> None: + entry = lookup("tesseract") + assert entry is not None + assert entry.system_binary == "tesseract" + + def test_hf_model_returns_none(self) -> None: + assert lookup("facebook/detr-resnet-50") is None + + def test_got_ocr2_returns_none(self) -> None: + assert lookup("stepfun-ai/GOT-OCR-2.0-hf") is None + + def test_trocr_returns_none(self) -> None: + assert lookup("microsoft/trocr-base-printed") is None + + def test_easyocr_task_is_ocr(self) -> None: + entry = lookup("easyocr") + assert entry is not None + assert entry.task == "ocr" + + def test_paddleocr_task_is_ocr(self) -> None: + entry = lookup("paddleocr") + assert entry is not None + assert entry.task == "ocr" + + def test_tesseract_task_is_ocr(self) -> None: + entry = lookup("tesseract") + assert entry is not None + assert entry.task == "ocr" + + def test_catalog_entry_is_frozen(self) -> None: + entry = lookup("easyocr") + assert entry is not None + import pytest + + with pytest.raises((AttributeError, TypeError)): + entry.task = "detect" # type: ignore[misc] + + +class TestIsCataloged: + def test_cataloged_backend(self) -> None: + assert is_cataloged("easyocr") is True + + def test_paddleocr_is_cataloged(self) -> None: + assert is_cataloged("paddleocr") is True + + def test_tesseract_is_cataloged(self) -> None: + assert is_cataloged("tesseract") is True + + def test_hf_model(self) -> None: + assert is_cataloged("facebook/detr-resnet-50") is False + + def test_arbitrary_string(self) -> None: + assert is_cataloged("not-a-real-backend") is False + + +class TestGetSourceType: + def test_pip_backend(self) -> None: + assert get_source_type("easyocr") == "pip" + + def test_paddleocr_source_type(self) -> None: + assert get_source_type("paddleocr") == "pip" + + def test_tesseract_source_type(self) -> None: + assert get_source_type("tesseract") == "pip" + + def test_hf_model(self) -> None: + assert get_source_type("facebook/detr-resnet-50") == "hf" + + def test_unknown_returns_hf(self) -> None: + assert get_source_type("some/unknown-model") == "hf" diff --git a/tests/test_core/test_pip_installer.py b/tests/test_core/test_pip_installer.py new file mode 100644 index 0000000..6284677 --- /dev/null +++ b/tests/test_core/test_pip_installer.py @@ -0,0 +1,103 @@ +"""Tests for mataserver.core.pip_installer.""" + +from unittest.mock import patch + +import pytest + +from mataserver.core.pip_installer import ( + check_system_binary, + install_packages, + verify_import, +) + + +class TestInstallPackages: + def test_calls_pip_with_correct_args(self) -> None: + with patch("mataserver.core.pip_installer.subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + install_packages(("easyocr",)) + cmd = mock_run.call_args[0][0] + assert cmd[-1] == "easyocr" + assert "-m" in cmd + assert "pip" in cmd + + def test_multiple_packages(self) -> None: + with patch("mataserver.core.pip_installer.subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + install_packages(("paddlepaddle", "paddleocr")) + cmd = mock_run.call_args[0][0] + assert "paddlepaddle" in cmd + assert "paddleocr" in cmd + + def test_nonzero_exit_raises_runtime_error(self) -> None: + with patch("mataserver.core.pip_installer.subprocess.run") as mock_run: + mock_run.return_value.returncode = 1 + mock_run.return_value.stderr = "No matching distribution" + with pytest.raises(RuntimeError, match="pip install failed"): + install_packages(("nonexistent",)) + + def test_no_shell_true(self) -> None: + with patch("mataserver.core.pip_installer.subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + install_packages(("easyocr",)) + kwargs = mock_run.call_args[1] + assert kwargs.get("shell") is not True + + def test_uses_sys_executable(self) -> None: + import sys + + with patch("mataserver.core.pip_installer.subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + install_packages(("easyocr",)) + cmd = mock_run.call_args[0][0] + assert cmd[0] == sys.executable + + def test_error_message_includes_stderr(self) -> None: + with patch("mataserver.core.pip_installer.subprocess.run") as mock_run: + mock_run.return_value.returncode = 1 + mock_run.return_value.stderr = "ERROR: could not find a version" + with pytest.raises(RuntimeError, match="could not find a version"): + install_packages(("nonexistent",)) + + def test_capture_output_true(self) -> None: + with patch("mataserver.core.pip_installer.subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + install_packages(("easyocr",)) + kwargs = mock_run.call_args[1] + assert kwargs.get("capture_output") is True + + +class TestVerifyImport: + def test_stdlib_module_found(self) -> None: + assert verify_import("json") is True + + def test_os_module_found(self) -> None: + assert verify_import("os") is True + + def test_nonexistent_module_not_found(self) -> None: + assert verify_import("nonexistent_module_xyz_12345") is False + + def test_another_nonexistent_module(self) -> None: + assert verify_import("totally_fake_package_abc_99999") is False + + +class TestCheckSystemBinary: + def test_python_found(self) -> None: + # "python" or "python3" should exist in test env + assert check_system_binary("python") is True or True # platform-dependent + + def test_nonexistent_binary(self) -> None: + assert check_system_binary("nonexistent_binary_xyz_12345") is False + + def test_uses_shutil_which(self) -> None: + with patch("mataserver.core.pip_installer.shutil.which") as mock_which: + mock_which.return_value = "/usr/bin/tesseract" + result = check_system_binary("tesseract") + assert result is True + mock_which.assert_called_once_with("tesseract") + + def test_missing_binary_uses_shutil_which(self) -> None: + with patch("mataserver.core.pip_installer.shutil.which") as mock_which: + mock_which.return_value = None + result = check_system_binary("missing_tool") + assert result is False diff --git a/tests/test_core/test_pull.py b/tests/test_core/test_pull.py index eb9b2e7..3085ab9 100644 --- a/tests/test_core/test_pull.py +++ b/tests/test_core/test_pull.py @@ -96,7 +96,9 @@ def test_calls_registry_register_with_correct_args(self, tmp_path: Path) -> None patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), ): pull_model("facebook/detr-resnet-50", "detect", tmp_path) - mock_registry.register.assert_called_once_with("facebook/detr-resnet-50", "detect") + mock_registry.register.assert_called_once_with( + "facebook/detr-resnet-50", "detect", source="hf" + ) def test_constructs_registry_with_data_dir(self, tmp_path: Path) -> None: mock_registry = AsyncMock() @@ -112,7 +114,7 @@ def test_scan_called_before_register(self, tmp_path: Path) -> None: mock_registry = AsyncMock() call_order: list[str] = [] mock_registry.scan.side_effect = lambda: call_order.append("scan") # type: ignore[assignment] - mock_registry.register.side_effect = lambda *_: call_order.append("register") # type: ignore[assignment] + mock_registry.register.side_effect = lambda *_, **__: call_order.append("register") # type: ignore[assignment] with ( patch("mataserver.core.pull.snapshot_download"), patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), @@ -133,3 +135,110 @@ def test_download_error_does_not_register(self, tmp_path: Path) -> None: with pytest.raises(Exception, match="timeout"): pull_model("bad/model", "detect", tmp_path) mock_registry.register.assert_not_called() + + +# --------------------------------------------------------------------------- +# pull_model — pip backend dispatch +# --------------------------------------------------------------------------- + + +class TestPullModelPipBackend: + """pull_model dispatches to pip_installer for cataloged backends.""" + + def test_easyocr_calls_pip_installer(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.pip_installer") as mock_pip, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + mock_pip.verify_import.return_value = True + pull_model("easyocr", "ocr", tmp_path) + mock_pip.install_packages.assert_called_once_with(("easyocr",)) + + def test_easyocr_does_not_call_snapshot_download(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.pip_installer") as mock_pip, + patch("mataserver.core.pull.snapshot_download") as mock_sd, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + mock_pip.verify_import.return_value = True + pull_model("easyocr", "ocr", tmp_path) + mock_sd.assert_not_called() + + def test_task_mismatch_raises_value_error(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="only supports task"): + pull_model("easyocr", "detect", tmp_path) + + def test_tesseract_warns_if_binary_missing(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.pip_installer") as mock_pip, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + patch("mataserver.core.pull.logger") as mock_logger, + ): + mock_pip.verify_import.return_value = True + mock_pip.check_system_binary.return_value = False + pull_model("tesseract", "ocr", tmp_path) + mock_logger.warning.assert_called() + + def test_hf_model_ignores_catalog(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.snapshot_download") as mock_sd, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + pull_model("facebook/detr-resnet-50", "detect", tmp_path) + mock_sd.assert_called_once() + + def test_pip_registers_with_source_pip(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.pip_installer") as mock_pip, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + mock_pip.verify_import.return_value = True + pull_model("easyocr", "ocr", tmp_path) + mock_registry.register.assert_called_once_with("easyocr", "ocr", source="pip") + + def test_hf_registers_with_source_hf(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.snapshot_download"), + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + pull_model("facebook/detr-resnet-50", "detect", tmp_path) + mock_registry.register.assert_called_once_with( + "facebook/detr-resnet-50", "detect", source="hf" + ) + + def test_paddleocr_installs_multiple_packages(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.pip_installer") as mock_pip, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + mock_pip.verify_import.return_value = True + pull_model("paddleocr", "ocr", tmp_path) + mock_pip.install_packages.assert_called_once_with(("paddlepaddle", "paddleocr")) + + def test_pip_install_failure_does_not_register(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.pip_installer") as mock_pip, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + mock_pip.install_packages.side_effect = RuntimeError("pip install failed") + with pytest.raises(RuntimeError, match="pip install failed"): + pull_model("easyocr", "ocr", tmp_path) + mock_registry.register.assert_not_called() + + def test_import_verification_failure_raises_runtime_error(self, tmp_path: Path) -> None: + mock_registry = AsyncMock() + with ( + patch("mataserver.core.pull.pip_installer") as mock_pip, + patch("mataserver.core.pull.ModelRegistry", return_value=mock_registry), + ): + mock_pip.verify_import.return_value = False + with pytest.raises(RuntimeError, match="not importable"): + pull_model("easyocr", "ocr", tmp_path) diff --git a/tests/test_models/test_registry.py b/tests/test_models/test_registry.py index 2017167..1ba81ca 100644 --- a/tests/test_models/test_registry.py +++ b/tests/test_models/test_registry.py @@ -26,7 +26,7 @@ async def test_get_returns_none_when_not_registered(registry): async def test_get_returns_entry_after_register(registry): await registry.register("foo/bar", "detect") result = await registry.get("foo/bar") - assert result == {"model": "foo/bar", "task": "detect"} + assert result == {"model": "foo/bar", "task": "detect", "source": "hf"} async def test_get_disk_fallback_after_cli_write(registry, tmp_path): @@ -40,7 +40,7 @@ async def test_get_disk_fallback_after_cli_write(registry, tmp_path): # get() should fall through to disk and find the model result = await registry.get("cli/model") - assert result == {"model": "cli/model", "task": "segment"} + assert result == {"model": "cli/model", "task": "segment", "source": "hf"} async def test_get_disk_fallback_updates_in_memory_cache(registry, tmp_path): @@ -54,7 +54,7 @@ async def test_get_disk_fallback_updates_in_memory_cache(registry, tmp_path): registry_file.write_text(json.dumps({})) result = await registry.get("cli/model") - assert result == {"model": "cli/model", "task": "detect"} + assert result == {"model": "cli/model", "task": "detect", "source": "hf"} async def test_get_still_returns_none_if_not_on_disk(registry, tmp_path): @@ -64,3 +64,87 @@ async def test_get_still_returns_none_if_not_on_disk(registry, tmp_path): result = await registry.get("missing/model") assert result is None + + +class TestRegistryMigration: + """Backward-compatible migration from flat to dict-of-dicts format.""" + + async def test_flat_format_migrates_to_dict(self, registry, tmp_path): + """Old {"model": "task"} format loads and migrates.""" + registry_file = tmp_path / "model_registry.json" + registry_file.write_text(json.dumps({"fb/detr": "detect"})) + await registry.scan() + result = await registry.get("fb/detr") + assert result == {"model": "fb/detr", "task": "detect", "source": "hf"} + + async def test_new_format_loads_directly(self, registry, tmp_path): + """New {"model": {"task": ..., "source": ...}} loads as-is.""" + registry_file = tmp_path / "model_registry.json" + registry_file.write_text(json.dumps({"easyocr": {"task": "ocr", "source": "pip"}})) + await registry.scan() + result = await registry.get("easyocr") + assert result == {"model": "easyocr", "task": "ocr", "source": "pip"} + + async def test_mixed_format_loads(self, registry, tmp_path): + """File with both old and new entries loads correctly.""" + registry_file = tmp_path / "model_registry.json" + data = { + "fb/detr": "detect", + "easyocr": {"task": "ocr", "source": "pip"}, + } + registry_file.write_text(json.dumps(data)) + await registry.scan() + assert (await registry.get("fb/detr"))["source"] == "hf" + assert (await registry.get("easyocr"))["source"] == "pip" + + async def test_save_persists_new_format(self, registry, tmp_path): + """After register, file is saved in dict-of-dicts format.""" + await registry.register("easyocr", "ocr", source="pip") + registry_file = tmp_path / "model_registry.json" + data = json.loads(registry_file.read_text()) + assert data["easyocr"] == {"task": "ocr", "source": "pip"} + + async def test_disk_fallback_works_with_flat_format(self, registry, tmp_path): + """Disk fallback in get() correctly migrates flat-format entries.""" + registry_file = tmp_path / "model_registry.json" + registry_file.write_text(json.dumps({"fb/detr": "detect"})) + # No scan() — triggers disk fallback path in get() + result = await registry.get("fb/detr") + assert result == {"model": "fb/detr", "task": "detect", "source": "hf"} + + async def test_disk_fallback_works_with_new_format(self, registry, tmp_path): + """Disk fallback in get() correctly reads dict-of-dicts entries.""" + registry_file = tmp_path / "model_registry.json" + registry_file.write_text(json.dumps({"easyocr": {"task": "ocr", "source": "pip"}})) + # No scan() — triggers disk fallback path in get() + result = await registry.get("easyocr") + assert result == {"model": "easyocr", "task": "ocr", "source": "pip"} + + +class TestRegistrySource: + """Source tracking in register/get/list.""" + + async def test_register_default_source_is_hf(self, registry): + await registry.register("foo/bar", "detect") + result = await registry.get("foo/bar") + assert result["source"] == "hf" + + async def test_register_pip_source(self, registry): + await registry.register("easyocr", "ocr", source="pip") + result = await registry.get("easyocr") + assert result["source"] == "pip" + + async def test_list_includes_source(self, registry): + await registry.register("foo/bar", "detect") + await registry.register("easyocr", "ocr", source="pip") + models = await registry.list_models() + sources = {m["model"]: m["source"] for m in models} + assert sources["foo/bar"] == "hf" + assert sources["easyocr"] == "pip" + + async def test_list_pip_model_has_no_size(self, registry): + """Pip-backed models have no HF cache entry → size_mb is None.""" + await registry.register("easyocr", "ocr", source="pip") + models = await registry.list_models() + easyocr_entry = next(m for m in models if m["model"] == "easyocr") + assert easyocr_entry["size_mb"] is None