SDE download/import + offline system & station lookups (#51) #58
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user