From c9b277b1d68537d5b26aee7e38f6e77e8bea8732 Mon Sep 17 00:00:00 2001 From: VortexUK Date: Sat, 11 Jul 2026 09:51:44 +0100 Subject: [PATCH] feat(eq2db): dunder surface on BaseCatalogue for debug logging + ergonomics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniform dunders across all seven catalogues: - __repr__ — class, path, ready/missing, per-instance cache sizes (via a new _cache_info hook, overridden by AACatalogue / ClassCatalogue / SpellCatalogue); safe to drop into any log line. init_db now emits a DEBUG trace using it. - __bool__ — truthiness = "DB file provisioned"; mirrors the path.exists() guard every read path already uses. - __eq__/__hash__ — value semantics by (type, path); catalogues are dict-key/set safe (documented caveat: don't hash before conftest re-points path). - __fspath__ — catalogues are os.PathLike: Path(cat) and sqlite3.connect(cat) work directly. - __enter__/__exit__ — `with cat as conn:` opens via init_db and CLOSES on exit (sqlite3's own conn CM commits but never closes); nest-safe via a per-instance connection stack. - __init_subclass__ — a concrete subclass that forgets _create_schema fails at class-definition time instead of on the first init_db call. 19 new tests in tests/eq2db/test_catalogue_base.py exercising the surface through real subclasses (RaidCatalogue, SpellCatalogue). Co-Authored-By: Claude Fable 5 --- backend/eq2db/_catalogue.py | 89 ++++++++++++++- backend/eq2db/aas.py | 10 ++ backend/eq2db/classes.py | 3 + backend/eq2db/spells.py | 3 + tests/eq2db/test_catalogue_base.py | 169 +++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 tests/eq2db/test_catalogue_base.py diff --git a/backend/eq2db/_catalogue.py b/backend/eq2db/_catalogue.py index c4d9bd56..929addc3 100644 --- a/backend/eq2db/_catalogue.py +++ b/backend/eq2db/_catalogue.py @@ -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.""" @@ -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. @@ -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: diff --git a/backend/eq2db/aas.py b/backend/eq2db/aas.py index 04dd202b..4c0bdc13 100644 --- a/backend/eq2db/aas.py +++ b/backend/eq2db/aas.py @@ -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]]: diff --git a/backend/eq2db/classes.py b/backend/eq2db/classes.py index 3112ea99..4798e634 100644 --- a/backend/eq2db/classes.py +++ b/backend/eq2db/classes.py @@ -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 diff --git a/backend/eq2db/spells.py b/backend/eq2db/spells.py index dc47b483..58589e5d 100644 --- a/backend/eq2db/spells.py +++ b/backend/eq2db/spells.py @@ -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 diff --git a/tests/eq2db/test_catalogue_base.py b/tests/eq2db/test_catalogue_base.py new file mode 100644 index 00000000..1ca269e1 --- /dev/null +++ b/tests/eq2db/test_catalogue_base.py @@ -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()