Dev #61

Merged
Latte merged 4 commits from dev into main 2026-06-27 19:36:45 +00:00
5 changed files with 462 additions and 4 deletions
Showing only changes of commit a611b6f3f1 - Show all commits

No files matched your search

+11
View File
@@ -33,6 +33,17 @@ uv run familiar status # list logged-in characters
Re-run `login` for each character (switch EVE accounts in the browser as needed); they all live in one encrypted store. Changing the requested scopes requires logging in again. Re-run `login` for each character (switch EVE accounts in the browser as needed); they all live in one encrypted store. Changing the requested scopes requires logging in again.
### Offline SDE (optional)
For network-free item / system / station lookups, install a local copy of EVE's Static Data Export (the Fuzzwork SQLite conversion):
```bash
uv run familiar sde download # download + decompress the SDE (a few hundred MB)
uv run familiar sde status # show whether it's installed and where
```
Once present, `get_item` / `get_system` / `get_station` serve from it without hitting ESI; familiar falls back to ESI whenever the SDE is absent or doesn't know the id.
### Connect to Claude ### Connect to Claude
Claude Code: Claude Code:
+41 -2
View File
@@ -11,13 +11,27 @@ hand in a terminal:
familiar writes # show whether write actions are enabled familiar writes # show whether write actions are enabled
familiar writes enable # allow write actions (mail, waypoints, ...) familiar writes enable # allow write actions (mail, waypoints, ...)
familiar writes disable familiar writes disable
familiar sde download [url] # download + install the local SDE (offline lookups)
familiar sde status # is the local SDE available, and where
familiar sde path # print the expected SDE path
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import sys import sys
from . import auth, writes from . import auth, sde, writes
def _human_size(num: int) -> str:
"""Render a byte count as a short human-readable string."""
size = float(num)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if size < 1024.0 or unit == "TiB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} {unit}"
size /= 1024.0
return f"{num} B"
def _login() -> int: def _login() -> int:
@@ -64,9 +78,34 @@ def _writes() -> int:
return 0 return 0
def _sde() -> int:
sub = sys.argv[2] if len(sys.argv) > 2 else "status"
if sub == "download":
url = sys.argv[3] if len(sys.argv) > 3 else None
result = asyncio.run(sde.download_sde(url))
print(f"Wrote {result['path']} ({_human_size(result['bytes'])}).")
print("Done.")
elif sub == "status":
status = sde.sde_status()
state = "available" if status["available"] else "not available"
print(f"SDE is {state} at {status['path']}.")
elif sub == "path":
print(sde.sde_path())
else:
print("Usage: familiar sde [download [url]|status|path]")
return 1
return 0
def main() -> None: def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else None command = sys.argv[1] if len(sys.argv) > 1 else None
handlers = {"login": _login, "status": _status, "logout": _logout, "writes": _writes} handlers = {
"login": _login,
"status": _status,
"logout": _logout,
"writes": _writes,
"sde": _sde,
}
if command in handlers: if command in handlers:
raise SystemExit(handlers[command]()) raise SystemExit(handlers[command]())
if command in {"-h", "--help", "help"}: if command in {"-h", "--help", "help"}:
+134 -1
View File
@@ -18,21 +18,45 @@ Minimal expected schema (only what familiar reads)::
groupID INTEGER, groupID INTEGER,
volume REAL volume REAL
); );
CREATE TABLE mapSolarSystems (
solarSystemID INTEGER PRIMARY KEY,
solarSystemName TEXT,
regionID INTEGER,
constellationID INTEGER,
security REAL
);
CREATE TABLE staStations (
stationID INTEGER PRIMARY KEY,
stationName TEXT,
solarSystemID INTEGER,
corporationID INTEGER
);
Anything beyond these columns is ignored, so a full official SDE conversion works Anything beyond these columns is ignored, so a full official SDE conversion works
as well as a hand-built subset. as well as a hand-built subset. The Fuzzwork SQLite conversion of the SDE
(https://www.fuzzwork.co.uk/dump/sqlite-latest.sqlite.bz2) already ships all of
these tables, so :func:`download_sde` of that dump satisfies the schema above.
""" """
from __future__ import annotations from __future__ import annotations
import bz2
import os import os
import sqlite3 import sqlite3
from pathlib import Path
from typing import Any from typing import Any
import httpx
from fastmcp import FastMCP from fastmcp import FastMCP
from .config import data_dir from .config import data_dir
# Fuzzwork's SQLite conversion of the SDE ships invTypes / mapSolarSystems /
# staStations with the columns familiar reads. Env-overridable for mirrors.
SDE_URL = os.environ.get(
"FAMILIAR_SDE_URL", "https://www.fuzzwork.co.uk/dump/sqlite-latest.sqlite.bz2"
)
def sde_path() -> str: def sde_path() -> str:
"""Filesystem path to the SQLite SDE, env-overridable via ``FAMILIAR_SDE_PATH``.""" """Filesystem path to the SQLite SDE, env-overridable via ``FAMILIAR_SDE_PATH``."""
@@ -97,6 +121,48 @@ def available() -> bool:
return True return True
async def download_sde(
url: str | None = None,
dest: Path | None = None,
*,
transport: httpx.AsyncBaseTransport | None = None,
) -> dict[str, Any]:
"""Download and install the SQLite SDE, returning ``{"path", "bytes"}``.
Streams ``url`` (default :data:`SDE_URL`), bz2-decompresses it incrementally
so arbitrarily large dumps work, and writes the plain SQLite database to
``dest`` (default :func:`sde_path`). The Fuzzwork dump (a bz2-compressed
SQLite file) satisfies the schema familiar reads.
``transport`` mirrors :class:`familiar.esi.EsiClient`: tests inject an
``httpx.MockTransport`` to serve a fixture without touching the network.
The cached connection is reset on success so :func:`available` and the
lookups immediately see the freshly written file.
"""
source = url or SDE_URL
target = Path(dest) if dest is not None else Path(sde_path())
target.parent.mkdir(parents=True, exist_ok=True)
decompressor = bz2.BZ2Decompressor()
written = 0
async with httpx.AsyncClient(transport=transport, timeout=None) as http:
async with http.stream("GET", source) as resp:
resp.raise_for_status()
with open(target, "wb") as out:
async for chunk in resp.aiter_bytes():
if not chunk:
continue
plain = decompressor.decompress(chunk)
if plain:
out.write(plain)
written += len(plain)
# Point the cached connection at the new file.
set_connection(None)
return {"path": str(target), "bytes": written}
def _row_to_type(row: sqlite3.Row | None) -> dict[str, Any] | None: def _row_to_type(row: sqlite3.Row | None) -> dict[str, Any] | None:
"""Normalize an ``invTypes`` row to the familiar type dict.""" """Normalize an ``invTypes`` row to the familiar type dict."""
if row is None: if row is None:
@@ -146,6 +212,73 @@ def resolve_type_name(name: str) -> int | None:
return found["type_id"] if found is not None else None return found["type_id"] if found is not None else None
def _row_to_system(row: sqlite3.Row | None) -> dict[str, Any] | None:
"""Normalize a ``mapSolarSystems`` row to the familiar system dict."""
if row is None:
return None
return {
"system_id": row["solarSystemID"],
"name": row["solarSystemName"],
"region_id": row["regionID"],
"constellation_id": row["constellationID"],
"security_status": row["security"],
}
def system_by_id(system_id: int) -> dict[str, Any] | None:
"""Look up a solar system by id offline, or ``None`` if absent/unavailable."""
conn = connect()
if conn is None:
return None
try:
cur = conn.execute(
"SELECT solarSystemID, solarSystemName, regionID, constellationID, security "
"FROM mapSolarSystems WHERE solarSystemID = ?",
(system_id,),
)
return _row_to_system(cur.fetchone())
except sqlite3.Error:
return None
def system_by_name(name: str) -> dict[str, Any] | None:
"""Look up a solar system by exact (case-insensitive) name offline, or ``None``."""
conn = connect()
if conn is None:
return None
try:
cur = conn.execute(
"SELECT solarSystemID, solarSystemName, regionID, constellationID, security "
"FROM mapSolarSystems WHERE solarSystemName = ? COLLATE NOCASE",
(name,),
)
return _row_to_system(cur.fetchone())
except sqlite3.Error:
return None
def station_by_id(station_id: int) -> dict[str, Any] | None:
"""Look up a station by id offline, or ``None`` if absent/unavailable."""
conn = connect()
if conn is None:
return None
try:
cur = conn.execute(
"SELECT stationID, stationName, solarSystemID FROM staStations WHERE stationID = ?",
(station_id,),
)
row = cur.fetchone()
except sqlite3.Error:
return None
if row is None:
return None
return {
"station_id": row["stationID"],
"name": row["stationName"],
"system_id": row["solarSystemID"],
}
def sde_status() -> dict[str, Any]: def sde_status() -> dict[str, Any]:
"""Report whether the local SDE is available and where it is expected. """Report whether the local SDE is available and where it is expected.
+36 -1
View File
@@ -12,7 +12,7 @@ from fastmcp import FastMCP
from . import sde from . import sde
from .esi import client from .esi import client
from .lookup import resolve_id from .lookup import _as_id, resolve_id
ROUTE_FLAGS = {"shortest", "secure", "insecure"} ROUTE_FLAGS = {"shortest", "secure", "insecure"}
@@ -56,6 +56,26 @@ async def get_item(item: str | int) -> dict[str, Any]:
async def get_system(system: str | int) -> dict[str, Any]: async def get_system(system: str | int) -> dict[str, Any]:
"""Look up a solar system by name or id: security status, region, counts.""" """Look up a solar system by name or id: security status, region, counts."""
# Serve from the offline SDE when present — no network call. The SDE lacks
# security_class and planet/stargate counts, so those come back as
# None / 0; ESI is the source of record when the SDE is absent or doesn't
# know the system.
if sde.available():
direct = _as_id(system)
offline = (
sde.system_by_id(direct) if direct is not None else sde.system_by_name(str(system))
)
if offline is not None:
sec = offline["security_status"]
return {
"system_id": offline["system_id"],
"name": offline["name"],
"constellation_id": offline["constellation_id"],
"security_status": round(sec, 2) if isinstance(sec, (int, float)) else sec,
"security_class": None,
"planets": 0,
"stargates": 0,
}
system_id = await resolve_id(system, "systems") system_id = await resolve_id(system, "systems")
data = await client().get(f"/universe/systems/{system_id}/") data = await client().get(f"/universe/systems/{system_id}/")
sec = data.get("security_status") sec = data.get("security_status")
@@ -72,6 +92,21 @@ async def get_system(system: str | int) -> dict[str, Any]:
async def get_station(station: str | int) -> dict[str, Any]: async def get_station(station: str | int) -> dict[str, Any]:
"""Look up a station by name or id: which system, type, and services.""" """Look up a station by name or id: which system, type, and services."""
# Serve from the offline SDE when the caller passed a numeric id and the
# store knows it — no network call. The SDE lacks type_id and services, so
# those come back as None; ESI is the source of record otherwise.
if sde.available():
direct = _as_id(station)
if direct is not None:
offline = sde.station_by_id(direct)
if offline is not None:
return {
"station_id": offline["station_id"],
"name": offline["name"],
"system_id": offline["system_id"],
"type_id": None,
"services": None,
}
station_id = await resolve_id(station, "stations") station_id = await resolve_id(station, "stations")
data = await client().get(f"/universe/stations/{station_id}/") data = await client().get(f"/universe/stations/{station_id}/")
return { return {
+240
View File
@@ -1,7 +1,9 @@
"""Local SDE store: offline lookups and SDE-preferred / ESI-fallback wiring.""" """Local SDE store: offline lookups and SDE-preferred / ESI-fallback wiring."""
import asyncio import asyncio
import bz2
import sqlite3 import sqlite3
from pathlib import Path
import httpx import httpx
import pytest import pytest
@@ -21,6 +23,58 @@ def _make_sde(rows=((34, "Tritanium", 18, 0.01), (35, "Pyerite", 18, 0.01))):
return conn return conn
def _build_sde_file(path: Path) -> None:
"""Write a tiny on-disk SDE with invTypes, mapSolarSystems and staStations."""
conn = sqlite3.connect(str(path))
conn.executescript(
"CREATE TABLE invTypes ("
"typeID INTEGER PRIMARY KEY, typeName TEXT, groupID INTEGER, volume REAL);"
"CREATE TABLE mapSolarSystems ("
"solarSystemID INTEGER PRIMARY KEY, solarSystemName TEXT, regionID INTEGER, "
"constellationID INTEGER, security REAL);"
"CREATE TABLE staStations ("
"stationID INTEGER PRIMARY KEY, stationName TEXT, solarSystemID INTEGER, "
"corporationID INTEGER);"
)
conn.execute("INSERT INTO invTypes VALUES (?, ?, ?, ?)", (34, "Tritanium", 18, 0.01))
conn.execute(
"INSERT INTO mapSolarSystems VALUES (?, ?, ?, ?, ?)",
(30000142, "Jita", 10000002, 20000020, 0.945913),
)
conn.execute(
"INSERT INTO staStations VALUES (?, ?, ?, ?)",
(60003760, "Jita IV - Moon 4 - Caldari Navy Assembly Plant", 30000142, 1000035),
)
conn.commit()
conn.close()
def _make_sde_full():
"""In-memory SQLite with invTypes, mapSolarSystems and staStations populated."""
conn = sqlite3.connect(":memory:")
conn.executescript(
"CREATE TABLE invTypes ("
"typeID INTEGER PRIMARY KEY, typeName TEXT, groupID INTEGER, volume REAL);"
"CREATE TABLE mapSolarSystems ("
"solarSystemID INTEGER PRIMARY KEY, solarSystemName TEXT, regionID INTEGER, "
"constellationID INTEGER, security REAL);"
"CREATE TABLE staStations ("
"stationID INTEGER PRIMARY KEY, stationName TEXT, solarSystemID INTEGER, "
"corporationID INTEGER);"
)
conn.execute("INSERT INTO invTypes VALUES (?, ?, ?, ?)", (34, "Tritanium", 18, 0.01))
conn.execute(
"INSERT INTO mapSolarSystems VALUES (?, ?, ?, ?, ?)",
(30000142, "Jita", 10000002, 20000020, 0.945913),
)
conn.execute(
"INSERT INTO staStations VALUES (?, ?, ?, ?)",
(60003760, "Jita IV - Moon 4 - CNAP", 30000142, 1000035),
)
conn.commit()
return conn
@pytest.fixture @pytest.fixture
def use_sde(): def use_sde():
"""Inject an in-memory SDE and reset the cached connection afterwards.""" """Inject an in-memory SDE and reset the cached connection afterwards."""
@@ -31,6 +85,16 @@ def use_sde():
conn.close() conn.close()
@pytest.fixture
def use_sde_full():
"""Inject an in-memory SDE with systems + stations; reset afterwards."""
conn = _make_sde_full()
sde.set_connection(conn)
yield conn
sde.set_connection(None)
conn.close()
def test_available_true_with_table(use_sde): def test_available_true_with_table(use_sde):
assert sde.available() is True assert sde.available() is True
@@ -123,3 +187,179 @@ def test_get_item_falls_back_to_esi_when_sde_absent(mock_esi):
result = asyncio.run(universe.get_item("Tritanium")) result = asyncio.run(universe.get_item("Tritanium"))
assert result["type_id"] == 34 assert result["type_id"] == 34
assert result["description"] == "The most common ore type." assert result["description"] == "The most common ore type."
# --- system / station offline lookups -------------------------------------
def test_system_by_id(use_sde_full):
sysrow = sde.system_by_id(30000142)
assert sysrow == {
"system_id": 30000142,
"name": "Jita",
"region_id": 10000002,
"constellation_id": 20000020,
"security_status": 0.945913,
}
def test_system_by_name_case_insensitive(use_sde_full):
assert sde.system_by_name("jita")["system_id"] == 30000142
assert sde.system_by_name("JITA")["system_id"] == 30000142
def test_system_lookups_missing(use_sde_full):
assert sde.system_by_id(999999) is None
assert sde.system_by_name("Nowhere") is None
def test_station_by_id(use_sde_full):
station = sde.station_by_id(60003760)
assert station == {
"station_id": 60003760,
"name": "Jita IV - Moon 4 - CNAP",
"system_id": 30000142,
}
def test_station_by_id_missing(use_sde_full):
assert sde.station_by_id(999999) is None
def test_system_station_lookups_degrade_without_table(use_sde):
# `use_sde` has only invTypes — the system/station tables are absent.
assert sde.system_by_id(30000142) is None
assert sde.system_by_name("Jita") is None
assert sde.station_by_id(60003760) is None
def test_system_station_lookups_degrade_without_connection():
sde.set_connection(None)
assert sde.system_by_id(30000142) is None
assert sde.system_by_name("Jita") is None
assert sde.station_by_id(60003760) is None
# --- universe SDE fast-path / ESI fallback --------------------------------
def test_get_system_uses_sde_without_esi(use_sde_full, mock_esi):
def boom(request): # any ESI call here is a bug
raise AssertionError(f"ESI was called: {request.url}")
mock_esi(boom)
result = asyncio.run(universe.get_system("Jita"))
assert result["system_id"] == 30000142
assert result["name"] == "Jita"
assert result["constellation_id"] == 20000020
assert result["security_status"] == 0.95 # rounded to 2 dp
assert result["security_class"] is None
assert result["planets"] == 0
assert result["stargates"] == 0
def test_get_system_falls_back_to_esi_when_sde_absent(mock_esi):
sde.set_connection(None)
fixtures = {
"/universe/ids/": {"systems": [{"id": 30000142, "name": "Jita"}]},
"/universe/systems/": {
"system_id": 30000142,
"name": "Jita",
"constellation_id": 20000020,
"security_status": 0.9459,
"security_class": "B",
"planets": [{"planet_id": 1}],
"stargates": [1, 2, 3],
},
}
def route(request):
for needle, body in fixtures.items():
if needle in request.url.path:
return httpx.Response(200, json=body)
return httpx.Response(404, json={"error": "no fixture"})
mock_esi(route)
result = asyncio.run(universe.get_system("Jita"))
assert result["system_id"] == 30000142
assert result["security_status"] == 0.95
assert result["security_class"] == "B"
assert result["planets"] == 1
assert result["stargates"] == 3
def test_get_station_uses_sde_for_numeric_id_without_esi(use_sde_full, mock_esi):
def boom(request):
raise AssertionError(f"ESI was called: {request.url}")
mock_esi(boom)
result = asyncio.run(universe.get_station(60003760))
assert result["station_id"] == 60003760
assert result["system_id"] == 30000142
assert result["type_id"] is None
assert result["services"] is None
def test_get_station_falls_back_to_esi_when_sde_absent(mock_esi):
sde.set_connection(None)
fixtures = {
"/universe/stations/": {
"station_id": 60003760,
"name": "Jita IV - Moon 4 - CNAP",
"system_id": 30000142,
"type_id": 1529,
"services": ["market", "repair"],
},
}
def route(request):
for needle, body in fixtures.items():
if needle in request.url.path:
return httpx.Response(200, json=body)
return httpx.Response(404, json={"error": "no fixture"})
mock_esi(route)
result = asyncio.run(universe.get_station(60003760))
assert result["type_id"] == 1529
assert result["services"] == ["market", "repair"]
# --- download_sde ----------------------------------------------------------
def test_download_sde_decompresses_and_installs(tmp_path):
src = tmp_path / "source.sqlite"
_build_sde_file(src)
compressed = bz2.compress(src.read_bytes())
def handler(request):
# Serve the bz2 bytes in a couple of chunks to exercise streaming.
return httpx.Response(200, content=compressed)
dest = tmp_path / "installed.sqlite"
try:
result = asyncio.run(
sde.download_sde(
url="https://example.invalid/sde.sqlite.bz2",
dest=dest,
transport=httpx.MockTransport(handler),
)
)
assert result["path"] == str(dest)
assert dest.is_file()
assert result["bytes"] == dest.stat().st_size
# Point the live store at the freshly written file and query it.
import os
os.environ["FAMILIAR_SDE_PATH"] = str(dest)
sde.set_connection(None)
try:
assert sde.available() is True
assert sde.type_by_id(34)["name"] == "Tritanium"
assert sde.system_by_name("Jita")["system_id"] == 30000142
finally:
del os.environ["FAMILIAR_SDE_PATH"]
sde.set_connection(None)
finally:
sde.set_connection(None)