[Update] 2026-02-12 20:23:09, 18 files
This commit is contained in:
+87
@@ -0,0 +1,87 @@
|
||||
# Refactoring Plan
|
||||
|
||||
Generated: 2026-02-12
|
||||
Scope: `tensors/` (all modules) + `tests/`
|
||||
|
||||
## Summary
|
||||
|
||||
11 issues found: 3 high priority, 5 medium priority, 3 low priority.
|
||||
|
||||
## High Priority
|
||||
|
||||
### [ ] Extract common HTTP request pattern in api.py
|
||||
- **Location**: `api.py:38-108`
|
||||
- **Problem**: `fetch_civitai_model_version`, `fetch_civitai_model`, and `fetch_civitai_by_hash` share nearly identical try/except/error-handling blocks (3x duplication).
|
||||
- **Impact**: Changing error handling or adding retry logic requires editing 3+ places. Easy to introduce inconsistencies (e.g., `fetch_civitai_model_version` lacks the Progress spinner that the other two have).
|
||||
- **Action**: Extract a `_api_get(url, api_key, console, spinner_msg=None) -> dict | None` helper that handles GET, 404 check, error printing, and optional Progress spinner. Each fetch function becomes a one-liner calling this helper.
|
||||
|
||||
### [ ] Eliminate module-level console singleton in cli.py
|
||||
- **Location**: `cli.py:67`
|
||||
- **Problem**: `console = Console()` at module level is used by 6 helper functions (`_output_info_json`, `_save_metadata`, `_resolve_by_hash`, `_resolve_by_model_id`, `_prepare_download_dir`, `_display_download_info`) via closure rather than parameter passing.
|
||||
- **Impact**: Impossible to inject a test console into helpers, prevents output capture in tests, creates hidden coupling. Commands pass `console` to API/display functions but helpers silently use the global.
|
||||
- **Action**: Add `console: Console` parameter to all helper functions. Pass the module-level console from command functions. This makes the dependency explicit and testable.
|
||||
|
||||
### [ ] Remove or use unused `safetensors` dependency
|
||||
- **Location**: `pyproject.toml:8`
|
||||
- **Problem**: `safetensors>=0.4.0` is listed as a runtime dependency but never imported anywhere. The code does manual binary parsing in `safetensor.py`.
|
||||
- **Impact**: Unnecessary install bloat. Users install a native extension library they don't need. Confusing for contributors.
|
||||
- **Action**: Remove `safetensors` from `[project.dependencies]` since manual parsing is the intended approach.
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### [ ] Deduplicate output dict construction in cli.py
|
||||
- **Location**: `cli.py:126-133` and `cli.py:154-161`
|
||||
- **Problem**: `_output_info_json` and `_save_metadata` construct the same dict structure independently.
|
||||
- **Impact**: Adding a new field to info output requires editing two places.
|
||||
- **Action**: Extract `_build_info_dict(file_path, sha256_hash, local_metadata, civitai_data) -> dict` and call it from both functions.
|
||||
|
||||
### [ ] Extract API timeout and chunk size constants
|
||||
- **Location**: `api.py:43,70,97,175` and `safetensor.py:67`
|
||||
- **Problem**: `timeout=30.0` appears 4 times in api.py. Chunk sizes (`1024 * 1024` in api.py, `1024 * 1024 * 8` in safetensor.py) are inline magic numbers.
|
||||
- **Impact**: Changing timeout or chunk size requires hunting through code.
|
||||
- **Action**: Add `API_TIMEOUT = 30.0`, `DOWNLOAD_CHUNK_SIZE = 1024 * 1024` to config.py or at module top. Add `HASH_CHUNK_SIZE = 1024 * 1024 * 8` to safetensor.py constants section.
|
||||
|
||||
### [ ] Rename `_format_size` and `_format_count` to public API
|
||||
- **Location**: `display.py:23,32`
|
||||
- **Problem**: `_format_size` is imported and used by `cli.py:36` as a cross-module API but has a private underscore prefix. Same for `_format_count` which could be useful externally.
|
||||
- **Impact**: Violates Python naming convention. Underscore-prefixed names signal "don't import this" but the codebase does.
|
||||
- **Action**: Rename to `format_size` and `format_count`. Update all imports.
|
||||
|
||||
### [ ] Deduplicate Progress spinner setup in api.py
|
||||
- **Location**: `api.py:61-66,88-93,166-171`
|
||||
- **Problem**: The `Progress(SpinnerColumn(), TextColumn(...), console=..., transient=True)` pattern is repeated 3 times with identical configuration.
|
||||
- **Impact**: Changing spinner appearance requires 3 edits.
|
||||
- **Action**: Extract `_spinner(console, description)` context manager helper or a factory function. This pairs well with the HTTP helper extraction in High Priority #1.
|
||||
|
||||
### [ ] Add shared console fixture to tests
|
||||
- **Location**: `tests/test_tensors.py:284,291,298,...`
|
||||
- **Problem**: `Console(force_terminal=True, width=80)` is created ~15 times across test methods.
|
||||
- **Impact**: Changing test console config requires editing every test.
|
||||
- **Action**: Add a `console` fixture to `conftest.py` and use it across all test classes.
|
||||
|
||||
## Low Priority
|
||||
|
||||
### [ ] Replace hardcoded command list in main()
|
||||
- **Location**: `cli.py:403`
|
||||
- **Problem**: `arg not in ("info", "search", "get", "dl", "download", "config")` is a manually maintained list of known commands for legacy invocation detection.
|
||||
- **Impact**: Adding a new command requires remembering to update this list, or legacy mode will swallow it.
|
||||
- **Action**: Derive the command list from `app.registered_commands` or Typer's command registry dynamically.
|
||||
|
||||
### [ ] Strengthen display function tests
|
||||
- **Location**: `tests/test_tensors.py:279-385`
|
||||
- **Problem**: Most display tests only assert "should not raise" with no output verification.
|
||||
- **Impact**: Tests won't catch regressions in output format or content.
|
||||
- **Action**: Use `Console(record=True)` to capture output, then assert key strings appear in `console.export_text()`.
|
||||
|
||||
### [ ] Add tests for untested pure functions
|
||||
- **Location**: `api.py:111-142,145-150,187-199,202-209`
|
||||
- **Problem**: `_build_search_params`, `_filter_results`, `_setup_resume`, `_get_dest_from_response` have no test coverage. These are pure/near-pure functions that are easy to test.
|
||||
- **Impact**: Logic errors in search parameter building or resume setup won't be caught.
|
||||
- **Action**: Add direct unit tests for each function. These don't need HTTP mocking since they're pure logic.
|
||||
|
||||
## Notes
|
||||
|
||||
- High Priority #1 (HTTP helper) and Medium #6 (spinner dedup) should be done together since the extracted helper will naturally absorb the spinner logic.
|
||||
- High Priority #2 (console singleton) should be done before Medium #7 (test fixture) since making console a parameter enables proper test injection.
|
||||
- Medium #4 (output dict) is a quick win that can be done independently.
|
||||
- Start with High Priority #3 (remove unused dep) as it's the simplest and lowest risk change.
|
||||
@@ -11,6 +11,9 @@ dependencies = [
|
||||
"typer>=0.15.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
server = ["fastapi>=0.115", "uvicorn>=0.30"]
|
||||
|
||||
[project.scripts]
|
||||
tsr = "tensors:main"
|
||||
|
||||
@@ -30,6 +33,8 @@ dev = [
|
||||
"pytest-cov>=4.1",
|
||||
"pre-commit>=3.6",
|
||||
"respx>=0.22.0",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn>=0.30",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
@@ -56,6 +61,9 @@ ignore = [
|
||||
"PLR0913", # Too many arguments - CLI commands need many options
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["PLR2004", "ARG002", "TC001", "TC003"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["tensors"]
|
||||
|
||||
@@ -79,6 +87,10 @@ ignore_missing_imports = false
|
||||
module = ["safetensors.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["uvicorn.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v --cov=tensors --cov-report=term-missing"
|
||||
|
||||
+62
-1
@@ -64,6 +64,8 @@ def _main(
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Read safetensor metadata, search and download CivitAI models."""
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@@ -395,12 +397,71 @@ def config(
|
||||
console.print("[dim]Set API key with: tsr config --set-key YOUR_KEY[/dim]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def generate(
|
||||
prompt: Annotated[str, typer.Argument(help="Text prompt for image generation.")],
|
||||
host: Annotated[str, typer.Option(help="sd-server address.")] = "127.0.0.1",
|
||||
port: Annotated[int, typer.Option(help="sd-server port.")] = 1234,
|
||||
output: Annotated[str, typer.Option("-o", help="Output directory.")] = ".",
|
||||
negative_prompt: Annotated[str, typer.Option("-n", help="Negative prompt.")] = "",
|
||||
width: Annotated[int, typer.Option("-W", help="Image width.")] = 512,
|
||||
height: Annotated[int, typer.Option("-H", help="Image height.")] = 512,
|
||||
steps: Annotated[int, typer.Option(help="Sampling steps.")] = 20,
|
||||
cfg_scale: Annotated[float, typer.Option(help="CFG scale.")] = 7.0,
|
||||
seed: Annotated[int, typer.Option("-s", help="RNG seed (-1 for random).")] = -1,
|
||||
sampler: Annotated[str, typer.Option(help="Sampler name.")] = "",
|
||||
scheduler: Annotated[str, typer.Option(help="Scheduler name.")] = "",
|
||||
batch_size: Annotated[int, typer.Option("-b", help="Number of images.")] = 1,
|
||||
) -> None:
|
||||
"""Generate images using a running sd-server."""
|
||||
from tensors.generate import SDClient, Txt2ImgParams, save_images # noqa: PLC0415
|
||||
|
||||
params = Txt2ImgParams(
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
width=width,
|
||||
height=height,
|
||||
steps=steps,
|
||||
cfg_scale=cfg_scale,
|
||||
seed=seed,
|
||||
batch_size=batch_size,
|
||||
sampler_name=sampler,
|
||||
scheduler=scheduler,
|
||||
)
|
||||
|
||||
with SDClient(host=host, port=port) as client:
|
||||
console.print(f"[cyan]Generating {batch_size} image(s)...[/cyan]")
|
||||
images = client.generate.txt2img(params)
|
||||
paths = save_images(images, output)
|
||||
for p in paths:
|
||||
console.print(f"[green]Saved:[/green] {p}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
host: Annotated[str, typer.Option(help="Wrapper API listen address.")] = "127.0.0.1",
|
||||
port: Annotated[int, typer.Option(help="Wrapper API listen port.")] = 8080,
|
||||
log_level: Annotated[str, typer.Option(help="Log level.")] = "info",
|
||||
) -> None:
|
||||
"""Start the sd-server wrapper API."""
|
||||
try:
|
||||
import uvicorn # noqa: PLC0415
|
||||
|
||||
from tensors.server import create_app # noqa: PLC0415
|
||||
except ImportError:
|
||||
console.print("[red]Missing server dependencies. Install with:[/red]")
|
||||
console.print(" pip install tensors[server]")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
uvicorn.run(create_app(), host=host, port=port, log_level=log_level)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
# Handle legacy invocation: tsr <file.safetensors> -> tsr info <file>
|
||||
if len(sys.argv) > 1 and not sys.argv[1].startswith("-"):
|
||||
arg = sys.argv[1]
|
||||
if arg not in ("info", "search", "get", "dl", "download", "config") and (
|
||||
if arg not in ("info", "search", "get", "dl", "download", "config", "generate", "serve") and (
|
||||
arg.endswith(".safetensors") or arg.endswith(".sft") or Path(arg).exists()
|
||||
):
|
||||
sys.argv = [sys.argv[0], "info", *sys.argv[1:]]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""sd-server Python client — modular, httpx-based."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tensors.generate._http import HttpTransport
|
||||
from tensors.generate.generation import GenerationAPI
|
||||
from tensors.generate.info import InfoAPI
|
||||
from tensors.generate.params import Img2ImgParams, Txt2ImgParams
|
||||
from tensors.generate.util import save_images
|
||||
|
||||
__all__ = [
|
||||
"Img2ImgParams",
|
||||
"SDClient",
|
||||
"Txt2ImgParams",
|
||||
"save_images",
|
||||
]
|
||||
|
||||
|
||||
class SDClient:
|
||||
"""Composite client for sd-server.
|
||||
|
||||
Usage::
|
||||
|
||||
with SDClient() as c:
|
||||
c.info.models()
|
||||
images = c.generate.txt2img(Txt2ImgParams(prompt="a cat"))
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 1234) -> None:
|
||||
self._http = HttpTransport(f"http://{host}:{port}")
|
||||
self.info = InfoAPI(self._http)
|
||||
self.generate = GenerationAPI(self._http)
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> SDClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""HTTP transport layer wrapping httpx."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
def __init__(self, base_url: str, timeout: float = 300.0) -> None:
|
||||
self._client = httpx.Client(base_url=base_url, timeout=timeout)
|
||||
logger.debug("transport ready: %s", base_url)
|
||||
|
||||
def get(self, path: str) -> Any:
|
||||
logger.debug("GET %s", path)
|
||||
try:
|
||||
r = self._client.get(path)
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("GET %s → %d: %s", path, e.response.status_code, e.response.text[:200])
|
||||
raise
|
||||
except httpx.RequestError as e:
|
||||
logger.error("GET %s connection failed: %s", path, e)
|
||||
raise
|
||||
return r.json()
|
||||
|
||||
def post(self, path: str, json: dict[str, Any]) -> Any:
|
||||
logger.debug("POST %s", path)
|
||||
try:
|
||||
r = self._client.post(path, json=json)
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("POST %s → %d: %s", path, e.response.status_code, e.response.text[:200])
|
||||
raise
|
||||
except httpx.RequestError as e:
|
||||
logger.error("POST %s connection failed: %s", path, e)
|
||||
raise
|
||||
return r.json()
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
logger.debug("transport closed")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Image generation endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tensors.generate._http import HttpTransport
|
||||
from tensors.generate.params import Img2ImgParams, Txt2ImgParams
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenerationAPI:
|
||||
def __init__(self, http: HttpTransport) -> None:
|
||||
self._http = http
|
||||
|
||||
def txt2img(self, params: Txt2ImgParams) -> list[bytes]:
|
||||
"""Generate images from text prompt."""
|
||||
logger.info("txt2img: '%s' %dx%d steps=%d", params.prompt[:60], params.width, params.height, params.steps)
|
||||
data = self._http.post("/sdapi/v1/txt2img", params.to_body())
|
||||
images = [base64.b64decode(img) for img in data["images"]]
|
||||
logger.info("txt2img: got %d image(s)", len(images))
|
||||
return images
|
||||
|
||||
def img2img(self, params: Img2ImgParams) -> list[bytes]:
|
||||
"""Generate images from image + text prompt."""
|
||||
logger.info("img2img: '%s' strength=%.2f steps=%d", params.prompt[:60], params.denoising_strength, params.steps)
|
||||
data = self._http.post("/sdapi/v1/img2img", params.to_body())
|
||||
images = [base64.b64decode(img) for img in data["images"]]
|
||||
logger.info("img2img: got %d image(s)", len(images))
|
||||
return images
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Model and server info endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tensors.generate._http import HttpTransport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InfoAPI:
|
||||
def __init__(self, http: HttpTransport) -> None:
|
||||
self._http = http
|
||||
|
||||
def models(self) -> list[dict[str, Any]]:
|
||||
"""List loaded models (OpenAI /v1/models)."""
|
||||
return self._http.get("/v1/models")["data"] # type: ignore[no-any-return]
|
||||
|
||||
def sd_models(self) -> list[dict[str, Any]]:
|
||||
"""Detailed model info (sdapi)."""
|
||||
return self._http.get("/sdapi/v1/sd-models") # type: ignore[no-any-return]
|
||||
|
||||
def options(self) -> dict[str, Any]:
|
||||
"""Current server options."""
|
||||
return self._http.get("/sdapi/v1/options") # type: ignore[no-any-return]
|
||||
|
||||
def loras(self) -> list[dict[str, Any]]:
|
||||
"""Available LoRAs from --lora-model-dir."""
|
||||
result: list[dict[str, Any]] = self._http.get("/sdapi/v1/loras")
|
||||
logger.info("found %d lora(s)", len(result))
|
||||
return result
|
||||
|
||||
def samplers(self) -> list[str]:
|
||||
"""Available sampler names."""
|
||||
return [s["name"] for s in self._http.get("/sdapi/v1/samplers")]
|
||||
|
||||
def schedulers(self) -> list[str]:
|
||||
"""Available scheduler names."""
|
||||
return [s["name"] for s in self._http.get("/sdapi/v1/schedulers")]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Generation parameter dataclasses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from tensors.generate.util import to_b64
|
||||
|
||||
|
||||
@dataclass
|
||||
class Txt2ImgParams:
|
||||
prompt: str
|
||||
negative_prompt: str = ""
|
||||
width: int = 512
|
||||
height: int = 512
|
||||
steps: int = 20
|
||||
cfg_scale: float = 7.0
|
||||
seed: int = -1
|
||||
batch_size: int = 1
|
||||
sampler_name: str = ""
|
||||
scheduler: str = ""
|
||||
clip_skip: int = -1
|
||||
lora: list[dict[str, Any]] | None = None
|
||||
|
||||
def to_body(self) -> dict[str, Any]:
|
||||
body = {
|
||||
"prompt": self.prompt,
|
||||
"negative_prompt": self.negative_prompt,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"steps": self.steps,
|
||||
"cfg_scale": self.cfg_scale,
|
||||
"seed": self.seed,
|
||||
"batch_size": self.batch_size,
|
||||
}
|
||||
if self.sampler_name:
|
||||
body["sampler_name"] = self.sampler_name
|
||||
if self.scheduler:
|
||||
body["scheduler"] = self.scheduler
|
||||
if self.clip_skip > 0:
|
||||
body["clip_skip"] = self.clip_skip
|
||||
if self.lora:
|
||||
body["lora"] = self.lora
|
||||
return body
|
||||
|
||||
|
||||
@dataclass
|
||||
class Img2ImgParams:
|
||||
prompt: str
|
||||
init_image: str | bytes | Path
|
||||
negative_prompt: str = ""
|
||||
width: int = -1
|
||||
height: int = -1
|
||||
steps: int = 20
|
||||
cfg_scale: float = 7.0
|
||||
denoising_strength: float = 0.75
|
||||
seed: int = -1
|
||||
batch_size: int = 1
|
||||
sampler_name: str = ""
|
||||
scheduler: str = ""
|
||||
clip_skip: int = -1
|
||||
mask: str | bytes | Path | None = None
|
||||
inpainting_mask_invert: bool = False
|
||||
lora: list[dict[str, Any]] | None = None
|
||||
extra_images: list[str | bytes | Path] = field(default_factory=list)
|
||||
|
||||
def to_body(self) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"prompt": self.prompt,
|
||||
"negative_prompt": self.negative_prompt,
|
||||
"steps": self.steps,
|
||||
"cfg_scale": self.cfg_scale,
|
||||
"denoising_strength": self.denoising_strength,
|
||||
"seed": self.seed,
|
||||
"batch_size": self.batch_size,
|
||||
"init_images": [to_b64(self.init_image)],
|
||||
}
|
||||
if self.width > 0:
|
||||
body["width"] = self.width
|
||||
if self.height > 0:
|
||||
body["height"] = self.height
|
||||
if self.mask is not None:
|
||||
body["mask"] = to_b64(self.mask)
|
||||
if self.inpainting_mask_invert:
|
||||
body["inpainting_mask_invert"] = 1
|
||||
if self.sampler_name:
|
||||
body["sampler_name"] = self.sampler_name
|
||||
if self.scheduler:
|
||||
body["scheduler"] = self.scheduler
|
||||
if self.clip_skip > 0:
|
||||
body["clip_skip"] = self.clip_skip
|
||||
if self.lora:
|
||||
body["lora"] = self.lora
|
||||
if self.extra_images:
|
||||
body["extra_images"] = [to_b64(img) for img in self.extra_images]
|
||||
return body
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Utility functions for image encoding and file I/O."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def to_b64(image: str | bytes | Path) -> str:
|
||||
"""Convert a file path, raw bytes, or base64 string to base64."""
|
||||
if isinstance(image, (str, Path)):
|
||||
path = Path(image)
|
||||
if path.exists():
|
||||
logger.debug("encoding file: %s", path)
|
||||
return base64.b64encode(path.read_bytes()).decode()
|
||||
return str(image)
|
||||
if isinstance(image, bytes):
|
||||
return base64.b64encode(image).decode()
|
||||
raise TypeError(f"unsupported image type: {type(image)}")
|
||||
|
||||
|
||||
def save_images(
|
||||
images: list[bytes],
|
||||
output_dir: str = ".",
|
||||
prefix: str = "output",
|
||||
) -> list[Path]:
|
||||
"""Write raw PNG bytes to numbered files. Returns saved paths."""
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
paths = []
|
||||
for i, data in enumerate(images):
|
||||
path = out / f"{prefix}_{i:04d}.png"
|
||||
path.write_bytes(data)
|
||||
logger.info("saved: %s", path)
|
||||
paths.append(path)
|
||||
return paths
|
||||
@@ -0,0 +1,31 @@
|
||||
"""sd-server wrapper — FastAPI app for managing sd-server process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from tensors.server.process import ProcessManager
|
||||
from tensors.server.routes import create_router
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
__all__ = ["ProcessManager", "create_app"]
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build the FastAPI application with process manager."""
|
||||
pm = ProcessManager()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
yield
|
||||
pm.stop()
|
||||
|
||||
app = FastAPI(title="sd-server wrapper", lifespan=lifespan)
|
||||
app.include_router(create_router(pm))
|
||||
app.state.pm = pm
|
||||
return app
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Pydantic request models for the wrapper API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
DEFAULT_PORT = 1234
|
||||
|
||||
|
||||
class StartRequest(BaseModel):
|
||||
model: str
|
||||
port: int = DEFAULT_PORT
|
||||
args: list[str] = []
|
||||
|
||||
|
||||
class RestartRequest(BaseModel):
|
||||
model: str | None = None
|
||||
port: int | None = None
|
||||
args: list[str] | None = None
|
||||
@@ -0,0 +1,60 @@
|
||||
"""sd-server process lifecycle management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SD_SERVER_BIN = shutil.which("sd-server") or "sd-server"
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
def __init__(self) -> None:
|
||||
self.proc: subprocess.Popen[bytes] | None = None
|
||||
self.config: dict[str, Any] = {}
|
||||
|
||||
def build_cmd(self, config: dict[str, Any] | None = None) -> list[str]:
|
||||
cfg = config or self.config
|
||||
cmd = [SD_SERVER_BIN, "-m", cfg["model"], "--listen-port", str(cfg["port"])]
|
||||
cmd.extend(cfg.get("args", []))
|
||||
return cmd
|
||||
|
||||
def start(self, config: dict[str, Any]) -> None:
|
||||
if self.proc is not None and self.proc.poll() is None:
|
||||
raise RuntimeError("Server already running — stop it first")
|
||||
self.config = config
|
||||
cmd = self.build_cmd(config)
|
||||
self.proc = subprocess.Popen(cmd)
|
||||
logger.info("started sd-server pid=%d cmd=%s", self.proc.pid, cmd)
|
||||
|
||||
def stop(self) -> bool:
|
||||
if self.proc is None or self.proc.poll() is not None:
|
||||
self.proc = None
|
||||
return False
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
self.proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.proc.wait(timeout=5)
|
||||
logger.info("stopped sd-server")
|
||||
self.proc = None
|
||||
return True
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
if self.proc is None:
|
||||
return {"running": False}
|
||||
rc = self.proc.poll()
|
||||
if rc is not None:
|
||||
return {"running": False, "exit_code": rc}
|
||||
return {
|
||||
"running": True,
|
||||
"pid": self.proc.pid,
|
||||
"config": self.config,
|
||||
"cmd": self.build_cmd(),
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""FastAPI route handlers for the wrapper API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from tensors.server.models import RestartRequest, StartRequest # noqa: TC001
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tensors.server.process import ProcessManager
|
||||
|
||||
|
||||
def create_router(pm: ProcessManager) -> APIRouter:
|
||||
"""Build a new router bound to the given ProcessManager."""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/status")
|
||||
def status() -> dict[str, Any]:
|
||||
return pm.status()
|
||||
|
||||
@router.post("/start")
|
||||
def start(req: StartRequest) -> dict[str, Any]:
|
||||
if pm.proc is not None and pm.proc.poll() is None:
|
||||
raise HTTPException(409, "Server already running — use /restart or /stop first")
|
||||
config = {"model": req.model, "port": req.port, "args": req.args}
|
||||
pm.start(config)
|
||||
assert pm.proc is not None
|
||||
return {"started": True, "pid": pm.proc.pid, "cmd": pm.build_cmd(config)}
|
||||
|
||||
@router.post("/stop")
|
||||
def stop() -> dict[str, Any]:
|
||||
if not pm.stop():
|
||||
raise HTTPException(409, "Server is not running")
|
||||
return {"stopped": True}
|
||||
|
||||
@router.post("/restart")
|
||||
def restart(req: RestartRequest) -> dict[str, Any]:
|
||||
if not pm.config and req.model is None:
|
||||
raise HTTPException(400, "No previous config — provide at least 'model'")
|
||||
config = dict(pm.config)
|
||||
if req.model is not None:
|
||||
config["model"] = req.model
|
||||
if req.port is not None:
|
||||
config["port"] = req.port
|
||||
if req.args is not None:
|
||||
config["args"] = req.args
|
||||
was_running = pm.stop()
|
||||
pm.start(config)
|
||||
assert pm.proc is not None
|
||||
return {
|
||||
"restarted": True,
|
||||
"was_running": was_running,
|
||||
"pid": pm.proc.pid,
|
||||
"cmd": pm.build_cmd(config),
|
||||
}
|
||||
|
||||
return router
|
||||
@@ -2,10 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from tensors.generate import SDClient
|
||||
|
||||
BASE_URL = "http://127.0.0.1:1234"
|
||||
|
||||
# 1x1 red PNG for image response stubs
|
||||
TINY_PNG = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
|
||||
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00"
|
||||
b"\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00"
|
||||
b"\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
TINY_PNG_B64 = base64.b64encode(TINY_PNG).decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -28,3 +43,18 @@ def temp_safetensor(tmp_path):
|
||||
f.write(header_bytes)
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_api():
|
||||
"""Activate respx mock for the sd-server base URL."""
|
||||
with respx.mock(base_url=BASE_URL, assert_all_called=False) as rsps:
|
||||
yield rsps
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(mock_api: respx.MockRouter) -> SDClient: # noqa: ARG001
|
||||
"""SDClient wired to the mocked transport."""
|
||||
c = SDClient()
|
||||
yield c # type: ignore[misc]
|
||||
c.close()
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Tests for tensors.generate package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from tensors.generate import SDClient
|
||||
from tensors.generate._http import HttpTransport
|
||||
from tensors.generate.params import Img2ImgParams, Txt2ImgParams
|
||||
from tensors.generate.util import save_images, to_b64
|
||||
from tests.conftest import BASE_URL, TINY_PNG, TINY_PNG_B64
|
||||
|
||||
# ── util ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToB64:
|
||||
def test_bytes_input(self):
|
||||
raw = b"hello"
|
||||
assert to_b64(raw) == base64.b64encode(raw).decode()
|
||||
|
||||
def test_file_path(self, tmp_path: Path):
|
||||
f = tmp_path / "img.png"
|
||||
f.write_bytes(b"\x89PNG")
|
||||
result = to_b64(str(f))
|
||||
assert base64.b64decode(result) == b"\x89PNG"
|
||||
|
||||
def test_pathlib_path(self, tmp_path: Path):
|
||||
f = tmp_path / "img.png"
|
||||
f.write_bytes(b"data")
|
||||
result = to_b64(f)
|
||||
assert base64.b64decode(result) == b"data"
|
||||
|
||||
def test_passthrough_string(self):
|
||||
b64 = base64.b64encode(b"already").decode()
|
||||
assert to_b64(b64) == b64
|
||||
|
||||
def test_unsupported_type(self):
|
||||
with pytest.raises(TypeError, match="unsupported image type"):
|
||||
to_b64(12345) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestSaveImages:
|
||||
def test_saves_files(self, tmp_path: Path):
|
||||
images = [b"img0", b"img1", b"img2"]
|
||||
paths = save_images(images, str(tmp_path), prefix="test")
|
||||
assert len(paths) == 3
|
||||
for i, p in enumerate(paths):
|
||||
assert p.name == f"test_{i:04d}.png"
|
||||
assert p.read_bytes() == images[i]
|
||||
|
||||
def test_creates_directory(self, tmp_path: Path):
|
||||
out = tmp_path / "sub" / "dir"
|
||||
save_images([b"x"], str(out))
|
||||
assert (out / "output_0000.png").exists()
|
||||
|
||||
|
||||
# ── params ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTxt2ImgParams:
|
||||
def test_minimal_body(self):
|
||||
p = Txt2ImgParams(prompt="a cat")
|
||||
body = p.to_body()
|
||||
assert body["prompt"] == "a cat"
|
||||
assert body["width"] == 512
|
||||
assert body["height"] == 512
|
||||
assert body["steps"] == 20
|
||||
assert body["seed"] == -1
|
||||
assert "sampler_name" not in body
|
||||
assert "scheduler" not in body
|
||||
assert "clip_skip" not in body
|
||||
assert "lora" not in body
|
||||
|
||||
def test_optional_fields_included(self):
|
||||
p = Txt2ImgParams(
|
||||
prompt="test",
|
||||
sampler_name="euler_a",
|
||||
scheduler="karras",
|
||||
clip_skip=2,
|
||||
lora=[{"path": "x.safetensors", "multiplier": 0.5}],
|
||||
)
|
||||
body = p.to_body()
|
||||
assert body["sampler_name"] == "euler_a"
|
||||
assert body["scheduler"] == "karras"
|
||||
assert body["clip_skip"] == 2
|
||||
assert len(body["lora"]) == 1
|
||||
|
||||
|
||||
class TestImg2ImgParams:
|
||||
def test_minimal_body(self, tmp_path: Path):
|
||||
img = tmp_path / "init.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
p = Img2ImgParams(prompt="paint it", init_image=str(img))
|
||||
body = p.to_body()
|
||||
assert body["prompt"] == "paint it"
|
||||
assert body["denoising_strength"] == 0.75
|
||||
decoded = base64.b64decode(body["init_images"][0])
|
||||
assert decoded == b"\x89PNG"
|
||||
assert "width" not in body
|
||||
assert "height" not in body
|
||||
assert "mask" not in body
|
||||
|
||||
def test_all_optional_fields(self, tmp_path: Path):
|
||||
img = tmp_path / "init.png"
|
||||
img.write_bytes(b"img")
|
||||
mask = tmp_path / "mask.png"
|
||||
mask.write_bytes(b"mask")
|
||||
extra = tmp_path / "extra.png"
|
||||
extra.write_bytes(b"extra")
|
||||
|
||||
p = Img2ImgParams(
|
||||
prompt="test",
|
||||
init_image=str(img),
|
||||
mask=str(mask),
|
||||
width=768,
|
||||
height=768,
|
||||
inpainting_mask_invert=True,
|
||||
sampler_name="euler",
|
||||
scheduler="simple",
|
||||
clip_skip=1,
|
||||
lora=[{"path": "a.gguf", "multiplier": 1.0}],
|
||||
extra_images=[str(extra)],
|
||||
)
|
||||
body = p.to_body()
|
||||
assert body["width"] == 768
|
||||
assert body["mask"]
|
||||
assert body["inpainting_mask_invert"] == 1
|
||||
assert body["sampler_name"] == "euler"
|
||||
assert len(body["extra_images"]) == 1
|
||||
|
||||
|
||||
# ── _http ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHttpTransport:
|
||||
def test_get_success(self):
|
||||
with respx.mock(base_url=BASE_URL) as rsps:
|
||||
rsps.get("/test").respond(json={"ok": True})
|
||||
t = HttpTransport(BASE_URL)
|
||||
assert t.get("/test") == {"ok": True}
|
||||
t.close()
|
||||
|
||||
def test_post_success(self):
|
||||
with respx.mock(base_url=BASE_URL) as rsps:
|
||||
rsps.post("/gen").respond(json={"images": []})
|
||||
t = HttpTransport(BASE_URL)
|
||||
assert t.post("/gen", {"prompt": "x"}) == {"images": []}
|
||||
t.close()
|
||||
|
||||
def test_get_http_error(self):
|
||||
with respx.mock(base_url=BASE_URL) as rsps:
|
||||
rsps.get("/bad").respond(status_code=404, text="not found")
|
||||
t = HttpTransport(BASE_URL)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
t.get("/bad")
|
||||
t.close()
|
||||
|
||||
def test_post_http_error(self):
|
||||
with respx.mock(base_url=BASE_URL) as rsps:
|
||||
rsps.post("/bad").respond(status_code=500, text="error")
|
||||
t = HttpTransport(BASE_URL)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
t.post("/bad", {})
|
||||
t.close()
|
||||
|
||||
def test_get_connection_error(self):
|
||||
with respx.mock(base_url=BASE_URL) as rsps:
|
||||
rsps.get("/fail").mock(side_effect=httpx.ConnectError("refused"))
|
||||
t = HttpTransport(BASE_URL)
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
t.get("/fail")
|
||||
t.close()
|
||||
|
||||
|
||||
# ── info ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInfoAPI:
|
||||
def test_models(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.get("/v1/models").respond(json={"data": [{"id": "sd-cpp-local", "object": "model", "owned_by": "local"}]})
|
||||
result = client.info.models()
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "sd-cpp-local"
|
||||
|
||||
def test_sd_models(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.get("/sdapi/v1/sd-models").respond(
|
||||
json=[{"title": "sdxl", "model_name": "sdxl", "filename": "sdxl.safetensors"}]
|
||||
)
|
||||
result = client.info.sd_models()
|
||||
assert result[0]["title"] == "sdxl"
|
||||
|
||||
def test_options(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.get("/sdapi/v1/options").respond(
|
||||
json={
|
||||
"samples_format": "png",
|
||||
"sd_model_checkpoint": "v1-5",
|
||||
}
|
||||
)
|
||||
result = client.info.options()
|
||||
assert result["sd_model_checkpoint"] == "v1-5"
|
||||
|
||||
def test_loras(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.get("/sdapi/v1/loras").respond(
|
||||
json=[
|
||||
{"name": "style", "path": "style.safetensors"},
|
||||
]
|
||||
)
|
||||
result = client.info.loras()
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "style"
|
||||
|
||||
def test_samplers(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.get("/sdapi/v1/samplers").respond(
|
||||
json=[
|
||||
{"name": "euler", "aliases": ["euler"], "options": {}},
|
||||
{"name": "euler_a", "aliases": ["euler_a"], "options": {}},
|
||||
]
|
||||
)
|
||||
result = client.info.samplers()
|
||||
assert result == ["euler", "euler_a"]
|
||||
|
||||
def test_schedulers(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.get("/sdapi/v1/schedulers").respond(
|
||||
json=[
|
||||
{"name": "discrete", "label": "discrete"},
|
||||
{"name": "karras", "label": "karras"},
|
||||
]
|
||||
)
|
||||
result = client.info.schedulers()
|
||||
assert result == ["discrete", "karras"]
|
||||
|
||||
|
||||
# ── generation ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTxt2Img:
|
||||
def test_returns_decoded_images(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.post("/sdapi/v1/txt2img").respond(
|
||||
json={
|
||||
"images": [TINY_PNG_B64],
|
||||
"parameters": {},
|
||||
"info": "",
|
||||
}
|
||||
)
|
||||
images = client.generate.txt2img(Txt2ImgParams(prompt="a cat"))
|
||||
assert len(images) == 1
|
||||
assert images[0] == TINY_PNG
|
||||
|
||||
def test_multiple_images(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
mock_api.post("/sdapi/v1/txt2img").respond(
|
||||
json={
|
||||
"images": [TINY_PNG_B64, TINY_PNG_B64, TINY_PNG_B64],
|
||||
"parameters": {},
|
||||
"info": "",
|
||||
}
|
||||
)
|
||||
params = Txt2ImgParams(prompt="cats", batch_size=3)
|
||||
images = client.generate.txt2img(params)
|
||||
assert len(images) == 3
|
||||
|
||||
def test_sends_correct_body(self, mock_api: respx.MockRouter, client: SDClient):
|
||||
route = mock_api.post("/sdapi/v1/txt2img").respond(
|
||||
json={
|
||||
"images": [TINY_PNG_B64],
|
||||
"parameters": {},
|
||||
"info": "",
|
||||
}
|
||||
)
|
||||
params = Txt2ImgParams(
|
||||
prompt="hello",
|
||||
width=768,
|
||||
height=768,
|
||||
steps=30,
|
||||
sampler_name="euler_a",
|
||||
)
|
||||
client.generate.txt2img(params)
|
||||
sent = json.loads(route.calls[0].request.content)
|
||||
assert sent["prompt"] == "hello"
|
||||
assert sent["width"] == 768
|
||||
assert sent["sampler_name"] == "euler_a"
|
||||
|
||||
|
||||
class TestImg2Img:
|
||||
def test_returns_decoded_images(self, mock_api: respx.MockRouter, client: SDClient, tmp_path: Path):
|
||||
mock_api.post("/sdapi/v1/img2img").respond(
|
||||
json={
|
||||
"images": [TINY_PNG_B64],
|
||||
"parameters": {},
|
||||
"info": "",
|
||||
}
|
||||
)
|
||||
img = tmp_path / "init.png"
|
||||
img.write_bytes(TINY_PNG)
|
||||
params = Img2ImgParams(prompt="paint", init_image=str(img))
|
||||
images = client.generate.img2img(params)
|
||||
assert len(images) == 1
|
||||
assert images[0] == TINY_PNG
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for tensors.server package (FastAPI sd-server manager)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tensors.server import create_app
|
||||
from tensors.server.process import ProcessManager
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pm() -> ProcessManager:
|
||||
return ProcessManager()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api() -> TestClient:
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def _get_pm(api: TestClient) -> ProcessManager:
|
||||
return api.app.state.pm # type: ignore[union-attr]
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_not_running(self, api: TestClient) -> None:
|
||||
r = api.get("/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["running"] is False
|
||||
|
||||
def test_running(self, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.pid = 999
|
||||
pm.proc = mock_proc
|
||||
pm.config = {"model": "/m.safetensors", "port": 1234, "args": []}
|
||||
r = api.get("/status")
|
||||
data = r.json()
|
||||
assert data["running"] is True
|
||||
assert data["pid"] == 999
|
||||
|
||||
def test_exited(self, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
pm.proc = mock_proc
|
||||
r = api.get("/status")
|
||||
data = r.json()
|
||||
assert data["running"] is False
|
||||
assert data["exit_code"] == 1
|
||||
|
||||
|
||||
class TestStart:
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_start_success(self, mock_popen: MagicMock, api: TestClient) -> None:
|
||||
mock_popen.return_value.pid = 42
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
r = api.post("/start", json={"model": "/m.safetensors"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["started"] is True
|
||||
assert r.json()["pid"] == 42
|
||||
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_start_already_running(self, mock_popen: MagicMock, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
pm.proc = mock_proc
|
||||
r = api.post("/start", json={"model": "/m.safetensors"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
class TestStop:
|
||||
def test_stop_not_running(self, api: TestClient) -> None:
|
||||
r = api.post("/stop")
|
||||
assert r.status_code == 409
|
||||
|
||||
def test_stop_running(self, api: TestClient) -> None:
|
||||
pm = _get_pm(api)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.wait.return_value = 0
|
||||
pm.proc = mock_proc
|
||||
r = api.post("/stop")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stopped"] is True
|
||||
mock_proc.send_signal.assert_called_once()
|
||||
|
||||
|
||||
class TestRestart:
|
||||
def test_restart_no_config_no_model(self, api: TestClient) -> None:
|
||||
r = api.post("/restart", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_restart_with_new_model(self, mock_popen: MagicMock, api: TestClient) -> None:
|
||||
mock_popen.return_value.pid = 100
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
pm = _get_pm(api)
|
||||
pm.config = {"model": "/old.safetensors", "port": 1234, "args": []}
|
||||
r = api.post("/restart", json={"model": "/new.safetensors"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["restarted"] is True
|
||||
assert "/new.safetensors" in str(data["cmd"])
|
||||
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_restart_keeps_previous_config(self, mock_popen: MagicMock, api: TestClient) -> None:
|
||||
mock_popen.return_value.pid = 101
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
pm = _get_pm(api)
|
||||
pm.config = {"model": "/m.safetensors", "port": 5555, "args": ["--fa"]}
|
||||
r = api.post("/restart", json={})
|
||||
assert r.status_code == 200
|
||||
assert "5555" in str(r.json()["cmd"])
|
||||
|
||||
|
||||
class TestProcessManager:
|
||||
def test_status_not_running(self, pm: ProcessManager) -> None:
|
||||
assert pm.status() == {"running": False}
|
||||
|
||||
def test_build_cmd(self, pm: ProcessManager) -> None:
|
||||
config = {"model": "/m.gguf", "port": 1234, "args": ["--fa"]}
|
||||
cmd = pm.build_cmd(config)
|
||||
assert "/m.gguf" in cmd
|
||||
assert "--fa" in cmd
|
||||
assert "1234" in cmd
|
||||
|
||||
@patch("tensors.server.process.subprocess.Popen")
|
||||
def test_start_and_stop(self, mock_popen: MagicMock, pm: ProcessManager) -> None:
|
||||
mock_popen.return_value.pid = 77
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
mock_popen.return_value.wait.return_value = 0
|
||||
pm.start({"model": "/m.gguf", "port": 1234, "args": []})
|
||||
assert pm.proc is not None
|
||||
assert pm.stop() is True
|
||||
assert pm.proc is None
|
||||
@@ -2,6 +2,24 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
@@ -137,6 +155,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.129.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.20.3"
|
||||
@@ -405,6 +439,92 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -572,6 +692,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.52.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tensors"
|
||||
version = "0.1.5"
|
||||
@@ -583,8 +716,15 @@ dependencies = [
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
server = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "mypy" },
|
||||
{ name = "nuitka" },
|
||||
{ name = "pre-commit" },
|
||||
@@ -592,18 +732,23 @@ dev = [
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "respx" },
|
||||
{ name = "ruff" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "rich", specifier = ">=13.0.0" },
|
||||
{ name = "safetensors", specifier = ">=0.4.0" },
|
||||
{ name = "typer", specifier = ">=0.15.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30" },
|
||||
]
|
||||
provides-extras = ["server"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "mypy", specifier = ">=1.14.0" },
|
||||
{ name = "nuitka", specifier = ">=2.0" },
|
||||
{ name = "pre-commit", specifier = ">=3.6" },
|
||||
@@ -611,6 +756,7 @@ dev = [
|
||||
{ name = "pytest-cov", specifier = ">=4.1" },
|
||||
{ name = "respx", specifier = ">=0.22.0" },
|
||||
{ name = "ruff", specifier = ">=0.9.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.30" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -637,6 +783,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.36.1"
|
||||
|
||||
Reference in New Issue
Block a user