feat: add UI registry server and tiered backend (local → registry → github)

Add FastAPI registry server that pre-fetches all component data from
hanzoai/ui on GitHub, caches in memory, refreshes every 15min. Serves
instant cached responses. Deploy at ui.hanzo.ai.

- registry/cache.py: eager full-cache with concurrent GitHub fetching
- registry/server.py: FastAPI endpoints for components, blocks, search
- registry/client.py: RegistryClient matching GitHubAPIClient interface
- Dockerfile for containerized deployment
- UiTool now uses 3-tier fallback: local disk → registry → GitHub API
- Bump hanzo-tools-ui to 0.2.0, server deps optional (fastapi, uvicorn)
This commit is contained in:
2026-03-27 19:06:38 -07:00
parent b71b054f11
commit 07105edc8d
9 changed files with 851 additions and 34 deletions
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim AS builder
WORKDIR /build
COPY pkg/hanzo-tools-ui/ ./pkg/hanzo-tools-ui/
COPY pkg/hanzo-tools-core/ ./pkg/hanzo-tools-core/
RUN pip install --no-cache-dir \
./pkg/hanzo-tools-core \
"./pkg/hanzo-tools-ui[server]"
FROM python:3.12-slim
RUN useradd -m -r hanzo
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin/hanzo-ui-registry /usr/local/bin/
USER hanzo
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8787/api/health')"
ENV REGISTRY_HOST=0.0.0.0 \
REGISTRY_PORT=8787
CMD ["python", "-m", "uvicorn", "hanzo_tools.ui.registry.server:app", "--host", "0.0.0.0", "--port", "8787"]
@@ -0,0 +1,15 @@
"""Hanzo UI Registry — cached component server and client.
Server: pre-fetches all component data from hanzoai/ui on GitHub,
serves cached responses via FastAPI. Deploy at ui.hanzo.ai.
Client: fetches from the registry server instead of hitting GitHub
directly. Used by the UI tool as a middle-tier between local and GitHub.
"""
from .client import RegistryClient
__all__ = ["RegistryClient"]
# Server imports are deferred to avoid requiring fastapi at import time.
# Use: from hanzo_tools.ui.registry.server import app
@@ -0,0 +1,28 @@
"""Run the Hanzo UI Registry server.
Usage:
python -m hanzo_tools.ui.registry
# or
hanzo-ui-registry
"""
import os
def main():
import uvicorn
host = os.environ.get("REGISTRY_HOST", "0.0.0.0")
port = int(os.environ.get("REGISTRY_PORT", "8787"))
dev = os.environ.get("REGISTRY_DEV", "") == "1"
uvicorn.run(
"hanzo_tools.ui.registry.server:app",
host=host,
port=port,
reload=dev,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,323 @@
"""Registry cache — eagerly fetches all component data from GitHub.
On startup, walks the hanzoai/ui repo and fetches every component's
source, metadata, and demo. Stores everything in memory. Refreshes
on a background timer. All lookups are O(1) dict reads.
"""
import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any
from ..github_api import FRAMEWORK_CONFIGS, GitHubAPIClient
logger = logging.getLogger(__name__)
DEFAULT_REFRESH_INTERVAL = int(os.environ.get("REFRESH_INTERVAL", "900")) # 15 min
MAX_CONCURRENCY = 10
@dataclass
class ComponentEntry:
name: str
type: str # "file" or "dir"
path: str = ""
source: str | None = None
metadata: dict | None = None
demo: str | None = None
category: str = ""
description: str = ""
@dataclass
class FrameworkData:
"""All cached data for a single framework."""
components: dict[str, ComponentEntry] = field(default_factory=dict)
blocks: dict[str, ComponentEntry] = field(default_factory=dict)
directory_cache: dict[str, dict] = field(default_factory=dict)
class RegistryCache:
"""In-memory cache of all UI component data from GitHub."""
def __init__(
self,
frameworks: list[str] | None = None,
refresh_interval: int = DEFAULT_REFRESH_INTERVAL,
):
self._github = GitHubAPIClient()
self._frameworks = frameworks or ["hanzo"]
self._refresh_interval = refresh_interval
self._data: dict[str, FrameworkData] = {}
self._last_refresh: float = 0
self._refresh_duration: float = 0
self._refreshing = False
self._ready = False
self._task: asyncio.Task | None = None
@property
def ready(self) -> bool:
return self._ready
@property
def health(self) -> dict:
total_components = sum(len(d.components) for d in self._data.values())
total_blocks = sum(len(d.blocks) for d in self._data.values())
age = time.time() - self._last_refresh if self._last_refresh else None
return {
"status": "healthy" if self._ready else "warming",
"ready": self._ready,
"last_refresh": self._last_refresh,
"cache_age_seconds": round(age, 1) if age else None,
"refresh_duration_seconds": round(self._refresh_duration, 1),
"refreshing": self._refreshing,
"frameworks": self._frameworks,
"components_cached": total_components,
"blocks_cached": total_blocks,
}
async def refresh(self) -> None:
"""Full refresh: fetch all components for all frameworks."""
if self._refreshing:
logger.info("Refresh already in progress, skipping")
return
self._refreshing = True
start = time.time()
logger.info("Starting cache refresh for frameworks: %s", self._frameworks)
try:
for framework in self._frameworks:
await self._refresh_framework(framework)
except Exception:
logger.exception("Cache refresh failed")
finally:
self._refreshing = False
self._last_refresh = time.time()
self._refresh_duration = time.time() - start
self._ready = True
total = sum(len(d.components) for d in self._data.values())
logger.info(
"Cache refresh complete: %d components in %.1fs",
total,
self._refresh_duration,
)
async def _refresh_framework(self, framework: str) -> None:
"""Refresh all data for a single framework."""
if framework not in FRAMEWORK_CONFIGS:
logger.warning("Unknown framework: %s", framework)
return
fw_data = FrameworkData()
sem = asyncio.Semaphore(MAX_CONCURRENCY)
# Fetch component list
try:
comp_list = await self._github.list_components(framework)
except Exception:
logger.exception("Failed to list components for %s", framework)
return
# Fetch source + metadata + demo for each component concurrently
async def fetch_one(comp: dict) -> ComponentEntry:
name = comp["name"]
entry = ComponentEntry(
name=name,
type=comp.get("type", "file"),
path=comp.get("path", ""),
)
async with sem:
# Source
try:
entry.source = await self._github.fetch_component(name, framework)
except Exception:
logger.debug("No source for %s/%s", framework, name)
# Metadata
try:
entry.metadata = await self._github.fetch_component_metadata(
name, framework
)
except Exception:
pass
# Demo
try:
entry.demo = await self._github.fetch_component_demo(
name, framework
)
except Exception:
pass
return entry
entries = await asyncio.gather(
*[fetch_one(c) for c in comp_list],
return_exceptions=True,
)
for entry in entries:
if isinstance(entry, ComponentEntry):
fw_data.components[entry.name] = entry
elif isinstance(entry, Exception):
logger.debug("Component fetch error: %s", entry)
# Fetch blocks
try:
block_list = await self._github.list_blocks(framework)
for block in block_list:
name = block["name"]
b_entry = ComponentEntry(name=name, type=block.get("type", "file"))
try:
async with sem:
b_entry.source = await self._github.fetch_block(
name, framework
)
except Exception:
pass
fw_data.blocks[name] = b_entry
except Exception:
logger.debug("No blocks for %s", framework)
# Cache directory structure
config = FRAMEWORK_CONFIGS[framework]
try:
structure = await self._github.get_directory_structure(
config.components_path, framework
)
fw_data.directory_cache[config.components_path] = structure
except Exception:
pass
# Atomic swap
self._data[framework] = fw_data
logger.info(
"Cached %d components, %d blocks for %s",
len(fw_data.components),
len(fw_data.blocks),
framework,
)
async def start_background_refresh(self) -> None:
"""Run refresh loop in the background."""
while True:
await asyncio.sleep(self._refresh_interval)
try:
await self.refresh()
except Exception:
logger.exception("Background refresh failed")
def start(self) -> None:
"""Start the background refresh task."""
if self._task is None:
self._task = asyncio.create_task(self.start_background_refresh())
def stop(self) -> None:
"""Stop the background refresh task."""
if self._task is not None:
self._task.cancel()
self._task = None
# --- Lookup methods (all O(1)) ---
def _fw(self, framework: str) -> FrameworkData:
return self._data.get(framework, FrameworkData())
def get_components(self, framework: str = "hanzo") -> list[dict]:
return [
{
"name": e.name,
"type": e.type,
"path": e.path,
"has_source": e.source is not None,
"has_demo": e.demo is not None,
}
for e in self._fw(framework).components.values()
]
def get_component(self, name: str, framework: str = "hanzo") -> dict | None:
entry = self._fw(framework).components.get(name)
if entry is None:
return None
return {
"name": entry.name,
"type": entry.type,
"source": entry.source,
"metadata": entry.metadata,
"demo": entry.demo,
}
def get_component_source(self, name: str, framework: str = "hanzo") -> str | None:
entry = self._fw(framework).components.get(name)
return entry.source if entry else None
def get_component_demo(self, name: str, framework: str = "hanzo") -> str | None:
entry = self._fw(framework).components.get(name)
return entry.demo if entry else None
def get_component_metadata(
self, name: str, framework: str = "hanzo"
) -> dict | None:
entry = self._fw(framework).components.get(name)
return entry.metadata if entry else None
def get_blocks(self, framework: str = "hanzo") -> list[dict]:
return [
{"name": e.name, "type": e.type, "has_source": e.source is not None}
for e in self._fw(framework).blocks.values()
]
def get_block(self, name: str, framework: str = "hanzo") -> dict | None:
entry = self._fw(framework).blocks.get(name)
if entry is None:
return None
return {"name": entry.name, "source": entry.source}
def search(self, query: str, framework: str = "hanzo") -> list[dict]:
q = query.lower()
results = []
for entry in self._fw(framework).components.values():
if (
q in entry.name.lower()
or q in entry.description.lower()
or q in entry.category.lower()
):
results.append(
{"name": entry.name, "type": entry.type, "match": "name"}
)
return results
def get_structure(
self, path: str, framework: str = "hanzo"
) -> dict[str, Any] | None:
return self._fw(framework).directory_cache.get(path)
def build_index(self, framework: str = "hanzo") -> dict:
"""Build the full registry index (single-payload for client hydration)."""
components = {}
for name, entry in self._fw(framework).components.items():
components[name] = {
"name": entry.name,
"type": entry.type,
"source": entry.source,
"metadata": entry.metadata,
"demo": entry.demo,
}
blocks = {}
for name, entry in self._fw(framework).blocks.items():
blocks[name] = {"name": entry.name, "source": entry.source}
return {
"framework": framework,
"generated_at": self._last_refresh,
"components": components,
"blocks": blocks,
"total_components": len(components),
"total_blocks": len(blocks),
}
@@ -0,0 +1,143 @@
"""Registry client — fetches component data from the Hanzo UI registry server.
Mirrors the GitHubAPIClient / LocalUIClient interface so it can be used
as a drop-in backend in UiTool. Has its own short TTL cache to avoid
hitting the server on every tool call during a session.
"""
import os
import time
from typing import Any
import httpx
DEFAULT_REGISTRY_URL = "https://ui.hanzo.ai"
CLIENT_CACHE_TTL = 60 # 1 minute local cache
class RegistryClient:
"""Client for the Hanzo UI registry server."""
def __init__(self, base_url: str | None = None):
self._base_url = (
base_url
or os.environ.get("HANZO_UI_REGISTRY_URL")
or DEFAULT_REGISTRY_URL
).rstrip("/")
self._client: httpx.AsyncClient | None = None
self._cache: dict[str, tuple[Any, float]] = {}
self._available: bool | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
base_url=self._base_url,
timeout=15.0,
headers={"User-Agent": "Hanzo-MCP-UI-Tool"},
)
return self._client
def _cache_get(self, key: str) -> Any | None:
entry = self._cache.get(key)
if entry is None:
return None
data, ts = entry
if time.time() - ts > CLIENT_CACHE_TTL:
del self._cache[key]
return None
return data
def _cache_set(self, key: str, data: Any) -> None:
self._cache[key] = (data, time.time())
async def _get(self, path: str, params: dict | None = None) -> Any:
cache_key = f"{path}:{params}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
client = await self._get_client()
resp = await client.get(path, params=params)
resp.raise_for_status()
data = resp.json()
self._cache_set(cache_key, data)
return data
async def check_available(self) -> bool:
"""Check if the registry server is reachable."""
if self._available is not None:
return self._available
try:
client = await self._get_client()
resp = await client.get("/api/health", timeout=5.0)
self._available = resp.status_code == 200
except Exception:
self._available = False
return self._available
@property
def available(self) -> bool | None:
"""Cached availability check result. None if not yet checked."""
return self._available
# --- Mirror of GitHubAPIClient / LocalUIClient interface ---
async def list_components(self, framework: str = "hanzo") -> list[dict]:
data = await self._get("/api/components", {"framework": framework})
return data.get("components", [])
async def fetch_component(self, name: str, framework: str = "hanzo") -> str:
data = await self._get(f"/api/components/{name}", {"framework": framework})
source = data.get("source")
if source is None:
raise FileNotFoundError(f"Component '{name}' source not available")
return source
async def fetch_component_demo(self, name: str, framework: str = "hanzo") -> str:
data = await self._get(
f"/api/components/{name}/demo", {"framework": framework}
)
demo = data.get("demo")
if demo is None:
raise FileNotFoundError(f"Demo for '{name}' not available")
return demo
async def fetch_component_metadata(
self, name: str, framework: str = "hanzo"
) -> dict:
data = await self._get(
f"/api/components/{name}/metadata", {"framework": framework}
)
return data.get("metadata", data)
async def list_blocks(self, framework: str = "hanzo") -> list[dict]:
data = await self._get("/api/blocks", {"framework": framework})
return data.get("blocks", [])
async def fetch_block(self, name: str, framework: str = "hanzo") -> str:
data = await self._get(f"/api/blocks/{name}", {"framework": framework})
source = data.get("source")
if source is None:
raise FileNotFoundError(f"Block '{name}' source not available")
return source
async def search_components(self, query: str, framework: str = "hanzo") -> list[dict]:
data = await self._get("/api/search", {"q": query, "framework": framework})
return data.get("results", [])
async def get_directory_structure(
self, path: str, framework: str = "hanzo"
) -> dict:
return await self._get("/api/structure", {"path": path, "framework": framework})
async def fetch_full_index(self, framework: str = "hanzo") -> dict:
"""Fetch the full registry index — all components in one payload.
Use this to hydrate a local cache with a single HTTP call.
"""
return await self._get("/registry/index.json", {"framework": framework})
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
@@ -0,0 +1,177 @@
"""Hanzo UI Registry Server — FastAPI service serving cached component data.
Deploy at ui.hanzo.ai. Pre-fetches all components from hanzoai/ui on GitHub,
serves instant cached responses. Refreshes every 15 minutes in background.
Run:
python -m hanzo_tools.ui.registry
# or
uvicorn hanzo_tools.ui.registry.server:app --port 8787
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, ORJSONResponse
from .cache import RegistryCache
logger = logging.getLogger(__name__)
# Configuration
FRAMEWORKS = os.environ.get("REGISTRY_FRAMEWORKS", "hanzo").split(",")
REFRESH_INTERVAL = int(os.environ.get("REFRESH_INTERVAL", "900"))
# Global cache instance
_cache: RegistryCache | None = None
def get_cache() -> RegistryCache:
assert _cache is not None, "Cache not initialized"
return _cache
@asynccontextmanager
async def lifespan(app: FastAPI):
global _cache
_cache = RegistryCache(frameworks=FRAMEWORKS, refresh_interval=REFRESH_INTERVAL)
logger.info("Starting initial cache refresh...")
await _cache.refresh()
logger.info("Initial refresh complete, starting background refresh loop")
_cache.start()
yield
_cache.stop()
logger.info("Registry server shutting down")
app = FastAPI(
title="Hanzo UI Registry",
description="Cached component registry for Hanzo UI",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
# --- API Endpoints ---
@app.get("/api/health")
async def health():
cache = get_cache()
return cache.health
@app.get("/api/components")
async def list_components(framework: str = Query(default="hanzo")):
cache = get_cache()
components = cache.get_components(framework)
return {"framework": framework, "total": len(components), "components": components}
@app.get("/api/components/{name}")
async def get_component(name: str, framework: str = Query(default="hanzo")):
cache = get_cache()
comp = cache.get_component(name, framework)
if comp is None:
raise HTTPException(404, detail={"error": "Component not found", "name": name})
return comp
@app.get("/api/components/{name}/source")
async def get_component_source(name: str, framework: str = Query(default="hanzo")):
cache = get_cache()
source = cache.get_component_source(name, framework)
if source is None:
raise HTTPException(404, detail={"error": "Component not found", "name": name})
return {"name": name, "source": source}
@app.get("/api/components/{name}/demo")
async def get_component_demo(name: str, framework: str = Query(default="hanzo")):
cache = get_cache()
demo = cache.get_component_demo(name, framework)
if demo is None:
raise HTTPException(
404, detail={"error": "Demo not found", "name": name}
)
return {"name": name, "demo": demo}
@app.get("/api/components/{name}/metadata")
async def get_component_metadata(name: str, framework: str = Query(default="hanzo")):
cache = get_cache()
metadata = cache.get_component_metadata(name, framework)
if metadata is None:
raise HTTPException(404, detail={"error": "Component not found", "name": name})
return {"name": name, "metadata": metadata}
@app.get("/api/blocks")
async def list_blocks(framework: str = Query(default="hanzo")):
cache = get_cache()
blocks = cache.get_blocks(framework)
return {"framework": framework, "total": len(blocks), "blocks": blocks}
@app.get("/api/blocks/{name}")
async def get_block(name: str, framework: str = Query(default="hanzo")):
cache = get_cache()
block = cache.get_block(name, framework)
if block is None:
raise HTTPException(404, detail={"error": "Block not found", "name": name})
return block
@app.get("/api/search")
async def search_components(
q: str = Query(..., min_length=1), framework: str = Query(default="hanzo")
):
cache = get_cache()
results = cache.search(q, framework)
return {"query": q, "framework": framework, "results": results}
@app.get("/api/structure")
async def get_structure(
path: str = Query(default=""), framework: str = Query(default="hanzo")
):
cache = get_cache()
structure = cache.get_structure(path, framework)
if structure is None:
raise HTTPException(
404, detail={"error": "Path not found in cache", "path": path}
)
return structure
@app.get("/registry/index.json")
async def registry_index(framework: str = Query(default="hanzo")):
"""Full registry manifest — all components with source in one payload."""
cache = get_cache()
index = cache.build_index(framework)
return JSONResponse(content=index)
@app.get("/")
async def root():
return {
"service": "Hanzo UI Registry",
"docs": "/docs",
"health": "/api/health",
"components": "/api/components",
"index": "/registry/index.json",
}
+48 -30
View File
@@ -3,8 +3,7 @@
Single 'ui' tool for browsing, searching, installing, and managing
UI components from Hanzo and other registries.
Local-first: when ~/work/hanzo/ui exists, reads directly from disk
for the hanzo framework. Falls back to GitHub API otherwise.
Backend priority: local disk → registry server → GitHub API
Actions:
- list_components: List available components for a framework
@@ -25,6 +24,7 @@ Actions:
import asyncio
import logging
import os
from typing import ClassVar
from mcp.server.fastmcp import Context as MCPContext
@@ -37,6 +37,7 @@ from .github_api import (
GitHubAPIClient,
)
from .local_client import LocalUIClient
from .registry.client import RegistryClient
logger = logging.getLogger(__name__)
@@ -50,16 +51,20 @@ class UiTool(BaseTool):
Browse, search, install, and manage UI components from
Hanzo and other registries (shadcn/ui, Vue, Svelte, React Native).
Local-first: prefers ~/work/hanzo/ui when available for hanzo framework.
Backend priority: local → registry → GitHub API
"""
name: ClassVar[str] = "ui"
VERSION: ClassVar[str] = "0.2.0"
VERSION: ClassVar[str] = "0.3.0"
def __init__(self):
super().__init__()
self._github = GitHubAPIClient()
self._local = LocalUIClient()
self._registry = RegistryClient()
self._registry_checked = False
self._registry_available = False
if self._local.available:
logger.info("UI tool: local hanzo/ui repo detected, using local-first mode")
self._register_ui_actions()
@@ -68,6 +73,21 @@ class UiTool(BaseTool):
"""Use local client for hanzo frameworks when repo is available."""
return framework.startswith("hanzo") and self._local.available
async def _use_registry(self) -> bool:
"""Check if the registry server is available (cached after first check)."""
if not self._registry_checked:
self._registry_checked = True
self._registry_available = await self._registry.check_available()
if self._registry_available:
logger.info("UI tool: registry server available at %s", self._registry._base_url)
return self._registry_available
async def _remote_client(self):
"""Return registry client if available, else GitHub client."""
if await self._use_registry():
return self._registry, "registry"
return self._github, "github"
@property
def description(self) -> str:
local_status = " (local repo detected)" if self._local.available else ""
@@ -113,8 +133,8 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
components = await self._local.list_components(fw)
source = "local"
else:
components = await self._github.list_components(fw)
source = "github"
client, source = await self._remote_client()
components = await client.list_components(fw)
if category:
components = [c for c in components if c.get("category") == category]
@@ -149,14 +169,15 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
"backend": "local",
}
except FileNotFoundError:
pass # Fall through to GitHub
pass # Fall through to remote
source = await self._github.fetch_component(comp_name, fw)
client, backend = await self._remote_client()
source = await client.fetch_component(comp_name, fw)
return {
"framework": self._fw_name(fw),
"component": comp_name,
"source": source,
"backend": "github",
"backend": backend,
}
@self.action("get_demo", "Get component demo/example")
@@ -184,12 +205,13 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
except FileNotFoundError:
pass
demo = await self._github.fetch_component_demo(comp_name, fw)
client, backend = await self._remote_client()
demo = await client.fetch_component_demo(comp_name, fw)
return {
"framework": self._fw_name(fw),
"component": comp_name,
"demo": demo,
"backend": "github",
"backend": backend,
}
@self.action("get_metadata", "Get component metadata")
@@ -213,7 +235,8 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
"metadata": metadata,
}
metadata = await self._github.fetch_component_metadata(comp_name, fw)
client, _ = await self._remote_client()
metadata = await client.fetch_component_metadata(comp_name, fw)
return {
"framework": self._fw_name(fw),
"component": comp_name,
@@ -232,8 +255,8 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
blocks = await self._local.list_blocks(fw)
source = "local"
else:
blocks = await self._github.list_blocks(fw)
source = "github"
client, source = await self._remote_client()
blocks = await client.list_blocks(fw)
if category:
blocks = [b for b in blocks if b.get("category") == category]
@@ -270,12 +293,13 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
except FileNotFoundError:
pass
content = await self._github.fetch_block(block_name, fw)
client, backend = await self._remote_client()
content = await client.fetch_block(block_name, fw)
return {
"framework": self._fw_name(fw),
"block": block_name,
"implementation": content,
"backend": "github",
"backend": backend,
}
@self.action("search", "Search components")
@@ -300,21 +324,12 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
"results": matches,
}
components = await self._github.list_components(fw)
q_lower = q.lower()
matches = [
c
for c in components
if q_lower in c.get("name", "").lower()
or q_lower in c.get("description", "").lower()
or q_lower in c.get("category", "").lower()
]
client, source = await self._remote_client()
matches = await client.search_components(q, fw)
return {
"framework": self._fw_name(fw),
"query": q,
"source": "github",
"source": source,
"results": matches,
}
@@ -336,11 +351,12 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
"structure": structure,
}
structure = await self._github.get_directory_structure(path, fw)
client, source = await self._remote_client()
structure = await client.get_directory_structure(path, fw)
return {
"framework": self._fw_name(fw),
"path": path or "/",
"source": "github",
"source": source,
"structure": structure,
}
@@ -401,10 +417,12 @@ Frameworks: hanzo (default), hanzo-native, hanzo-vue, hanzo-svelte,
@self.action("get_framework", "Show current and available frameworks")
async def get_framework(ctx: MCPContext) -> dict:
registry_up = await self._use_registry()
return {
"current": self._fw_name(_current_framework),
"framework": _current_framework,
"local_available": self._local.available,
"registry_available": registry_up,
"available": [
{
"key": key,
+9 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "hanzo-tools-ui"
version = "0.1.0"
description = "UI component registry tools for Hanzo AI (HIP-0300)"
version = "0.2.0"
description = "UI component registry tools and server for Hanzo AI (HIP-0300)"
readme = "README.md"
license = "MIT"
requires-python = ">=3.12"
@@ -36,12 +36,19 @@ dependencies = [
]
[project.optional-dependencies]
server = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.scripts]
hanzo-ui-registry = "hanzo_tools.ui.registry.__main__:main"
[project.entry-points."hanzo.tools"]
ui = "hanzo_tools.ui:TOOLS"
Generated
+80 -2
View File
@@ -1243,7 +1243,7 @@ wheels = [
[[package]]
name = "hanzo-tools-ui"
version = "0.1.0"
version = "0.2.0"
source = { editable = "pkg/hanzo-tools-ui" }
dependencies = [
{ name = "aiofiles" },
@@ -1258,18 +1258,24 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
server = [
{ name = "fastapi" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=24.0.0" },
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115.0" },
{ name = "hanzo-tools-core", specifier = ">=0.1.0" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "mcp", specifier = ">=1.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.34.0" },
]
provides-extras = ["dev"]
provides-extras = ["server", "dev"]
[[package]]
name = "hanzoai"
@@ -1394,6 +1400,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -3990,6 +4025,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "virtualenv"
version = "21.2.0"