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
9 changes: 8 additions & 1 deletion src/reader/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ def convert(self, value, param, ctx):
),
)
@click.option('--read-only/--no-read-only', help="Do not modify storage.")
@click.option(
'--migrate/--no-migrate',
default=True,
help="Enable or disable automatic schema migrations (default: enabled).",
)
@click.option(
'--plugin',
'plugins',
Expand Down Expand Up @@ -202,7 +207,9 @@ def convert(self, value, param, ctx):
)
@click.version_option(reader.__version__, message='%(prog)s %(version)s')
@click.pass_context
def cli(ctx, url, feed_root, read_only, plugins, cli_plugins, reserved_name_scheme):
def cli(
ctx, url, feed_root, read_only, migrate, plugins, cli_plugins, reserved_name_scheme
):
"""reader command-line interface.

Option defaults can be set via environment variables;
Expand Down
8 changes: 6 additions & 2 deletions src/reader/_storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ class Storage(FeedsMixin, EntriesMixin, TagsMixin, StorageBase):
"""

def __init__(
self, path: str, read_only: bool = False, timeout: float | None = None
self,
path: str,
read_only: bool = False,
migrate: bool = True,
Comment thread
lemon24 marked this conversation as resolved.
timeout: float | None = None,
):
super().__init__(path, read_only, timeout)
super().__init__(path, read_only, migrate, timeout)
self.changes: ChangeTrackerType = Changes(self)

def make_search(self) -> SearchType:
Expand Down
27 changes: 22 additions & 5 deletions src/reader/_storage/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import sys
from collections.abc import Callable
from collections.abc import Iterable
from dataclasses import replace
from functools import partial
from typing import Any
from typing import Self
Expand Down Expand Up @@ -52,7 +53,13 @@ class StorageBase:
chunk_size = 2**8

@wrap_exceptions(message="while opening database")
def __init__(self, path: str, read_only: bool, timeout: float | None = None):
def __init__(
self,
path: str,
read_only: bool,
migrate: bool = True,
timeout: float | None = None,
):
kwargs: dict[str, Any] = {'factory': CONNECTION_CLS}
if timeout is not None:
kwargs['timeout'] = timeout
Expand All @@ -61,23 +68,33 @@ def __init__(self, path: str, read_only: bool, timeout: float | None = None):
# has to run for every connection (in every thread),
# since it's not persisted across connections
self.factory = _sqlite_utils.LocalConnectionFactory(
path, self.setup_db, read_only, **kwargs
path, partial(self.setup_db, migrate=migrate), read_only, **kwargs
)

def get_db(self) -> sqlite3.Connection:
return self.factory()

@staticmethod
def setup_db(db: sqlite3.Connection) -> None:
def setup_db(db: sqlite3.Connection, migrate: bool = True) -> None:
# Private API, used by tests.

from . import MINIMUM_SQLITE_VERSION
from . import REQUIRED_SQLITE_FUNCTIONS
from ._schema import MIGRATION

migration = MIGRATION
if not migrate:
migration = replace(
MIGRATION,
migrations={},
missing_suffix=(
"; reader created with migrate=False, "
"so migrations cannot run automatically;"
" pass migrate=True to allow it"
),
)
return _sqlite_utils.setup_db(
db,
migration=MIGRATION,
migration=migration,
id=APPLICATION_ID,
minimum_sqlite_version=MINIMUM_SQLITE_VERSION,
required_sqlite_functions=REQUIRED_SQLITE_FUNCTIONS,
Expand Down
13 changes: 12 additions & 1 deletion src/reader/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def make_reader(
*,
feed_root: str | None = None,
read_only: bool = False,
migrate: bool = True,
plugins: Iterable[PluginInput[Reader]] = DEFAULT_PLUGINS,
session_timeout: TimeoutType = DEFAULT_TIMEOUT,
reserved_name_scheme: Mapping[str, str] = DEFAULT_RESERVED_NAME_SCHEME,
Expand Down Expand Up @@ -167,6 +168,11 @@ def make_reader(
read_only (bool):
Allow only read storage operations.

migrate (bool):
Allow storage to migrate to a newer version automatically,
if needed. If :const:`False` and a migration is needed,
:exc:`StorageError` is raised.

plugins (iterable(str or callable(Reader)) or None):
An iterable of built-in plugin names (``.<plugin>``),
:func:`~pkgutil.resolve_name` import paths
Expand Down Expand Up @@ -247,6 +253,9 @@ def make_reader(
Built-in plugins starting with ``reader.``;
use ``.<plugin>`` instead.

.. versionadded:: 3.27
The ``migrate`` keyword argument.

"""

# Do as much work as possible before creating the storage.
Expand All @@ -272,7 +281,9 @@ def make_reader(
# See this comment for details on how it should evolve:
# https://github.com/lemon24/reader/issues/168#issuecomment-642002049

storage: StorageType = _storage or Storage(url, read_only=read_only)
storage: StorageType = _storage or Storage(
url, read_only=read_only, migrate=migrate
)

try:
# For now, we're using a storage-bound search provider.
Expand Down
28 changes: 28 additions & 0 deletions tests/test_reader_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import asyncio
import concurrent.futures
import functools
import pathlib
import sqlite3
import sys
import threading
Expand All @@ -19,6 +20,7 @@

from reader import SearchError
from reader import StorageError
from reader._storage._sqlite_utils import HeavyMigration
from utils import rename_argument

pytestmark = pytest.mark.noscheduled
Expand Down Expand Up @@ -385,3 +387,29 @@ def test_paths_read_only_private(make_reader, path):
# can open, but can't create tables etc. because read only
with pytest.raises(StorageError):
make_reader(path, read_only=True)


def test_migrate(make_reader, db_path, monkeypatch):
migration = HeavyMigration(
create=lambda db: None,
version=1,
migrations={},
)
monkeypatch.setattr('reader._storage._schema.MIGRATION', migration)

# Test empty case
with make_reader(db_path) as reader:
assert migration.get_version(reader._storage.get_db()) == 1

# Update migration definition to version 2
migration.version = 2
migration.migrations[1] = lambda _: None

# Test make_reader(db_path, migrate=False)
with pytest.raises(StorageError) as excinfo:
make_reader(db_path, migrate=False)
assert 'migrate=False' in str(excinfo.value)
Comment thread
lemon24 marked this conversation as resolved.

# Test make_reader(db_path)
with make_reader(db_path) as reader:
assert migration.get_version(reader._storage.get_db()) == 2