Phase 1: public market + universe lookups via ESI
Add a thin async ESI client (httpx, descriptive User-Agent, uniform errors, mockable transport) plus name<->id resolution. New read-only tools: - universe: resolve_names, get_item, get_system, get_station, get_region, get_route - market: list_hubs, get_market_price, get_market_orders, compare_hubs Trade-hub IDs (Jita/Amarr/Dodixie/Rens/Hek) are hardcoded constants. Market aggregation (best buy/sell, spread, order book) lives in pure helpers. 30 unit tests with mocked ESI; coverage 99%. SDE bulk-load deferred to a later phase. Closes #2 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
11 files changed
+839
-1
No files matched your search
@@ -0,0 +1,115 @@
|
|||||||
|
"""Thin async client for the public EVE Swagger Interface (ESI).
|
||||||
|
|
||||||
|
Phase 1 only touches public endpoints — no auth, no tokens. The client is a
|
||||||
|
small wrapper around httpx with a descriptive User-Agent (ESI asks for one) and
|
||||||
|
uniform error handling. It is built so tests can inject an ``httpx.MockTransport``
|
||||||
|
instead of hitting the live API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
ESI_BASE = os.environ.get("FAMILIAR_ESI_BASE", "https://esi.evetech.net/latest")
|
||||||
|
DATASOURCE = os.environ.get("FAMILIAR_ESI_DATASOURCE", "tranquility")
|
||||||
|
USER_AGENT = (
|
||||||
|
f"familiar/{__version__} (+https://git.hiddenden.cafe/Hiddenden/familiar; read-only MCP)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EsiError(RuntimeError):
|
||||||
|
"""An ESI request failed. Carries the HTTP status when there was a response."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class EsiClient:
|
||||||
|
"""Async wrapper around one ESI base URL."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str = ESI_BASE,
|
||||||
|
*,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=base_url,
|
||||||
|
transport=transport,
|
||||||
|
timeout=30.0,
|
||||||
|
headers={
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
return await self._request("GET", path, params=params)
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
json: Any | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
return await self._request("POST", path, params=params, json=json)
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: Any | None = None,
|
||||||
|
) -> Any:
|
||||||
|
merged = {"datasource": DATASOURCE, **(params or {})}
|
||||||
|
try:
|
||||||
|
resp = await self._client.request(method, path, params=merged, json=json)
|
||||||
|
except httpx.HTTPError as exc: # network/timeout
|
||||||
|
raise EsiError(f"ESI request to {path} failed: {exc}") from exc
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
detail = _error_detail(resp)
|
||||||
|
raise EsiError(
|
||||||
|
f"ESI {method} {path} returned {resp.status_code}: {detail}",
|
||||||
|
status=resp.status_code,
|
||||||
|
)
|
||||||
|
if not resp.content:
|
||||||
|
return None
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
def _error_detail(resp: httpx.Response) -> str:
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return resp.text[:200]
|
||||||
|
if isinstance(body, dict) and "error" in body:
|
||||||
|
return str(body["error"])
|
||||||
|
return str(body)[:200]
|
||||||
|
|
||||||
|
|
||||||
|
_default: EsiClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def client() -> EsiClient:
|
||||||
|
"""Return the shared default client, creating it on first use."""
|
||||||
|
global _default
|
||||||
|
if _default is None:
|
||||||
|
_default = EsiClient()
|
||||||
|
return _default
|
||||||
|
|
||||||
|
|
||||||
|
def set_client(new: EsiClient | None) -> None:
|
||||||
|
"""Replace the shared client (used by tests to inject a mock transport)."""
|
||||||
|
global _default
|
||||||
|
_default = new
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""The major NPC trade hubs of New Eden, with stable ESI IDs.
|
||||||
|
|
||||||
|
These IDs are constant in the game data, so hardcoding them avoids a lookup
|
||||||
|
round-trip for the most common case ("price in Jita").
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Hub:
|
||||||
|
name: str
|
||||||
|
region_id: int
|
||||||
|
system_id: int
|
||||||
|
station_id: int
|
||||||
|
|
||||||
|
|
||||||
|
# Keyed by lowercase short name for easy lookup.
|
||||||
|
HUBS: dict[str, Hub] = {
|
||||||
|
"jita": Hub("Jita", region_id=10000002, system_id=30000142, station_id=60003760),
|
||||||
|
"amarr": Hub("Amarr", region_id=10000043, system_id=30002187, station_id=60008494),
|
||||||
|
"dodixie": Hub("Dodixie", region_id=10000032, system_id=30002659, station_id=60011866),
|
||||||
|
"rens": Hub("Rens", region_id=10000030, system_id=30002510, station_id=60004588),
|
||||||
|
"hek": Hub("Hek", region_id=10000042, system_id=30002053, station_id=60005686),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_HUB = "jita"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hub(name: str | None) -> Hub:
|
||||||
|
"""Look up a hub by short name (case-insensitive). Defaults to Jita.
|
||||||
|
|
||||||
|
Raises ValueError with the list of valid hubs if the name is unknown.
|
||||||
|
"""
|
||||||
|
key = (name or DEFAULT_HUB).strip().lower()
|
||||||
|
hub = HUBS.get(key)
|
||||||
|
if hub is None:
|
||||||
|
valid = ", ".join(h.name for h in HUBS.values())
|
||||||
|
raise ValueError(f"Unknown trade hub {name!r}. Known hubs: {valid}.")
|
||||||
|
return hub
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Name <-> ID resolution against ESI's ``/universe/ids/`` endpoint.
|
||||||
|
|
||||||
|
Shared by the universe and market tools so a user can say "Tritanium" or "Jita"
|
||||||
|
instead of a numeric ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .esi import client
|
||||||
|
|
||||||
|
# ESI /universe/ids/ groups results by category. We care about these.
|
||||||
|
_CATEGORY_KEYS = {
|
||||||
|
"inventory_types",
|
||||||
|
"systems",
|
||||||
|
"regions",
|
||||||
|
"constellations",
|
||||||
|
"stations",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_id(value: str | int) -> int | None:
|
||||||
|
"""Return value as an int if it already is a numeric id, else None."""
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
return int(text) if text.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_name(name: str, *categories: str) -> tuple[int, str]:
|
||||||
|
"""Resolve a single name to ``(id, category)``.
|
||||||
|
|
||||||
|
``categories`` restricts which result groups are accepted, in priority
|
||||||
|
order (e.g. ``"inventory_types"``). With none given, any known category is
|
||||||
|
accepted. Raises ``LookupError`` if nothing matches.
|
||||||
|
"""
|
||||||
|
wanted = list(categories) or list(_CATEGORY_KEYS)
|
||||||
|
data = await client().post("/universe/ids/", json=[name])
|
||||||
|
for key in wanted:
|
||||||
|
matches = (data or {}).get(key) or []
|
||||||
|
if matches:
|
||||||
|
return matches[0]["id"], key
|
||||||
|
raise LookupError(f"No {' / '.join(wanted)} in EVE named {name!r}.")
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_id(value: str | int, *categories: str) -> int:
|
||||||
|
"""Coerce a name-or-id into a numeric id, resolving names via ESI."""
|
||||||
|
direct = _as_id(value)
|
||||||
|
if direct is not None:
|
||||||
|
return direct
|
||||||
|
resolved, _ = await resolve_name(str(value), *categories)
|
||||||
|
return resolved
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Read-only market lookups via public ESI: prices, order book, hub spreads.
|
||||||
|
|
||||||
|
The tool functions are defined at module level (easy to import and test) and
|
||||||
|
wired onto the server in :func:`register`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from .esi import client
|
||||||
|
from .hubs import HUBS, Hub, resolve_hub
|
||||||
|
from .lookup import resolve_id
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_orders(orders: list[dict[str, Any]], station_id: int) -> dict[str, Any]:
|
||||||
|
"""Reduce a region's orders for one type to best buy/sell at one station.
|
||||||
|
|
||||||
|
Pure function (no I/O) so it is cheap to unit test.
|
||||||
|
"""
|
||||||
|
sells = [o for o in orders if not o["is_buy_order"] and o["location_id"] == station_id]
|
||||||
|
buys = [o for o in orders if o["is_buy_order"] and o["location_id"] == station_id]
|
||||||
|
|
||||||
|
best_sell = min((o["price"] for o in sells), default=None)
|
||||||
|
best_buy = max((o["price"] for o in buys), default=None)
|
||||||
|
spread = (
|
||||||
|
round(best_sell - best_buy, 2) if best_sell is not None and best_buy is not None else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"best_sell": best_sell,
|
||||||
|
"best_buy": best_buy,
|
||||||
|
"spread": spread,
|
||||||
|
"sell_orders": len(sells),
|
||||||
|
"buy_orders": len(buys),
|
||||||
|
"volume_on_sell": sum(o["volume_remain"] for o in sells),
|
||||||
|
"volume_on_buy": sum(o["volume_remain"] for o in buys),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def top_orders(
|
||||||
|
orders: list[dict[str, Any]], station_id: int, side: str, limit: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Best ``limit`` orders of one side at a station (sells ascending price)."""
|
||||||
|
want_buy = side == "buy"
|
||||||
|
matched = [
|
||||||
|
o for o in orders if o["is_buy_order"] == want_buy and o["location_id"] == station_id
|
||||||
|
]
|
||||||
|
matched.sort(key=lambda o: o["price"], reverse=want_buy)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"price": o["price"],
|
||||||
|
"volume_remain": o["volume_remain"],
|
||||||
|
"location_id": o["location_id"],
|
||||||
|
"range": o.get("range"),
|
||||||
|
}
|
||||||
|
for o in matched[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _region_orders(type_id: int, region_id: int) -> list[dict[str, Any]]:
|
||||||
|
data = await client().get(
|
||||||
|
f"/markets/{region_id}/orders/",
|
||||||
|
params={"type_id": type_id, "order_type": "all"},
|
||||||
|
)
|
||||||
|
return data or []
|
||||||
|
|
||||||
|
|
||||||
|
async def _price_at(type_id: int, hub: Hub) -> dict[str, Any]:
|
||||||
|
orders = await _region_orders(type_id, hub.region_id)
|
||||||
|
return {"type_id": type_id, "hub": hub.name, **summarize_orders(orders, hub.station_id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_hubs() -> list[dict[str, Any]]:
|
||||||
|
"""List the known NPC trade hubs and their region/system/station IDs."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": h.name,
|
||||||
|
"region_id": h.region_id,
|
||||||
|
"system_id": h.system_id,
|
||||||
|
"station_id": h.station_id,
|
||||||
|
}
|
||||||
|
for h in HUBS.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_market_price(item: str | int, hub: str = "Jita") -> dict[str, Any]:
|
||||||
|
"""Best buy/sell price and spread for an item at a trade hub.
|
||||||
|
|
||||||
|
``item`` is a name ("Tritanium") or type_id. ``hub`` is one of Jita, Amarr,
|
||||||
|
Dodixie, Rens, Hek (defaults to Jita).
|
||||||
|
"""
|
||||||
|
type_id = await resolve_id(item, "inventory_types")
|
||||||
|
return await _price_at(type_id, resolve_hub(hub))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_market_orders(
|
||||||
|
item: str | int,
|
||||||
|
hub: str = "Jita",
|
||||||
|
side: str = "sell",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Order book: the best ``limit`` sell or buy orders for an item at a hub."""
|
||||||
|
side = "buy" if side.lower().startswith("b") else "sell"
|
||||||
|
type_id = await resolve_id(item, "inventory_types")
|
||||||
|
h = resolve_hub(hub)
|
||||||
|
orders = await _region_orders(type_id, h.region_id)
|
||||||
|
return {
|
||||||
|
"type_id": type_id,
|
||||||
|
"hub": h.name,
|
||||||
|
"side": side,
|
||||||
|
"orders": top_orders(orders, h.station_id, side, max(1, limit)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def compare_hubs(item: str | int) -> dict[str, Any]:
|
||||||
|
"""Compare an item's best sell/buy price across every known trade hub."""
|
||||||
|
type_id = await resolve_id(item, "inventory_types")
|
||||||
|
per_hub = [await _price_at(type_id, h) for h in HUBS.values()]
|
||||||
|
with_sell = [p for p in per_hub if p["best_sell"] is not None]
|
||||||
|
cheapest = min(with_sell, key=lambda p: p["best_sell"], default=None)
|
||||||
|
return {
|
||||||
|
"type_id": type_id,
|
||||||
|
"hubs": per_hub,
|
||||||
|
"cheapest_sell_hub": cheapest["hub"] if cheapest else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = [list_hubs, get_market_price, get_market_orders, compare_hubs]
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
"""Register the market tools onto the given server."""
|
||||||
|
for fn in TOOLS:
|
||||||
|
mcp.tool(fn)
|
||||||
@@ -11,7 +11,7 @@ import datetime as _dt
|
|||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__, market, universe
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP(
|
mcp: FastMCP = FastMCP(
|
||||||
name="familiar",
|
name="familiar",
|
||||||
@@ -38,3 +38,8 @@ def ping() -> dict[str, str]:
|
|||||||
"version": __version__,
|
"version": __version__,
|
||||||
"time_utc": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
"time_utc": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 1: public market + universe lookups (no auth).
|
||||||
|
universe.register(mcp)
|
||||||
|
market.register(mcp)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Read-only universe lookups via public ESI: items, systems, stations, routes.
|
||||||
|
|
||||||
|
The tool functions are defined at module level (so they are easy to import and
|
||||||
|
test) and wired onto the server in :func:`register`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from .esi import client
|
||||||
|
from .lookup import resolve_id
|
||||||
|
|
||||||
|
ROUTE_FLAGS = {"shortest", "secure", "insecure"}
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_names(names: list[str]) -> dict[str, Any]:
|
||||||
|
"""Resolve EVE names (items, systems, regions, stations) to their IDs.
|
||||||
|
|
||||||
|
Pass a list of names; returns the raw ESI id/category groupings. Useful when
|
||||||
|
you need an ID for another lookup.
|
||||||
|
"""
|
||||||
|
return await client().post("/universe/ids/", json=names)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_item(item: str | int) -> dict[str, Any]:
|
||||||
|
"""Look up an item/type by name or type_id (volume, group, description)."""
|
||||||
|
type_id = await resolve_id(item, "inventory_types")
|
||||||
|
data = await client().get(f"/universe/types/{type_id}/")
|
||||||
|
return {
|
||||||
|
"type_id": data["type_id"],
|
||||||
|
"name": data["name"],
|
||||||
|
"group_id": data.get("group_id"),
|
||||||
|
"volume": data.get("volume"),
|
||||||
|
"packaged_volume": data.get("packaged_volume"),
|
||||||
|
"description": data.get("description"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_system(system: str | int) -> dict[str, Any]:
|
||||||
|
"""Look up a solar system by name or id: security status, region, counts."""
|
||||||
|
system_id = await resolve_id(system, "systems")
|
||||||
|
data = await client().get(f"/universe/systems/{system_id}/")
|
||||||
|
sec = data.get("security_status")
|
||||||
|
return {
|
||||||
|
"system_id": data["system_id"],
|
||||||
|
"name": data["name"],
|
||||||
|
"constellation_id": data.get("constellation_id"),
|
||||||
|
"security_status": round(sec, 2) if isinstance(sec, (int, float)) else sec,
|
||||||
|
"security_class": data.get("security_class"),
|
||||||
|
"planets": len(data.get("planets") or []),
|
||||||
|
"stargates": len(data.get("stargates") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_station(station: str | int) -> dict[str, Any]:
|
||||||
|
"""Look up a station by name or id: which system, type, and services."""
|
||||||
|
station_id = await resolve_id(station, "stations")
|
||||||
|
data = await client().get(f"/universe/stations/{station_id}/")
|
||||||
|
return {
|
||||||
|
"station_id": data["station_id"],
|
||||||
|
"name": data["name"],
|
||||||
|
"system_id": data.get("system_id"),
|
||||||
|
"type_id": data.get("type_id"),
|
||||||
|
"services": data.get("services"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_region(region: str | int) -> dict[str, Any]:
|
||||||
|
"""Look up a region by name or id."""
|
||||||
|
region_id = await resolve_id(region, "regions")
|
||||||
|
data = await client().get(f"/universe/regions/{region_id}/")
|
||||||
|
return {
|
||||||
|
"region_id": data["region_id"],
|
||||||
|
"name": data["name"],
|
||||||
|
"constellations": len(data.get("constellations") or []),
|
||||||
|
"description": data.get("description"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_route(
|
||||||
|
origin: str | int,
|
||||||
|
destination: str | int,
|
||||||
|
prefer: str = "shortest",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Number of jumps and the system path between two systems.
|
||||||
|
|
||||||
|
``prefer`` is one of "shortest", "secure" (high-sec where possible), or
|
||||||
|
"insecure". Names or system IDs are both accepted.
|
||||||
|
"""
|
||||||
|
flag = prefer if prefer in ROUTE_FLAGS else "shortest"
|
||||||
|
o = await resolve_id(origin, "systems")
|
||||||
|
d = await resolve_id(destination, "systems")
|
||||||
|
path = await client().get(f"/route/{o}/{d}/", params={"flag": flag})
|
||||||
|
systems = path or []
|
||||||
|
return {
|
||||||
|
"origin_id": o,
|
||||||
|
"destination_id": d,
|
||||||
|
"flag": flag,
|
||||||
|
"jumps": max(len(systems) - 1, 0),
|
||||||
|
"system_ids": systems,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = [resolve_names, get_item, get_system, get_station, get_region, get_route]
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
"""Register the universe tools onto the given server."""
|
||||||
|
for fn in TOOLS:
|
||||||
|
mcp.tool(fn)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Shared test helpers: a mock ESI client backed by httpx.MockTransport."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from familiar import esi
|
||||||
|
|
||||||
|
Handler = Callable[[httpx.Request], httpx.Response]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_esi():
|
||||||
|
"""Install a mock ESI client driven by a request handler.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
def test_x(mock_esi):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json={...})
|
||||||
|
mock_esi(handler)
|
||||||
|
...
|
||||||
|
|
||||||
|
The shared client is reset after the test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _make(handler: Handler) -> esi.EsiClient:
|
||||||
|
client = esi.EsiClient(transport=httpx.MockTransport(handler))
|
||||||
|
esi.set_client(client)
|
||||||
|
return client
|
||||||
|
|
||||||
|
yield _make
|
||||||
|
esi.set_client(None)
|
||||||
|
|
||||||
|
|
||||||
|
def route(request: httpx.Request, fixtures: dict[str, object]) -> httpx.Response:
|
||||||
|
"""Match a request against ``{substring_in_path: json_body}`` and respond.
|
||||||
|
|
||||||
|
The special key ``"/universe/ids/"`` is matched for the name-resolution POST.
|
||||||
|
A missing match returns 404 so tests fail loudly.
|
||||||
|
"""
|
||||||
|
path = request.url.path
|
||||||
|
for needle, body in fixtures.items():
|
||||||
|
if needle in path:
|
||||||
|
return httpx.Response(200, json=body)
|
||||||
|
return httpx.Response(404, json={"error": f"no fixture for {path}"})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""ESI client: error handling, empty bodies, datasource param."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from familiar import esi
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_raises_esi_error_on_http_error(mock_esi):
|
||||||
|
mock_esi(lambda req: httpx.Response(404, json={"error": "not found"}))
|
||||||
|
with pytest.raises(esi.EsiError) as exc:
|
||||||
|
asyncio.run(esi.client().get("/universe/types/999999/"))
|
||||||
|
assert exc.value.status == 404
|
||||||
|
assert "404" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_raises_on_network_error(mock_esi):
|
||||||
|
def boom(req):
|
||||||
|
raise httpx.ConnectError("boom")
|
||||||
|
|
||||||
|
mock_esi(boom)
|
||||||
|
with pytest.raises(esi.EsiError):
|
||||||
|
asyncio.run(esi.client().get("/anything/"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_body_returns_none(mock_esi):
|
||||||
|
mock_esi(lambda req: httpx.Response(200, content=b""))
|
||||||
|
assert asyncio.run(esi.client().get("/x/")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_datasource_param_is_attached(mock_esi):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
seen["datasource"] = req.url.params.get("datasource")
|
||||||
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|
||||||
|
mock_esi(handler)
|
||||||
|
asyncio.run(esi.client().get("/x/"))
|
||||||
|
assert seen["datasource"] == "tranquility"
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_detail_falls_back_to_text(mock_esi):
|
||||||
|
mock_esi(lambda req: httpx.Response(500, content=b"upstream exploded"))
|
||||||
|
with pytest.raises(esi.EsiError) as exc:
|
||||||
|
asyncio.run(esi.client().get("/x/"))
|
||||||
|
assert "upstream exploded" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_client_is_singleton():
|
||||||
|
esi.set_client(None)
|
||||||
|
first = esi.client()
|
||||||
|
assert esi.client() is first
|
||||||
|
esi.set_client(None)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Name <-> id resolution."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from familiar import lookup
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_id_passes_through_numeric():
|
||||||
|
assert asyncio.run(lookup.resolve_id(34)) == 34
|
||||||
|
assert asyncio.run(lookup.resolve_id("34")) == 34
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_name_picks_requested_category(mock_esi):
|
||||||
|
body = {
|
||||||
|
"inventory_types": [{"id": 34, "name": "Tritanium"}],
|
||||||
|
"systems": [{"id": 30000142, "name": "Jita"}],
|
||||||
|
}
|
||||||
|
mock_esi(lambda req: httpx.Response(200, json=body))
|
||||||
|
assert asyncio.run(lookup.resolve_name("Tritanium", "inventory_types")) == (
|
||||||
|
34,
|
||||||
|
"inventory_types",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_id_resolves_name(mock_esi):
|
||||||
|
body = {"inventory_types": [{"id": 34, "name": "Tritanium"}]}
|
||||||
|
mock_esi(lambda req: httpx.Response(200, json=body))
|
||||||
|
assert asyncio.run(lookup.resolve_id("Tritanium", "inventory_types")) == 34
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_name_raises_when_absent(mock_esi):
|
||||||
|
mock_esi(lambda req: httpx.Response(200, json={}))
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
asyncio.run(lookup.resolve_name("Nope", "inventory_types"))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Market lookup tools and pure aggregation helpers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from familiar import hubs, market
|
||||||
|
from familiar.server import mcp
|
||||||
|
from conftest import route
|
||||||
|
|
||||||
|
JITA_STATION = 60003760
|
||||||
|
JITA_REGION = 10000002
|
||||||
|
|
||||||
|
|
||||||
|
def _orders():
|
||||||
|
return [
|
||||||
|
# Sells at Jita
|
||||||
|
{
|
||||||
|
"is_buy_order": False,
|
||||||
|
"location_id": JITA_STATION,
|
||||||
|
"price": 5.10,
|
||||||
|
"volume_remain": 100,
|
||||||
|
"range": "station",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_buy_order": False,
|
||||||
|
"location_id": JITA_STATION,
|
||||||
|
"price": 5.05,
|
||||||
|
"volume_remain": 200,
|
||||||
|
"range": "station",
|
||||||
|
},
|
||||||
|
# Buy at Jita
|
||||||
|
{
|
||||||
|
"is_buy_order": True,
|
||||||
|
"location_id": JITA_STATION,
|
||||||
|
"price": 4.90,
|
||||||
|
"volume_remain": 300,
|
||||||
|
"range": "region",
|
||||||
|
},
|
||||||
|
# Order at a different station should be ignored
|
||||||
|
{
|
||||||
|
"is_buy_order": False,
|
||||||
|
"location_id": 999,
|
||||||
|
"price": 1.0,
|
||||||
|
"volume_remain": 1,
|
||||||
|
"range": "station",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_orders_picks_best_and_ignores_other_stations():
|
||||||
|
s = market.summarize_orders(_orders(), JITA_STATION)
|
||||||
|
assert s["best_sell"] == 5.05
|
||||||
|
assert s["best_buy"] == 4.90
|
||||||
|
assert s["spread"] == 0.15
|
||||||
|
assert s["sell_orders"] == 2
|
||||||
|
assert s["volume_on_sell"] == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_orders_handles_empty():
|
||||||
|
s = market.summarize_orders([], JITA_STATION)
|
||||||
|
assert s["best_sell"] is None
|
||||||
|
assert s["spread"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_orders_sorts_per_side():
|
||||||
|
sells = market.top_orders(_orders(), JITA_STATION, "sell", 10)
|
||||||
|
assert [o["price"] for o in sells] == [5.05, 5.10]
|
||||||
|
buys = market.top_orders(_orders(), JITA_STATION, "buy", 10)
|
||||||
|
assert [o["price"] for o in buys] == [4.90]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_market_price(mock_esi):
|
||||||
|
fixtures = {
|
||||||
|
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
|
||||||
|
f"/markets/{JITA_REGION}/orders/": _orders(),
|
||||||
|
}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(market.get_market_price("Tritanium", hub="Jita"))
|
||||||
|
assert result["type_id"] == 34
|
||||||
|
assert result["hub"] == "Jita"
|
||||||
|
assert result["best_sell"] == 5.05
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_market_orders_side_parsing(mock_esi):
|
||||||
|
fixtures = {f"/markets/{JITA_REGION}/orders/": _orders()}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(market.get_market_orders(34, hub="Jita", side="buy", limit=5))
|
||||||
|
assert result["side"] == "buy"
|
||||||
|
assert result["orders"][0]["price"] == 4.90
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_hubs_across_all_regions(mock_esi):
|
||||||
|
# Every region returns the same order book; cheapest sell is the same price
|
||||||
|
# everywhere, so the first hub (Jita) wins the tie.
|
||||||
|
def handler(req):
|
||||||
|
return route(
|
||||||
|
req,
|
||||||
|
{
|
||||||
|
"/orders/": _orders(),
|
||||||
|
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_esi(handler)
|
||||||
|
result = asyncio.run(market.compare_hubs("Tritanium"))
|
||||||
|
assert len(result["hubs"]) == len(hubs.HUBS)
|
||||||
|
assert result["cheapest_sell_hub"] == "Jita"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_hubs():
|
||||||
|
result = asyncio.run(market.list_hubs())
|
||||||
|
names = {h["name"] for h in result}
|
||||||
|
assert {"Jita", "Amarr", "Dodixie", "Rens", "Hek"} == names
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_hub_unknown_raises():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
hubs.resolve_hub("Atlantis")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_hub_defaults_to_jita():
|
||||||
|
assert hubs.resolve_hub(None).name == "Jita"
|
||||||
|
|
||||||
|
|
||||||
|
def test_market_tools_are_registered():
|
||||||
|
tools = {t.name for t in asyncio.run(mcp.list_tools())}
|
||||||
|
assert {"list_hubs", "get_market_price", "get_market_orders", "compare_hubs"} <= tools
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Universe lookup tools."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from familiar import universe
|
||||||
|
from familiar.server import mcp
|
||||||
|
from conftest import route
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_item_resolves_name_then_fetches_type(mock_esi):
|
||||||
|
fixtures = {
|
||||||
|
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
|
||||||
|
"/universe/types/": {
|
||||||
|
"type_id": 34,
|
||||||
|
"name": "Tritanium",
|
||||||
|
"group_id": 18,
|
||||||
|
"volume": 0.01,
|
||||||
|
"packaged_volume": 0.01,
|
||||||
|
"description": "The most common ore type.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(universe.get_item("Tritanium"))
|
||||||
|
assert result["type_id"] == 34
|
||||||
|
assert result["name"] == "Tritanium"
|
||||||
|
assert result["volume"] == 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_system_rounds_security_and_counts(mock_esi):
|
||||||
|
fixtures = {
|
||||||
|
"/universe/systems/": {
|
||||||
|
"system_id": 30000142,
|
||||||
|
"name": "Jita",
|
||||||
|
"constellation_id": 20000020,
|
||||||
|
"security_status": 0.945913,
|
||||||
|
"security_class": "D",
|
||||||
|
"planets": [{}, {}, {}],
|
||||||
|
"stargates": [1, 2],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(universe.get_system(30000142))
|
||||||
|
assert result["security_status"] == 0.95
|
||||||
|
assert result["planets"] == 3
|
||||||
|
assert result["stargates"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_station(mock_esi):
|
||||||
|
fixtures = {
|
||||||
|
"/universe/stations/": {
|
||||||
|
"station_id": 60003760,
|
||||||
|
"name": "Jita IV - Moon 4 - Caldari Navy Assembly Plant",
|
||||||
|
"system_id": 30000142,
|
||||||
|
"type_id": 52678,
|
||||||
|
"services": ["market", "repair-facilities"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(universe.get_station(60003760))
|
||||||
|
assert result["system_id"] == 30000142
|
||||||
|
assert "market" in result["services"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_region(mock_esi):
|
||||||
|
fixtures = {
|
||||||
|
"/universe/regions/": {
|
||||||
|
"region_id": 10000002,
|
||||||
|
"name": "The Forge",
|
||||||
|
"constellations": [1, 2, 3, 4],
|
||||||
|
"description": "A region.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(universe.get_region(10000002))
|
||||||
|
assert result["name"] == "The Forge"
|
||||||
|
assert result["constellations"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_route_counts_jumps(mock_esi):
|
||||||
|
fixtures = {"/route/": [30000142, 30000144, 30002187]}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(universe.get_route(30000142, 30002187, prefer="secure"))
|
||||||
|
assert result["jumps"] == 2
|
||||||
|
assert result["flag"] == "secure"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_route_rejects_unknown_flag(mock_esi):
|
||||||
|
fixtures = {"/route/": [30000142]}
|
||||||
|
mock_esi(lambda req: route(req, fixtures))
|
||||||
|
result = asyncio.run(universe.get_route(30000142, 30000142, prefer="banana"))
|
||||||
|
assert result["flag"] == "shortest"
|
||||||
|
assert result["jumps"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_names_passthrough(mock_esi):
|
||||||
|
body = {"systems": [{"id": 30000142, "name": "Jita"}]}
|
||||||
|
mock_esi(lambda req: httpx.Response(200, json=body))
|
||||||
|
result = asyncio.run(universe.resolve_names(["Jita"]))
|
||||||
|
assert result["systems"][0]["id"] == 30000142
|
||||||
|
|
||||||
|
|
||||||
|
def test_universe_tools_are_registered():
|
||||||
|
tools = {t.name for t in asyncio.run(mcp.list_tools())}
|
||||||
|
assert {"get_item", "get_system", "get_station", "get_region", "get_route"} <= tools
|
||||||
Reference in new issue
Block a user