From e5b84331533c99e67db317868191e4a5f1ed1aa5 Mon Sep 17 00:00:00 2001 From: Adrian Zawadzki Date: Sat, 15 Aug 2026 22:00:22 +0200 Subject: [PATCH] bugfix 673 1446 --- python_tests/test_memo.py | 43 +++++++++++++++++++++ python_tests/test_memo_singleton.py | 56 ++++++++++++++++++++++++++++ src/dbzero/bindings/python/Memo.cpp | 30 +++++++++++++++ src/dbzero/bindings/python/PyAPI.cpp | 7 +++- 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/python_tests/test_memo.py b/python_tests/test_memo.py index 13a1fb62..4cdbb9c1 100755 --- a/python_tests/test_memo.py +++ b/python_tests/test_memo.py @@ -4,6 +4,7 @@ import pytest import dbzero as db0 from .memo_test_types import MemoTestClass, TriColor, MemoAnyAttrs +from abc import ABC, abstractmethod from dataclasses import dataclass @@ -37,6 +38,33 @@ def value(self, new_value): @db0.memo class MemoDerivedClassNoInit(MemoTestClass): pass + + +@db0.memo +class MemoAbstractBase(ABC): + @abstractmethod + def describe(self): + pass + + +@db0.memo +class MemoConcreteDerived(MemoAbstractBase): + def __init__(self, value): + self.value = value + + def describe(self): + return f"value={self.value}" + + +@db0.memo +class MemoManualAbstractMethods: + __abstractmethods__ = frozenset({"describe"}) + + def __init__(self, value): + self.value = value + + def describe(self): + return f"value={self.value}" def test_memo_rejects_removed_option(): @@ -55,6 +83,21 @@ class RemovedCacheMemoOption: pass +def test_memo_respects_abc_abstract_instantiation_check(db0_fixture): + with pytest.raises(TypeError, match="abstract class"): + MemoAbstractBase() + + +def test_memo_allows_concrete_abc_derived_class(db0_fixture): + obj = MemoConcreteDerived(123) + assert obj.describe() == "value=123" + + +def test_memo_manual_abstractmethods_without_abc_does_not_block_instantiation(db0_fixture): + obj = MemoManualAbstractMethods(123) + assert obj.describe() == "value=123" + + def test_memo_is_instance_operator(db0_fixture): obj_1 = MemoTestClass(999) obj_2 = db0.fetch(db0.uuid(obj_1)) diff --git a/python_tests/test_memo_singleton.py b/python_tests/test_memo_singleton.py index 7c92ca44..5abf294e 100755 --- a/python_tests/test_memo_singleton.py +++ b/python_tests/test_memo_singleton.py @@ -3,6 +3,8 @@ import pytest import dbzero as db0 +import subprocess +import sys from .memo_test_types import MemoTestClass, MemoTestSingleton, MemoScopedSingleton, MemoDataPxSingleton from .memo_test_types import MemoSingletonWithMigrations @@ -56,6 +58,23 @@ def test_create_then_open_dynamically_scoped_singleton(db0_fixture): assert obj.value == 123 +def test_missing_singleton_in_read_only_prefix_reports_type_and_cause(db0_fixture): + prefix = db0.get_current_prefix().name + MemoScopedSingleton("existing-singleton") + db0.commit() + db0.close() + + db0.init(DB0_DIR) + db0.open(prefix, "r") + with pytest.raises(Exception) as exc_info: + MemoTestSingleton(123) + + message = str(exc_info.value) + assert "MemoTestSingleton" in message + assert "read-only" in message + assert "singleton instance does not exist" in message + + def test_find_singleton_static_scope(db0_fixture): # find singleton with a static-scope assert db0.find_singleton(MemoDataPxSingleton) is None @@ -74,6 +93,43 @@ def test_find_singleton(db0_fixture): assert db0.find_singleton(MemoScopedSingleton, prefix = "my-test-prefix-1") is obj_2 +def test_find_singleton_from_read_only_prefix_does_not_acquire_write_lock(db0_fixture): + prefix = "read-only-singleton-prefix" + obj = MemoScopedSingleton(123, prefix=prefix) + uuid = db0.uuid(obj) + db0.commit(prefix) + del obj + db0.close(prefix) + + child_code = """ +import sys +import time +import dbzero as db0 + +db0.init(sys.argv[1]) +db0.open(sys.argv[2], "rw") +print("ready", flush=True) +time.sleep(10) +db0.close() +""" + process = subprocess.Popen( + [sys.executable, "-c", child_code, DB0_DIR, prefix], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + assert process.stdout.readline().strip() == "ready" + obj = db0.find_singleton(MemoScopedSingleton, prefix=prefix) + + assert obj is not None + assert db0.uuid(obj) == uuid + assert obj.value == 123 + finally: + process.terminate() + process.communicate(timeout=5) + + def test_singleton_with_migrations(db0_fixture): obj_1 = MemoSingletonWithMigrations(123) diff --git a/src/dbzero/bindings/python/Memo.cpp b/src/dbzero/bindings/python/Memo.cpp index 2ceb40d5..8fed7748 100755 --- a/src/dbzero/bindings/python/Memo.cpp +++ b/src/dbzero/bindings/python/Memo.cpp @@ -35,6 +35,10 @@ #define Py_TPFLAGS_MANAGED_DICT 0 #endif +#ifndef Py_TPFLAGS_IS_ABSTRACT +#define Py_TPFLAGS_IS_ABSTRACT (1UL << 20) +#endif + namespace db0::python { @@ -82,10 +86,23 @@ namespace db0::python } return result; } + + bool checkMemoTypeAbstractInstantiation(PyTypeObject *py_type) + { + if (!(py_type->tp_flags & Py_TPFLAGS_IS_ABSTRACT)) { + return true; + } + PyErr_Format(PyExc_TypeError, "Can't instantiate abstract class %s", py_type->tp_name); + return false; + } template MemoImplT *tryMemoObject_new(const MemoTypeDecoration &decor, PyTypeObject *py_type, PyObject *, PyObject *) { + if (!checkMemoTypeAbstractInstantiation(py_type)) { + return nullptr; + } + // NOTE: read-only fixture access is sufficient here since objects are lazy-initialized // i.e. the actual dbzero instance is created on postInit // this is also important for dynamically scoped clases (where read/write access may not be possible on default fixture) @@ -193,6 +210,10 @@ namespace db0::python PyObject *tryMemoObject_new_singleton(PyTypeObject *py_type, PyObject *args, PyObject *kwargs) { + if (!checkMemoTypeAbstractInstantiation(py_type)) { + return nullptr; + } + std::string px_name; std::uint64_t fixture_uuid = 0; // try resolve type specific scope: static, dynamic or default @@ -206,6 +227,12 @@ namespace db0::python if (result) { return result; } + if (fixture->getAccessType() == AccessType::READ_ONLY) { + THROWF(db0::InputException) + << "Cannot create singleton " << PyToolkit::getTypeName(py_type) + << " in read-only prefix '" << fixture->getPrefix().getName() + << "': singleton instance does not exist"; + } } // fixture, does not exist, try creating a new one @@ -972,6 +999,9 @@ namespace db0::python // Enable GC for Python 3.10 compatibility - required for inheritance hierarchies std::uint32_t flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; + if (base_class->tp_flags & Py_TPFLAGS_IS_ABSTRACT) { + flags |= Py_TPFLAGS_IS_ABSTRACT; + } #if PY_VERSION_HEX < 0x030B0000 // Python < 3.11 flags |= Py_TPFLAGS_HAVE_GC; #endif diff --git a/src/dbzero/bindings/python/PyAPI.cpp b/src/dbzero/bindings/python/PyAPI.cpp index 266ca210..ec951910 100755 --- a/src/dbzero/bindings/python/PyAPI.cpp +++ b/src/dbzero/bindings/python/PyAPI.cpp @@ -1673,7 +1673,12 @@ namespace db0::python } auto &workspace = PyToolkit::getPyWorkspace().getWorkspace(); - db0::swine_ptr fixture = prefix_name ? workspace.getFixture(prefix_name) : workspace.getCurrentFixture(); + db0::swine_ptr fixture = prefix_name + ? workspace.tryGetFixture(prefix_name, AccessType::READ_ONLY) + : workspace.getCurrentFixture(); + if (!fixture) { + Py_RETURN_NONE; + } fixture->refreshIfUpdated(); auto py_singleton = tryMemoObject_open_singleton(reinterpret_cast(py_type), *fixture); if (!py_singleton) {