From 503274a9388f713201b267c6751464ac230cfd9e Mon Sep 17 00:00:00 2001 From: Adam Ladachowski Date: Thu, 12 Feb 2026 20:23:09 +0000 Subject: [PATCH] [Update] 2026-02-12 20:23:09, 18 files --- .coverage | Bin 86016 -> 98304 bytes REFACTOR.md | 87 ++++++++++ pyproject.toml | 12 ++ tensors/cli.py | 63 ++++++- tensors/generate/__init__.py | 43 +++++ tensors/generate/_http.py | 46 +++++ tensors/generate/generation.py | 34 ++++ tensors/generate/info.py | 42 +++++ tensors/generate/params.py | 100 +++++++++++ tensors/generate/util.py | 37 ++++ tensors/server/__init__.py | 31 ++++ tensors/server/models.py | 19 +++ tensors/server/process.py | 60 +++++++ tensors/server/routes.py | 59 +++++++ tests/conftest.py | 30 ++++ tests/test_generate.py | 303 +++++++++++++++++++++++++++++++++ tests/test_server.py | 141 +++++++++++++++ uv.lock | 171 +++++++++++++++++++ 18 files changed, 1277 insertions(+), 1 deletion(-) create mode 100644 REFACTOR.md create mode 100644 tensors/generate/__init__.py create mode 100644 tensors/generate/_http.py create mode 100644 tensors/generate/generation.py create mode 100644 tensors/generate/info.py create mode 100644 tensors/generate/params.py create mode 100644 tensors/generate/util.py create mode 100644 tensors/server/__init__.py create mode 100644 tensors/server/models.py create mode 100644 tensors/server/process.py create mode 100644 tensors/server/routes.py create mode 100644 tests/test_generate.py create mode 100644 tests/test_server.py diff --git a/.coverage b/.coverage index cea8ccffa4a6a08c9a0bdb2826cb1169c0f2fbe8..9f429c5cd2af0ed04651f6fcabeb7e083848a816 100644 GIT binary patch literal 98304 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 literal 86016 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 IgQ_?GAJD%D8vp 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. diff --git a/pyproject.toml b/pyproject.toml index b454891..75cf059 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tensors/cli.py b/tensors/cli.py index a898924..30e1a68 100644 --- a/tensors/cli.py +++ b/tensors/cli.py @@ -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 -> tsr info 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:]] diff --git a/tensors/generate/__init__.py b/tensors/generate/__init__.py new file mode 100644 index 0000000..5741ef3 --- /dev/null +++ b/tensors/generate/__init__.py @@ -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() diff --git a/tensors/generate/_http.py b/tensors/generate/_http.py new file mode 100644 index 0000000..ea4337d --- /dev/null +++ b/tensors/generate/_http.py @@ -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") diff --git a/tensors/generate/generation.py b/tensors/generate/generation.py new file mode 100644 index 0000000..acc8b89 --- /dev/null +++ b/tensors/generate/generation.py @@ -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 diff --git a/tensors/generate/info.py b/tensors/generate/info.py new file mode 100644 index 0000000..abcb7ae --- /dev/null +++ b/tensors/generate/info.py @@ -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")] diff --git a/tensors/generate/params.py b/tensors/generate/params.py new file mode 100644 index 0000000..be18ce8 --- /dev/null +++ b/tensors/generate/params.py @@ -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 diff --git a/tensors/generate/util.py b/tensors/generate/util.py new file mode 100644 index 0000000..4014115 --- /dev/null +++ b/tensors/generate/util.py @@ -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 diff --git a/tensors/server/__init__.py b/tensors/server/__init__.py new file mode 100644 index 0000000..d539965 --- /dev/null +++ b/tensors/server/__init__.py @@ -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 diff --git a/tensors/server/models.py b/tensors/server/models.py new file mode 100644 index 0000000..330bae1 --- /dev/null +++ b/tensors/server/models.py @@ -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 diff --git a/tensors/server/process.py b/tensors/server/process.py new file mode 100644 index 0000000..1898616 --- /dev/null +++ b/tensors/server/process.py @@ -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(), + } diff --git a/tensors/server/routes.py b/tensors/server/routes.py new file mode 100644 index 0000000..6074929 --- /dev/null +++ b/tensors/server/routes.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index df83dc4..6cfc14e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_generate.py b/tests/test_generate.py new file mode 100644 index 0000000..fdfe63d --- /dev/null +++ b/tests/test_generate.py @@ -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 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..090c649 --- /dev/null +++ b/tests/test_server.py @@ -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 diff --git a/uv.lock b/uv.lock index f52ed86..e894e29 100644 --- a/uv.lock +++ b/uv.lock @@ -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"