From eeebfaa684af8cff708aee9338f20c1f829a6ef4 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 27 Jun 2026 19:28:21 +0200 Subject: [PATCH] Phase 3: industry & trade tools (character + corp) Add read-only industry tools (industry jobs, blueprints, mining ledger; corp jobs + blueprints) and trade tools (contracts, own market orders; corp contracts + orders). New charutil helper runs a fetcher over one or all characters, with a corp variant that resolves each character's corp id. account._run now reuses it. 27 tools total, 70 tests, coverage 91%. Closes #18 and #19; covers most of #20. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/familiar/account.py | 24 +---------- src/familiar/charutil.py | 77 +++++++++++++++++++++++++++++++++ src/familiar/industry.py | 79 ++++++++++++++++++++++++++++++++++ src/familiar/server.py | 6 ++- src/familiar/trade.py | 59 ++++++++++++++++++++++++++ tests/test_industry.py | 92 ++++++++++++++++++++++++++++++++++++++++ tests/test_trade.py | 78 ++++++++++++++++++++++++++++++++++ 7 files changed, 391 insertions(+), 24 deletions(-) create mode 100644 src/familiar/charutil.py create mode 100644 src/familiar/industry.py create mode 100644 src/familiar/trade.py create mode 100644 tests/test_industry.py create mode 100644 tests/test_trade.py diff --git a/src/familiar/account.py b/src/familiar/account.py index d5e8d0a..9ec9364 100644 --- a/src/familiar/account.py +++ b/src/familiar/account.py @@ -12,36 +12,14 @@ resolved (and refreshed) per character via :mod:`familiar.auth`. from __future__ import annotations -from collections.abc import Awaitable, Callable from typing import Any from fastmcp import FastMCP from . import auth +from .charutil import run_per_character as _run from .esi import client -# A per-character fetcher: given (access_token, character_id) -> payload dict. -Fetcher = Callable[[str, int], Awaitable[dict[str, Any]]] - - -async def _run(character: str | int | None, fetch: Fetcher) -> dict[str, Any]: - chars = auth.list_characters() - if not chars: - raise auth.NotAuthenticated("Not logged in. Run `familiar login` in a terminal first.") - names = {c["character_id"]: c["character_name"] for c in chars} - - if character is None: - targets = [c["character_id"] for c in chars] - else: - targets = [auth.resolve_character(character)] - - results = [] - for cid in targets: - token, _ = await auth.get_auth(cid) - payload = await fetch(token, cid) - results.append({"character_id": cid, "character_name": names.get(cid), **payload}) - return {"characters": results} - async def account_status() -> dict[str, Any]: """List which characters familiar is logged in as (no network call).""" diff --git a/src/familiar/charutil.py b/src/familiar/charutil.py new file mode 100644 index 0000000..a714e4f --- /dev/null +++ b/src/familiar/charutil.py @@ -0,0 +1,77 @@ +"""Shared helpers for running authenticated tools over one or many characters. + +Both character-level and corporation-level tools follow the same shape: pick the +target character(s), get a (refreshed) token per character, fetch, and return a +uniform ``{"characters": [...]}`` list. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from . import auth +from .esi import client + +# (access_token, character_id) -> payload +CharFetcher = Callable[[str, int], Awaitable[dict[str, Any]]] +# (access_token, corporation_id, character_id) -> payload +CorpFetcher = Callable[[str, int, int], Awaitable[dict[str, Any]]] + + +def summarize(items: list[Any], limit: int) -> dict[str, Any]: + """Uniform list payload: total count plus a capped sample.""" + return {"count": len(items), "sample": items[: max(1, limit)]} + + +def _targets(character: str | int | None) -> list[int]: + chars = auth.list_characters() + if not chars: + raise auth.NotAuthenticated("Not logged in. Run `familiar login` in a terminal first.") + if character is None: + return [c["character_id"] for c in chars] + return [auth.resolve_character(character)] + + +def _names() -> dict[int, str | None]: + return {c["character_id"]: c["character_name"] for c in auth.list_characters()} + + +async def run_per_character(character: str | int | None, fetch: CharFetcher) -> dict[str, Any]: + """Run ``fetch`` for one character or all, returning a uniform list.""" + names = _names() + results = [] + for cid in _targets(character): + token, _ = await auth.get_auth(cid) + payload = await fetch(token, cid) + results.append({"character_id": cid, "character_name": names.get(cid), **payload}) + return {"characters": results} + + +async def corporation_id(character_id: int) -> int: + """Look up a character's corporation id (public endpoint, no token needed).""" + info = await client().get(f"/characters/{character_id}/") + return info["corporation_id"] + + +async def run_per_corp(character: str | int | None, fetch: CorpFetcher) -> dict[str, Any]: + """Like :func:`run_per_character`, but also resolves each character's corp id. + + Corp endpoints need the character to hold the relevant corp role, or ESI + returns 403; that surfaces as an EsiError for the affected character. + """ + names = _names() + results = [] + for cid in _targets(character): + token, _ = await auth.get_auth(cid) + corp_id = await corporation_id(cid) + payload = await fetch(token, corp_id, cid) + results.append( + { + "character_id": cid, + "character_name": names.get(cid), + "corporation_id": corp_id, + **payload, + } + ) + return {"characters": results} diff --git a/src/familiar/industry.py b/src/familiar/industry.py new file mode 100644 index 0000000..305fdcd --- /dev/null +++ b/src/familiar/industry.py @@ -0,0 +1,79 @@ +"""Read-only industry tools: jobs, blueprints, mining (character and corp).""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from .charutil import run_per_character, run_per_corp, summarize +from .esi import client + + +async def get_industry_jobs(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Active/queued industry jobs for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + jobs = await client().get(f"/characters/{cid}/industry/jobs/", token=token) or [] + return summarize(jobs, limit) + + return await run_per_character(character, fetch) + + +async def get_blueprints(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Owned blueprints (ME/TE, runs) for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + bps = await client().get_all_pages(f"/characters/{cid}/blueprints/", token=token) + return summarize(bps, limit) + + return await run_per_character(character, fetch) + + +async def get_mining_ledger(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Personal mining ledger (recent mined ore by day) for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + ledger = await client().get_all_pages(f"/characters/{cid}/mining/", token=token) + return summarize(ledger, limit) + + return await run_per_character(character, fetch) + + +async def get_corp_industry_jobs( + character: str | int | None = None, limit: int = 20 +) -> dict[str, Any]: + """Corp industry jobs (needs the Factory Manager role) for one character's corp or all.""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + jobs = await client().get_all_pages(f"/corporations/{corp_id}/industry/jobs/", token=token) + return summarize(jobs, limit) + + return await run_per_corp(character, fetch) + + +async def get_corp_blueprints( + character: str | int | None = None, limit: int = 20 +) -> dict[str, Any]: + """Corp blueprints (needs an appropriate corp role) for one character's corp or all.""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + bps = await client().get_all_pages(f"/corporations/{corp_id}/blueprints/", token=token) + return summarize(bps, limit) + + return await run_per_corp(character, fetch) + + +TOOLS = [ + get_industry_jobs, + get_blueprints, + get_mining_ledger, + get_corp_industry_jobs, + get_corp_blueprints, +] + + +def register(mcp: FastMCP) -> None: + """Register the industry tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/src/familiar/server.py b/src/familiar/server.py index 291b822..33147ef 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -11,7 +11,7 @@ import datetime as _dt from fastmcp import FastMCP -from . import __version__, account, market, universe +from . import __version__, account, industry, market, trade, universe mcp: FastMCP = FastMCP( name="familiar", @@ -46,3 +46,7 @@ market.register(mcp) # Phase 2: authenticated, read-only account lookups (requires `familiar login`). account.register(mcp) + +# Phase 3: industry & trade (character + corp), read-only. +industry.register(mcp) +trade.register(mcp) diff --git a/src/familiar/trade.py b/src/familiar/trade.py new file mode 100644 index 0000000..8bf9628 --- /dev/null +++ b/src/familiar/trade.py @@ -0,0 +1,59 @@ +"""Read-only trade tools: contracts and personal market orders (character and corp).""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from .charutil import run_per_character, run_per_corp, summarize +from .esi import client + + +async def get_contracts(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Personal contracts for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + contracts = await client().get_all_pages(f"/characters/{cid}/contracts/", token=token) + return summarize(contracts, limit) + + return await run_per_character(character, fetch) + + +async def get_my_orders(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Open market orders placed by one character or all (your own orders, not a hub book).""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + orders = await client().get(f"/characters/{cid}/orders/", token=token) or [] + return summarize(orders, limit) + + return await run_per_character(character, fetch) + + +async def get_corp_contracts(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Corp contracts (needs a corp role) for one character's corp or all.""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + contracts = await client().get_all_pages(f"/corporations/{corp_id}/contracts/", token=token) + return summarize(contracts, limit) + + return await run_per_corp(character, fetch) + + +async def get_corp_orders(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Corp market orders (needs the Accountant/Trader role) for one character's corp or all.""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + orders = await client().get_all_pages(f"/corporations/{corp_id}/orders/", token=token) + return summarize(orders, limit) + + return await run_per_corp(character, fetch) + + +TOOLS = [get_contracts, get_my_orders, get_corp_contracts, get_corp_orders] + + +def register(mcp: FastMCP) -> None: + """Register the trade tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/tests/test_industry.py b/tests/test_industry.py new file mode 100644 index 0000000..334bd06 --- /dev/null +++ b/tests/test_industry.py @@ -0,0 +1,92 @@ +"""Industry tools (character + corp), auth & ESI mocked.""" + +import asyncio + +import pytest + +from conftest import route +from familiar import auth, industry +from familiar.server import mcp + +CHARS = [ + {"character_id": 99, "character_name": "Alpha", "scopes": []}, + {"character_id": 100, "character_name": "Beta", "scopes": []}, +] + + +@pytest.fixture +def fake_auth(monkeypatch): + monkeypatch.setattr(auth, "list_characters", lambda: list(CHARS)) + + async def get_auth(cid, **kw): + return f"token-{cid}", cid + + monkeypatch.setattr(auth, "get_auth", get_auth) + + def resolve(identifier): + for c in CHARS: + if ( + str(c["character_id"]) == str(identifier) + or (c["character_name"] or "").lower() == str(identifier).lower() + ): + return c["character_id"] + raise auth.NotAuthenticated("no such character") + + monkeypatch.setattr(auth, "resolve_character", resolve) + return CHARS + + +def test_industry_jobs_all_characters(mock_esi, fake_auth): + jobs = [{"job_id": 1, "activity_id": 1, "status": "active"}] + mock_esi(lambda r: route(r, {"/industry/jobs/": jobs})) + res = asyncio.run(industry.get_industry_jobs()) + assert len(res["characters"]) == 2 + assert res["characters"][0]["count"] == 1 + + +def test_blueprints_single_character(mock_esi, fake_auth): + bps = [{"item_id": 1}, {"item_id": 2}] + mock_esi(lambda r: route(r, {"/blueprints/": bps})) + res = asyncio.run(industry.get_blueprints(character="Alpha")) + assert len(res["characters"]) == 1 + assert res["characters"][0]["count"] == 2 + + +def test_mining_ledger(mock_esi, fake_auth): + ledger = [{"date": "2026-06-01", "type_id": 34, "quantity": 1000}] + mock_esi(lambda r: route(r, {"/mining/": ledger})) + res = asyncio.run(industry.get_mining_ledger(character=99)) + assert res["characters"][0]["sample"][0]["type_id"] == 34 + + +def test_corp_industry_jobs_resolves_corp(mock_esi, fake_auth): + fixtures = { + "/corporations/": [{"job_id": 7}], + "/characters/": {"corporation_id": 2000}, + } + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(industry.get_corp_industry_jobs(character=99)) + entry = res["characters"][0] + assert entry["corporation_id"] == 2000 + assert entry["count"] == 1 + + +def test_corp_blueprints(mock_esi, fake_auth): + fixtures = { + "/corporations/": [{"item_id": 5}, {"item_id": 6}], + "/characters/": {"corporation_id": 2000}, + } + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(industry.get_corp_blueprints(character="Beta")) + assert res["characters"][0]["count"] == 2 + + +def test_not_logged_in_raises(monkeypatch): + monkeypatch.setattr(auth, "list_characters", lambda: []) + with pytest.raises(auth.NotAuthenticated): + asyncio.run(industry.get_industry_jobs()) + + +def test_industry_tools_registered(): + tools = {t.name for t in asyncio.run(mcp.list_tools())} + assert {"get_industry_jobs", "get_blueprints", "get_corp_industry_jobs"} <= tools diff --git a/tests/test_trade.py b/tests/test_trade.py new file mode 100644 index 0000000..93d2dbe --- /dev/null +++ b/tests/test_trade.py @@ -0,0 +1,78 @@ +"""Trade tools (contracts + market orders, character + corp), auth & ESI mocked.""" + +import asyncio + +import pytest + +from conftest import route +from familiar import auth, trade +from familiar.server import mcp + +CHARS = [ + {"character_id": 99, "character_name": "Alpha", "scopes": []}, + {"character_id": 100, "character_name": "Beta", "scopes": []}, +] + + +@pytest.fixture +def fake_auth(monkeypatch): + monkeypatch.setattr(auth, "list_characters", lambda: list(CHARS)) + + async def get_auth(cid, **kw): + return f"token-{cid}", cid + + monkeypatch.setattr(auth, "get_auth", get_auth) + + def resolve(identifier): + for c in CHARS: + if ( + str(c["character_id"]) == str(identifier) + or (c["character_name"] or "").lower() == str(identifier).lower() + ): + return c["character_id"] + raise auth.NotAuthenticated("no such character") + + monkeypatch.setattr(auth, "resolve_character", resolve) + return CHARS + + +def test_contracts_all(mock_esi, fake_auth): + contracts = [{"contract_id": 1, "type": "item_exchange"}] + mock_esi(lambda r: route(r, {"/contracts/": contracts})) + res = asyncio.run(trade.get_contracts()) + assert len(res["characters"]) == 2 + assert res["characters"][0]["count"] == 1 + + +def test_my_orders_single(mock_esi, fake_auth): + orders = [{"order_id": 1, "price": 10.0}, {"order_id": 2, "price": 20.0}] + mock_esi(lambda r: route(r, {"/orders/": orders})) + res = asyncio.run(trade.get_my_orders(character="Alpha")) + assert res["characters"][0]["count"] == 2 + + +def test_corp_contracts_resolves_corp(mock_esi, fake_auth): + fixtures = { + "/corporations/": [{"contract_id": 9}], + "/characters/": {"corporation_id": 2000}, + } + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(trade.get_corp_contracts(character=99)) + entry = res["characters"][0] + assert entry["corporation_id"] == 2000 + assert entry["count"] == 1 + + +def test_corp_orders(mock_esi, fake_auth): + fixtures = { + "/corporations/": [{"order_id": 3}], + "/characters/": {"corporation_id": 2000}, + } + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(trade.get_corp_orders(character="Beta")) + assert res["characters"][0]["count"] == 1 + + +def test_trade_tools_registered(): + tools = {t.name for t in asyncio.run(mcp.list_tools())} + assert {"get_contracts", "get_my_orders", "get_corp_contracts", "get_corp_orders"} <= tools