Add read only mode that doesn't use locking. Useful for inspection while the database is in use or when no changes are intended (except in RAM).
This commit is contained in:
+49
-5
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -49,16 +49,60 @@ def test_logfmt_requires_value_annotation(tmp_path, format_config):
|
||||
return None
|
||||
|
||||
|
||||
def test_logfmt_requires_return_annotation(tmp_path, format_config):
|
||||
def test_logfmt_allows_missing_return_annotation(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
with pytest.raises(TypeError, match="must annotate its return"):
|
||||
@kanta.logfmt
|
||||
def resolve_names(value: str, current: DictPost):
|
||||
return None
|
||||
|
||||
@kanta.logfmt
|
||||
def resolve_names(value: str, current: DictPost):
|
||||
|
||||
def test_logfmt_class_allows_missing_return_annotation(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logfmt
|
||||
class UserLogFmt(LogFmt):
|
||||
def resolve(self, value: str, path: str):
|
||||
return None
|
||||
|
||||
|
||||
# fmt: off
|
||||
def test_logfmt_accepts_optional_return_typing_forms(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logfmt
|
||||
def resolve_optional(value: str) -> Optional[str]: # noqa: UP007
|
||||
return value
|
||||
|
||||
@kanta.logfmt
|
||||
def resolve_union(value: str) -> Union[str, None]: # noqa: UP007
|
||||
return value
|
||||
|
||||
@kanta.logfmt
|
||||
def resolve_pipe(value: "str") -> "str | None":
|
||||
return value
|
||||
|
||||
|
||||
def test_logfmt_class_accepts_optional_return_typing_forms(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logfmt
|
||||
class OptionalStyle(LogFmt):
|
||||
def resolve(self, value: str, path: str) -> Optional[str]: # noqa: UP007
|
||||
return value
|
||||
|
||||
@kanta.logfmt
|
||||
class UnionStyle(LogFmt):
|
||||
def resolve(self, value: str, path: str) -> Union[str, None]: # noqa: UP007
|
||||
return value
|
||||
|
||||
@kanta.logfmt
|
||||
class StringStyle(LogFmt):
|
||||
def resolve(self, value: "str", path: "str") -> "str | None":
|
||||
return value
|
||||
# fmt: on
|
||||
|
||||
|
||||
def test_logfmt_rejects_async_callback(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for Kanta read-only mode."""
|
||||
|
||||
import pytest
|
||||
|
||||
from kanta.exceptions import DataIntegrityError, FileLockError
|
||||
from kanta.serialization import struct_to_dict
|
||||
|
||||
from .support import (
|
||||
Data,
|
||||
EvolvableDataV2,
|
||||
fixed_change,
|
||||
make_kanta,
|
||||
make_migrations_module,
|
||||
seed_single_change,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_opens_existing_database(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("seed", {"counter": 7}), format_config)
|
||||
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
await kanta.open(readonly=True)
|
||||
|
||||
assert isinstance(kanta.data, Data)
|
||||
assert kanta.data.counter == 7
|
||||
assert kanta._impl.readonly is True
|
||||
assert kanta._impl.background_task is None
|
||||
|
||||
await kanta.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_missing_file_fails(tmp_path, format_config):
|
||||
path = tmp_path / "missing.db"
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
|
||||
with pytest.raises(FileLockError):
|
||||
await kanta.open(readonly=True)
|
||||
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_empty_file_fails(tmp_path, format_config):
|
||||
path = tmp_path / "empty.db"
|
||||
path.touch()
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
|
||||
with pytest.raises(DataIntegrityError, match="empty"):
|
||||
await kanta.open(readonly=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_transaction_fails(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
|
||||
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
await kanta.open(readonly=True)
|
||||
|
||||
with pytest.raises(DataIntegrityError, match="read-only"):
|
||||
with kanta.transaction(action="inc") as data:
|
||||
data.counter = 2
|
||||
|
||||
# In-memory state must remain unchanged.
|
||||
assert kanta.data.counter == 1
|
||||
await kanta.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_flush_fails(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
|
||||
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
await kanta.open(readonly=True)
|
||||
|
||||
with pytest.raises(DataIntegrityError, match="read-only"):
|
||||
await kanta.flush()
|
||||
|
||||
await kanta.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_create_true_does_not_create_file(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
|
||||
with pytest.raises(FileLockError):
|
||||
await kanta.open(create=True, readonly=True)
|
||||
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_does_not_persist_changes(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
|
||||
original_content = path.read_bytes()
|
||||
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
await kanta.open(readonly=True)
|
||||
await kanta.close()
|
||||
|
||||
assert path.read_bytes() == original_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_runs_migrations(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(
|
||||
path,
|
||||
fixed_change("seed", {"counter": 1}, version=0),
|
||||
format_config,
|
||||
)
|
||||
|
||||
def migrate_v1(data, ctx):
|
||||
data.setdefault("enabled", True)
|
||||
|
||||
migrations = make_migrations_module("readonly_migrations", "migrate_v1", migrate_v1)
|
||||
|
||||
kanta = make_kanta(path, EvolvableDataV2, format_config, migrations=migrations)
|
||||
await kanta.open(readonly=True)
|
||||
|
||||
assert kanta.data.counter == 1
|
||||
# Migration ran in memory even though no change was persisted.
|
||||
assert struct_to_dict(kanta.data, serializer=kanta._impl.serializer) == {
|
||||
"counter": 1,
|
||||
"enabled": True,
|
||||
}
|
||||
assert not kanta._impl.pending_changes
|
||||
|
||||
await kanta.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readwrite_and_readonly_can_open_together(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
|
||||
|
||||
rw = make_kanta(path, Data, format_config)
|
||||
await rw.open()
|
||||
|
||||
ro = make_kanta(path, Data, format_config)
|
||||
await ro.open(readonly=True)
|
||||
|
||||
assert rw.data.counter == 1
|
||||
assert ro.data.counter == 1
|
||||
|
||||
await ro.close()
|
||||
await rw.close()
|
||||
Reference in New Issue
Block a user