Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion backend/eq2db/_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,33 @@
no-op ``clear_caches``. Subclasses implement ``_create_schema`` (their
CREATE TABLE / INDEX / migration statements — committed by the base)
and optionally ``_post_init`` (post-commit backfills) or override
``clear_caches`` when they hold per-instance caches.
``clear_caches`` / ``_cache_info`` when they hold per-instance caches.

Dunder surface (uniform across every catalogue):

* ``repr(cat)`` — class, path, provisioned-or-missing, cache sizes;
safe to drop into any log line.
* ``bool(cat)`` — True when the DB file exists (``if not catalogue:``
reads as "not provisioned").
* ``cat == other`` / ``hash(cat)`` — value semantics by (type, path).
* ``os.fspath(cat)`` — catalogues are path-like; ``sqlite3.connect(cat)``
and ``Path(cat)`` both work.
* ``with cat as conn:`` — init_db + guaranteed close (nest-safe).
* ``__init_subclass__`` — rejects a concrete subclass that forgets
``_create_schema`` at class-definition time, not first call.
"""

from __future__ import annotations

import logging
import sqlite3
from pathlib import Path
from typing import ClassVar

from backend.eq2db import _meta as _meta_db

_log = logging.getLogger(__name__)


class BaseCatalogue:
"""Read (and build) access to one eq2db SQLite file."""
Expand All @@ -46,6 +62,76 @@ class BaseCatalogue:

def __init__(self, path: Path) -> None:
self.path = Path(path)
# Connections opened via the context-manager protocol; a stack so
# nested ``with cat as conn:`` blocks close their own connection.
self._ctx_conns: list[sqlite3.Connection] = []

def __init_subclass__(cls, **kwargs) -> None:
"""Fail at class-definition time when a subclass forgets
``_create_schema`` — earlier and clearer than the first
``init_db()`` call raising NotImplementedError at runtime."""
super().__init_subclass__(**kwargs)
if cls._create_schema is BaseCatalogue._create_schema:
raise TypeError(f"{cls.__name__} must implement _create_schema(conn)")

# ── Introspection / logging ──────────────────────────────────────────────

def __repr__(self) -> str:
"""Debug/trace-friendly one-liner: class, path, whether the DB file
is provisioned, and any per-instance cache sizes."""
status = "ready" if self.path.exists() else "missing"
caches = ", ".join(f"{k}={v}" for k, v in self._cache_info().items())
extra = f", {caches}" if caches else ""
return f"{type(self).__name__}(path={str(self.path)!r}, {status}{extra})"

def _cache_info(self) -> dict[str, int]:
"""Cache-name → entry-count map rendered into ``repr``. Default: no
caches. Subclasses holding caches override (see AACatalogue)."""
return {}

def __bool__(self) -> bool:
"""Truthiness = "is the DB file provisioned". Every read path
already guards on ``self.path.exists()``; this gives callers and
log statements the same check as ``if not catalogue:``."""
return self.path.exists()

# ── Value semantics ──────────────────────────────────────────────────────

def __eq__(self, other: object) -> bool:
"""Two catalogues are equal when they are the same class over the
same path — handy in tests (``assert cat == RaidCatalogue(p)``)."""
if not isinstance(other, BaseCatalogue):
return NotImplemented
return type(self) is type(other) and self.path == other.path

def __hash__(self) -> int:
"""Hash by (type, path) to match ``__eq__``. Note: conftest
re-points ``catalogue.path`` at session start — don't put a
catalogue in a set/dict before mutating its path."""
return hash((type(self), self.path))

# ── Path-like protocol ───────────────────────────────────────────────────

def __fspath__(self) -> str:
"""Catalogues are os.PathLike over their DB file: ``Path(cat)``,
``os.path.getsize(cat)`` and ``sqlite3.connect(cat)`` all work."""
return str(self.path)

# ── Connection lifecycle ─────────────────────────────────────────────────

def __enter__(self) -> sqlite3.Connection:
"""``with cat as conn:`` — open via init_db (schema guaranteed) and
close on exit. Unlike ``with cat.init_db() as conn:`` (sqlite3's
own CM, which commits but never closes), this releases the file
handle. Nest-safe; not thread-safe on a shared instance."""
conn = self.init_db()
self._ctx_conns.append(conn)
return conn

def __exit__(self, exc_type, exc, tb) -> None:
self._ctx_conns.pop().close()

# ── DB lifecycle ─────────────────────────────────────────────────────────

def init_db(self) -> sqlite3.Connection:
"""Create tables/indexes if missing. Returns an open connection.
Expand All @@ -55,6 +141,7 @@ def init_db(self) -> sqlite3.Connection:
post-commit backfills from ``_post_init``. ``:memory:`` is
supported for tests (skips mkdir + WAL).
"""
_log.debug("[eq2db] init_db %r", self)
if str(self.path) == ":memory:":
conn = sqlite3.connect(":memory:")
else:
Expand Down
10 changes: 10 additions & 0 deletions backend/eq2db/aas.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ def clear_caches(self) -> None:
self._total_max_points.clear()
self._limits.clear()

def _cache_info(self) -> dict[str, int]:
return {
"tree_index": len(self._tree_index or ()),
"trees": len(self._trees),
"node_costs": len(self._node_costs),
"max_points": len(self._max_points),
"total_max_points": len(self._total_max_points),
"limits": len(self._limits),
}

# ── Runtime accessors ────────────────────────────────────────────────────

def load_tree_index(self) -> dict[int, dict[str, str]]:
Expand Down
3 changes: 3 additions & 0 deletions backend/eq2db/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def clear_caches(self) -> None:
self._rows = None
self._derived.clear()

def _cache_info(self) -> dict[str, int]:
return {"rows": len(self._rows or ()), "derived": len(self._derived)}

def _cached(self, key: str, build: Callable[[], _T]) -> _T:
"""Build-once cache for derived views. Callers across the codebase may
compare results by identity (same object every call), so derived views
Expand Down
3 changes: 3 additions & 0 deletions backend/eq2db/spells.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ def clear_caches(self) -> None:
"""Reset the per-instance caches — used by tests and upsert_spells."""
self._crc_cache.clear()

def _cache_info(self) -> dict[str, int]:
return {"crc_cache": len(self._crc_cache)}

# ── Pure helpers (no DB access — statics so the class is the ONE interface) ──

@staticmethod
Expand Down
169 changes: 169 additions & 0 deletions tests/eq2db/test_catalogue_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Tests for the BaseCatalogue dunder surface (backend/eq2db/_catalogue.py).

Exercised through RaidCatalogue (no caches, FOREIGN_KEYS=True) and
SpellCatalogue (crc cache) so the behaviour is proven on real subclasses,
not a synthetic stub.
"""

from __future__ import annotations

import os
import sqlite3
from pathlib import Path

import pytest

from backend.eq2db._catalogue import BaseCatalogue
from backend.eq2db.raids import RaidCatalogue
from backend.eq2db.spells import SpellCatalogue

# ---------------------------------------------------------------------------
# __repr__ / _cache_info
# ---------------------------------------------------------------------------


class TestRepr:
def test_missing_db(self, tmp_path: Path):
cat = RaidCatalogue(tmp_path / "raids.db")
r = repr(cat)
assert r.startswith("RaidCatalogue(")
assert "missing" in r
# !r-escaped on Windows, so compare against the repr of the string
assert repr(str(tmp_path / "raids.db")) in r

def test_ready_db(self, tmp_path: Path):
cat = RaidCatalogue(tmp_path / "raids.db")
cat.init_db().close()
assert "ready" in repr(cat)

def test_cache_sizes_rendered(self, tmp_path: Path):
cat = SpellCatalogue(tmp_path / "spells.db")
assert "crc_cache=0" in repr(cat)
cat._crc_cache[(123, None)] = None
assert "crc_cache=1" in repr(cat)

def test_no_cache_subclass_has_no_cache_suffix(self, tmp_path: Path):
r = repr(RaidCatalogue(tmp_path / "raids.db"))
assert r.endswith("missing)")


# ---------------------------------------------------------------------------
# __bool__
# ---------------------------------------------------------------------------


class TestBool:
def test_false_when_db_missing(self, tmp_path: Path):
assert not RaidCatalogue(tmp_path / "nope.db")

def test_true_when_db_exists(self, tmp_path: Path):
cat = RaidCatalogue(tmp_path / "raids.db")
cat.init_db().close()
assert cat


# ---------------------------------------------------------------------------
# __eq__ / __hash__
# ---------------------------------------------------------------------------


class TestEqHash:
def test_same_class_same_path_equal(self, tmp_path: Path):
p = tmp_path / "raids.db"
assert RaidCatalogue(p) == RaidCatalogue(p)
assert hash(RaidCatalogue(p)) == hash(RaidCatalogue(p))

def test_different_path_not_equal(self, tmp_path: Path):
assert RaidCatalogue(tmp_path / "a.db") != RaidCatalogue(tmp_path / "b.db")

def test_different_class_same_path_not_equal(self, tmp_path: Path):
p = tmp_path / "x.db"
assert RaidCatalogue(p) != SpellCatalogue(p)

def test_non_catalogue_not_equal(self, tmp_path: Path):
assert RaidCatalogue(tmp_path / "x.db") != "x.db"

def test_usable_as_dict_key(self, tmp_path: Path):
p = tmp_path / "raids.db"
d = {RaidCatalogue(p): "hit"}
assert d[RaidCatalogue(p)] == "hit"


# ---------------------------------------------------------------------------
# __fspath__
# ---------------------------------------------------------------------------


class TestFspath:
def test_os_fspath(self, tmp_path: Path):
p = tmp_path / "raids.db"
assert os.fspath(RaidCatalogue(p)) == str(p)

def test_path_conversion(self, tmp_path: Path):
p = tmp_path / "raids.db"
assert Path(RaidCatalogue(p)) == p

def test_sqlite_connect_accepts_catalogue(self, tmp_path: Path):
cat = RaidCatalogue(tmp_path / "raids.db")
cat.init_db().close()
with sqlite3.connect(cat) as conn:
n = conn.execute("SELECT COUNT(*) FROM raid_zones").fetchone()[0]
conn.close()
assert n == 0


# ---------------------------------------------------------------------------
# __enter__ / __exit__
# ---------------------------------------------------------------------------


class TestContextManager:
def test_with_yields_initialised_connection(self, tmp_path: Path):
cat = RaidCatalogue(tmp_path / "raids.db")
with cat as conn:
# Schema exists — init_db ran.
n = conn.execute("SELECT COUNT(*) FROM raid_encounters").fetchone()[0]
assert n == 0
# Connection is CLOSED on exit (unlike sqlite3's own CM).
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")

def test_nested_with_blocks_close_their_own_conn(self, tmp_path: Path):
cat = RaidCatalogue(tmp_path / "raids.db")
with cat as outer:
with cat as inner:
assert inner is not outer
inner.execute("SELECT 1")
# Inner closed; outer still usable.
outer.execute("SELECT 1")
with pytest.raises(sqlite3.ProgrammingError):
outer.execute("SELECT 1")

def test_close_happens_on_exception(self, tmp_path: Path):
cat = RaidCatalogue(tmp_path / "raids.db")
with pytest.raises(RuntimeError):
with cat as conn:
raise RuntimeError("boom")
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")


# ---------------------------------------------------------------------------
# __init_subclass__
# ---------------------------------------------------------------------------


class TestInitSubclass:
def test_subclass_without_create_schema_rejected_at_definition(self):
with pytest.raises(TypeError, match="_create_schema"):

class Broken(BaseCatalogue): # noqa: F841 — definition itself must raise
pass

def test_subclass_of_concrete_catalogue_inherits_schema(self, tmp_path: Path):
# A test double subclassing a real catalogue is fine — it inherits
# the parent's _create_schema.
class Doubled(RaidCatalogue):
pass

Doubled(tmp_path / "x.db").init_db().close()
Loading