Files
cleveragents-core/features/steps/migration_runner_steps.py
HAL9000 6fc294b24b
CI / lint (push) Successful in 47s
CI / quality (push) Successful in 57s
CI / typecheck (push) Successful in 1m15s
CI / helm (push) Successful in 28s
CI / build (push) Successful in 41s
CI / security (push) Successful in 2m0s
CI / e2e_tests (push) Successful in 3m24s
CI / push-validation (push) Successful in 19s
CI / integration_tests (push) Successful in 4m4s
CI / unit_tests (push) Successful in 4m13s
CI / docker (push) Successful in 2m4s
CI / benchmark-regression (push) Has been skipped
CI / coverage (push) Successful in 12m41s
CI / status-check (push) Successful in 5s
CI / benchmark-publish (push) Successful in 1h17m37s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1m12s
CI / integration_tests (pull_request) Failing after 4m47s
CI / push-validation (pull_request) Successful in 33s
CI / lint (pull_request) Successful in 39s
CI / security (pull_request) Successful in 1m1s
CI / typecheck (pull_request) Successful in 1m17s
CI / helm (pull_request) Successful in 37s
CI / build (pull_request) Successful in 40s
CI / quality (pull_request) Successful in 59s
CI / e2e_tests (pull_request) Successful in 3m54s
CI / unit_tests (pull_request) Successful in 4m25s
CI / status-check (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
fix(database/migration_runner): add check_same_thread=False to get_current_revision() SQLite engine
MigrationRunner.get_current_revision() was creating a SQLite engine without
connect_args={'check_same_thread': False}, causing ProgrammingError when called
from a different thread than the one that created the engine. This is now
consistent with init_or_upgrade() which correctly passes check_same_thread=False
for all SQLite engines.

Added a Behave scenario to verify that get_current_revision() passes
check_same_thread=False when creating a SQLite engine.

ISSUES CLOSED: #10952
2026-05-05 11:05:07 +00:00

870 lines
30 KiB
Python

from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.core.exceptions import MigrationNotApprovedError
from cleveragents.infrastructure.database.migration_runner import (
MEMORY_ENGINES,
MigrationRunner,
)
class FakeConnection:
def __init__(self) -> None:
self.entered = False
self.exit_called = False
self.closed_direct = False
self.committed = False
def __enter__(self) -> FakeConnection:
self.entered = True
return self
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
self.exit_called = True
return False
def close(self) -> None:
self.closed_direct = True
def commit(self) -> None:
self.committed = True
class FakeEngine:
def __init__(self) -> None:
self.connections: list[FakeConnection] = []
self.disposed = False
def connect(self) -> FakeConnection:
conn = FakeConnection()
self.connections.append(conn)
return conn
def dispose(self) -> None:
self.disposed = True
@dataclass
class LegacyStampContext:
stamp_calls: list[Any]
connection_flags: list[bool]
@given('a migration runner configured for "{database_url}"')
def step_given_migration_runner(context, database_url: str) -> None:
context.database_url = database_url
context.runner = MigrationRunner(database_url)
@when("I attempt to load the alembic config without an alembic.ini file")
def step_when_load_missing_alembic(context) -> None:
context.runner._alembic_cfg = None
from pathlib import Path
def fake_exists(self) -> bool:
if str(self).endswith("alembic.ini"):
return False
return Path.exists(self)
context.alembic_error = None
try:
with patch.object(Path, "exists", fake_exists):
_ = context.runner.alembic_cfg
except FileNotFoundError as exc:
context.alembic_error = exc
@then('a FileNotFoundError should be raised mentioning "{expected_snippet}"')
def step_then_missing_alembic_error(context, expected_snippet: str) -> None:
assert context.alembic_error is not None, "Expected FileNotFoundError"
assert expected_snippet in str(context.alembic_error)
@when("I run migrations without providing an engine")
def step_when_run_migrations_no_engine(context) -> None:
env_var = "CLEVERAGENTS_DATABASE_URL"
original_env = os.environ.get(env_var)
os.environ[env_var] = "sqlite:///pre-existing.db"
context.env_before = os.environ[env_var]
captured_connection_attr: list[bool] = []
def fake_upgrade(cfg, revision) -> None:
captured_connection_attr.append("connection" in cfg.attributes)
with patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade",
side_effect=fake_upgrade,
):
context.runner.run_migrations()
context.upgrade_call_count = len(captured_connection_attr)
context.connection_attribute_present = (
captured_connection_attr[0] if captured_connection_attr else None
)
context.connection_attribute_state = dict(context.runner.alembic_cfg.attributes)
context.env_after = os.environ.get(env_var)
if original_env is None:
os.environ.pop(env_var, None)
else:
os.environ[env_var] = original_env
@then("the upgrade command should be invoked without a connection attribute")
def step_then_upgrade_without_connection(context) -> None:
assert context.upgrade_call_count == 1
assert context.connection_attribute_present is False
assert "connection" not in context.connection_attribute_state
@then("the CLEVERAGENTS_DATABASE_URL environment variable should be restored")
def step_then_env_restored(context) -> None:
assert context.env_after == context.env_before
@when("I initialize or upgrade the database with cached in-memory engine")
def step_when_init_in_memory(context) -> None:
memory_snapshot = dict(MEMORY_ENGINES)
MEMORY_ENGINES.clear()
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = []
create_calls: list[Any] = []
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
create_calls.append((url, kwargs))
return fake_engine
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
):
context.runner.init_or_upgrade()
context.runner.init_or_upgrade()
upgrade_calls = list(upgrade_mock.call_args_list)
context.upgrade_call_count_with_engine = len(upgrade_calls)
context.create_engine_call_count = len(create_calls)
context.fake_engine = fake_engine
context.fake_engine_connections = list(fake_engine.connections)
context.connection_attrs_after_init = dict(context.runner.alembic_cfg.attributes)
context.memory_cache_state = dict(MEMORY_ENGINES)
MEMORY_ENGINES.clear()
MEMORY_ENGINES.update(memory_snapshot)
@then("migrations should run using the cached engine connection")
def step_then_cached_engine_used(context) -> None:
assert context.upgrade_call_count_with_engine == 2
assert context.create_engine_call_count == 1
assert len(context.fake_engine_connections) == 4
even_connections = context.fake_engine_connections[::2]
odd_connections = context.fake_engine_connections[1::2]
for conn in even_connections:
assert conn.exit_called is True
for conn in odd_connections:
assert conn.closed_direct is True
assert "connection" not in context.connection_attrs_after_init
cache_entry = context.memory_cache_state.get(context.runner.database_url)
assert cache_entry is context.fake_engine
@then("the cached engine should remain available without disposal")
def step_then_cached_engine_not_disposed(context) -> None:
assert context.fake_engine.disposed is False
@when("I initialize or upgrade the database with legacy tables present")
def step_when_legacy_stamping(context) -> None:
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = ["users"]
stamp_context = LegacyStampContext(stamp_calls=[], connection_flags=[])
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
context.legacy_create_engine_call = (url, kwargs)
return fake_engine
def fake_stamp(cfg, revision) -> None:
stamp_context.stamp_calls.append((cfg, revision))
stamp_context.connection_flags.append("connection" in cfg.attributes)
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.stamp",
side_effect=fake_stamp,
) as stamp_mock,
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
):
context.runner.init_or_upgrade()
context.legacy_stamp_calls = list(stamp_mock.call_args_list)
context.legacy_upgrade_calls = list(upgrade_mock.call_args_list)
context.legacy_stamp_connection_has_attr = any(stamp_context.connection_flags)
context.legacy_stamp_revision = (
stamp_context.stamp_calls[0][1] if stamp_context.stamp_calls else None
)
context.legacy_fake_engine = fake_engine
context.legacy_connection_attr_after = dict(context.runner.alembic_cfg.attributes)
@then("the stamp command should run using the active connection")
def step_then_stamp_uses_connection(context) -> None:
assert len(context.legacy_stamp_calls) == 1
assert context.legacy_stamp_revision == "head"
assert context.legacy_stamp_connection_has_attr is True
assert len(context.legacy_upgrade_calls) == 0
assert "connection" not in context.legacy_connection_attr_after
@then("the external database engine should be disposed after initialization")
def step_then_external_engine_disposed(context) -> None:
assert context.legacy_fake_engine.disposed is True
@when("I request pending migrations for a database with no current revision")
def step_when_pending_no_current(context) -> None:
revisions = ["rev_003", "rev_002", None]
class FakeRevision:
def __init__(self, revision: Any) -> None:
self.revision = revision
fake_revisions = [FakeRevision(r) for r in revisions]
def walk_revisions():
return iter(fake_revisions)
fake_script_dir = MagicMock()
fake_script_dir.walk_revisions.side_effect = walk_revisions
with (
patch.object(MigrationRunner, "get_current_revision", return_value=None),
patch(
"cleveragents.infrastructure.database.migration_runner.ScriptDirectory.from_config",
return_value=fake_script_dir,
),
):
context.pending_migrations = context.runner.get_pending_migrations()
@then("the pending migration list should be ordered from oldest to newest")
def step_then_pending_migrations_ordered(context) -> None:
assert context.pending_migrations == [None, "rev_002", "rev_003"]
@when("I request the current revision from the database")
def step_when_get_current_revision(context) -> None:
fake_engine = FakeEngine()
migration_context = MagicMock()
migration_context.get_current_revision.return_value = "001_initial_schema"
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
context.current_rev_create_call = (url, kwargs)
return fake_engine
def fake_configure(conn):
context.current_rev_connection = conn
return migration_context
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch(
"cleveragents.infrastructure.database.migration_runner.MigrationContext.configure",
side_effect=fake_configure,
),
):
context.current_revision = context.runner.get_current_revision()
context.current_rev_fake_engine = fake_engine
@then("the migration context should be queried for the current revision")
def step_then_migration_context_queried(context) -> None:
assert context.current_revision == "001_initial_schema"
@then("the temporary connection should be closed afterward")
def step_then_temp_connection_closed(context) -> None:
assert len(context.current_rev_fake_engine.connections) == 1
assert context.current_rev_fake_engine.connections[0].exit_called is True
@then("the SQLite engine for get_current_revision should use check_same_thread=False")
def step_then_get_current_revision_check_same_thread(context) -> None:
_url, kwargs = context.current_rev_create_call
assert "connect_args" in kwargs, (
"Expected connect_args to be passed to create_engine for SQLite"
)
assert kwargs["connect_args"].get("check_same_thread") is False, (
"Expected check_same_thread=False in connect_args for SQLite engine"
)
@when("I initialize or upgrade a file-based SQLite database")
def step_when_init_file_based_sqlite(context) -> None:
import shutil
from pathlib import Path
# Extract db path from URL
db_path = context.database_url.replace("sqlite:///", "")
if not db_path.startswith("/"):
db_path = "/" + db_path
db_file = Path(db_path)
# Clean up if exists
if db_file.parent.exists():
shutil.rmtree(db_file.parent)
context.db_parent_dir = db_file.parent
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = []
context.file_sqlite_create_calls = []
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
context.file_sqlite_create_calls.append((url, kwargs))
return fake_engine
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch("cleveragents.infrastructure.database.migration_runner.command.upgrade"),
):
context.runner.init_or_upgrade()
context.file_sqlite_fake_engine = fake_engine
@then("the parent directory should be created if it does not exist")
def step_then_parent_dir_created(context) -> None:
assert context.db_parent_dir.exists()
@then("the database engine should be disposed after initialization")
def step_then_engine_disposed_after_init(context) -> None:
assert context.file_sqlite_fake_engine.disposed is True
@then("the engine should be created with check_same_thread set to False")
def step_then_engine_created_with_args(context) -> None:
assert len(context.file_sqlite_create_calls) > 0
_url, kwargs = context.file_sqlite_create_calls[0]
assert "connect_args" in kwargs
assert kwargs["connect_args"]["check_same_thread"] is False
@when("I initialize the database and migrations are already applied")
def step_when_init_with_existing_migrations(context) -> None:
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = ["alembic_version", "users"]
context.up_to_date_upgrade_calls = []
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
return fake_engine
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
patch.object(MigrationRunner, "get_pending_migrations", return_value=[]),
):
context.check_migrations_result = context.runner.check_migrations_needed()
context.runner.init_or_upgrade()
context.up_to_date_upgrade_calls = list(upgrade_mock.call_args_list)
context.up_to_date_fake_engine = fake_engine
@then("no additional migrations should be run")
def step_then_no_migrations_run(context) -> None:
assert len(context.up_to_date_upgrade_calls) == 0
@then("check migrations needed should return False")
def step_then_check_migrations_false(context) -> None:
assert context.check_migrations_result is False
@when("I initialize the database with pending migrations detected")
def step_when_pending_migrations_detected(context) -> None:
memory_snapshot = dict(MEMORY_ENGINES)
fake_engine = FakeEngine()
MEMORY_ENGINES[context.runner.database_url] = fake_engine
inspector = MagicMock()
inspector.get_table_names.return_value = ["alembic_version", "users"]
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
return_value=fake_engine,
) as create_engine_mock,
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
patch.object(
MigrationRunner, "get_pending_migrations", return_value=["rev_002"]
),
):
context.runner.init_or_upgrade()
context.pending_upgrade_calls = list(upgrade_mock.call_args_list)
context.pending_create_engine_calls = create_engine_mock.call_count
context.pending_fake_engine = fake_engine
context.pending_engine_connections = list(fake_engine.connections)
context.pending_memory_cache_state = dict(MEMORY_ENGINES)
MEMORY_ENGINES.clear()
MEMORY_ENGINES.update(memory_snapshot)
@then("run migrations should be invoked with the existing engine")
def step_then_run_migrations_existing_engine(context) -> None:
assert len(context.pending_upgrade_calls) == 1
assert context.pending_create_engine_calls == 0
assert len(context.pending_engine_connections) == 2
first_conn, second_conn = context.pending_engine_connections
assert first_conn.exit_called is True
assert second_conn.closed_direct is True
cache_entry = context.pending_memory_cache_state.get(context.runner.database_url)
assert cache_entry is context.pending_fake_engine
@then("the in-memory engine should not be disposed")
def step_then_pending_engine_not_disposed(context) -> None:
assert context.pending_fake_engine.disposed is False
@when("I initialize the database with pending migrations requiring confirmation")
def step_when_pending_migrations_require_confirmation(context) -> None:
inspector = MagicMock()
inspector.get_table_names.return_value = ["alembic_version", "users"]
prompt_calls: list[str] = []
def prompt(message: str) -> bool:
prompt_calls.append(message)
return True
fake_engine = FakeEngine()
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
return_value=fake_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
patch.object(
MigrationRunner,
"get_pending_migrations",
return_value=["rev_002", "rev_003"],
),
):
context.runner.init_or_upgrade(
require_confirmation=True, prompt_for_migration=prompt
)
context.migration_prompt_calls = list(prompt_calls)
context.migration_upgrade_call_count = upgrade_mock.call_count
@then("the migration approval prompt should be invoked")
def step_then_prompt_invoked(context) -> None:
assert len(getattr(context, "migration_prompt_calls", [])) == 1
@then("migrations should run after approval")
def step_then_migrations_run_after_approval(context) -> None:
assert getattr(context, "migration_upgrade_call_count", 0) == 1
@when("I decline migration approval when pending migrations exist")
def step_when_decline_migration(context) -> None:
inspector = MagicMock()
inspector.get_table_names.return_value = ["alembic_version"]
def prompt(_message: str) -> bool:
return False
fake_engine = FakeEngine()
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
return_value=fake_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
patch.object(
MigrationRunner, "get_pending_migrations", return_value=["rev_002"]
),
):
context.migration_error = None
try:
context.runner.init_or_upgrade(
require_confirmation=True, prompt_for_migration=prompt
)
except Exception as exc:
context.migration_error = exc
context.migration_decline_upgrade_calls = upgrade_mock.call_count
@then("a migration approval error should be raised")
def step_then_migration_error(context) -> None:
assert isinstance(context.migration_error, MigrationNotApprovedError)
assert context.migration_decline_upgrade_calls == 0
def _snapshot_env(keys: list[str]) -> dict[str, str | None]:
return {key: os.environ.get(key) for key in keys}
def _restore_env(snapshot: dict[str, str | None]) -> None:
for key, value in snapshot.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
@when("I evaluate the default migration prompt while CI is set")
def step_when_prompt_ci(context) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
try:
os.environ["CI"] = "true"
os.environ.pop("BEHAVE_TESTING", None)
os.environ.pop("CLEVERAGENTS_AUTO_APPLY_MIGRATIONS", None)
context.prompt_result = MigrationRunner._default_prompt_for_migration(
"Apply migrations in CI?"
)
context.prompt_confirm_calls = []
finally:
_restore_env(snapshot)
@then("the default prompt should auto approve without interaction")
def step_then_prompt_auto_approve_ci(context) -> None:
assert context.prompt_result is True
assert getattr(context, "prompt_confirm_calls", []) == []
@when("I evaluate the default migration prompt in an interactive TTY")
def step_when_prompt_tty(context) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
fake_stdin = MagicMock()
fake_stdin.isatty.return_value = True
confirm_calls: list[Any] = []
def confirm(*args: Any, **kwargs: Any) -> bool:
confirm_calls.append((args, kwargs))
return False
fake_typer = MagicMock()
fake_typer.confirm.side_effect = confirm
try:
os.environ.pop("CI", None)
os.environ.pop("BEHAVE_TESTING", None)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = ""
with (
patch("sys.stdin", fake_stdin),
patch.dict(sys.modules, {"typer": fake_typer}),
):
context.prompt_result = MigrationRunner._default_prompt_for_migration(
"Apply migrations interactively?"
)
finally:
_restore_env(snapshot)
context.prompt_confirm_calls = list(confirm_calls)
@then("the default prompt should return the typer confirmation result")
def step_then_prompt_returns_typer(context) -> None:
assert context.prompt_result is False
assert len(getattr(context, "prompt_confirm_calls", [])) == 1
args, kwargs = context.prompt_confirm_calls[0]
assert kwargs.get("default") is False
assert "Apply migrations interactively?" in args[0]
@when("the default migration prompt raises an IOError while prompting")
def step_when_prompt_io_errors(context) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
fake_stdin = MagicMock()
fake_stdin.isatty.return_value = True
confirm_calls: list[Any] = []
def confirm(*args: Any, **kwargs: Any) -> bool:
confirm_calls.append((args, kwargs))
raise OSError("broken stdin")
error_typer = MagicMock()
error_typer.confirm.side_effect = confirm
try:
os.environ.pop("CI", None)
os.environ.pop("BEHAVE_TESTING", None)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = ""
with (
patch("sys.stdin", fake_stdin),
patch.dict(sys.modules, {"typer": error_typer}),
):
context.prompt_result = MigrationRunner._default_prompt_for_migration(
"Apply migrations after IO error?"
)
finally:
_restore_env(snapshot)
context.prompt_error_confirm_calls = list(confirm_calls)
@then("the default prompt should reject migration after the IO failure")
def step_then_prompt_reject_after_io_error(context) -> None:
assert context.prompt_result is False
assert len(getattr(context, "prompt_error_confirm_calls", [])) == 1
@when("I evaluate the default migration prompt in a non-interactive environment")
def step_when_prompt_non_interactive(context) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
fake_stdin = MagicMock()
fake_stdin.isatty.return_value = False
try:
os.environ.pop("CI", None)
os.environ.pop("BEHAVE_TESTING", None)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = ""
with patch("sys.stdin", fake_stdin):
context.prompt_result = MigrationRunner._default_prompt_for_migration(
"Apply migrations in non-interactive mode?"
)
finally:
_restore_env(snapshot)
@then("the default prompt should reject without interaction")
def step_then_prompt_reject_non_interactive(context) -> None:
assert context.prompt_result is False
@when("the default migration prompt raises an EOFError while prompting")
def step_when_prompt_eof_errors(context) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
fake_stdin = MagicMock()
fake_stdin.isatty.return_value = True
confirm_calls: list[Any] = []
def confirm(*args: Any, **kwargs: Any) -> bool:
confirm_calls.append((args, kwargs))
raise EOFError("unexpected end of input")
error_typer = MagicMock()
error_typer.confirm.side_effect = confirm
try:
os.environ.pop("CI", None)
os.environ.pop("BEHAVE_TESTING", None)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = ""
with (
patch("sys.stdin", fake_stdin),
patch.dict(sys.modules, {"typer": error_typer}),
):
context.prompt_result = MigrationRunner._default_prompt_for_migration(
"Apply migrations after EOF error?"
)
finally:
_restore_env(snapshot)
context.prompt_eof_confirm_calls = list(confirm_calls)
@then("the default prompt should reject migration after the EOF failure")
def step_then_prompt_reject_after_eof_error(context) -> None:
assert context.prompt_result is False
assert len(getattr(context, "prompt_eof_confirm_calls", [])) == 1
@when("the default migration prompt receives a KeyboardInterrupt")
def step_when_prompt_keyboard_interrupt(context) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
fake_stdin = MagicMock()
fake_stdin.isatty.return_value = True
def confirm(*args: Any, **kwargs: Any) -> bool:
raise KeyboardInterrupt()
interrupt_typer = MagicMock()
interrupt_typer.confirm.side_effect = confirm
context.keyboard_interrupt_raised = False
try:
os.environ.pop("CI", None)
os.environ.pop("BEHAVE_TESTING", None)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = ""
with (
patch("sys.stdin", fake_stdin),
patch.dict(sys.modules, {"typer": interrupt_typer}),
):
try:
MigrationRunner._default_prompt_for_migration(
"Apply migrations with Ctrl-C?"
)
except KeyboardInterrupt:
context.keyboard_interrupt_raised = True
finally:
_restore_env(snapshot)
@then("the KeyboardInterrupt should propagate from the migration prompt")
def step_then_keyboard_interrupt_propagates(context) -> None:
assert context.keyboard_interrupt_raised is True
@when(
'I evaluate the default migration prompt with CLEVERAGENTS_AUTO_APPLY_MIGRATIONS set to "{value}"'
)
def step_when_prompt_auto_apply_env(context, value: str) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
fake_stdin = MagicMock()
fake_stdin.isatty.return_value = False
try:
os.environ.pop("CI", None)
os.environ.pop("BEHAVE_TESTING", None)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = value
with patch("sys.stdin", fake_stdin):
context.prompt_result = MigrationRunner._default_prompt_for_migration(
"Apply migrations with env var set?"
)
finally:
_restore_env(snapshot)
@when("the default migration prompt raises an unexpected exception while prompting")
def step_when_prompt_unexpected_exception(context) -> None:
snapshot = _snapshot_env(
["CI", "BEHAVE_TESTING", "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"]
)
fake_stdin = MagicMock()
fake_stdin.isatty.return_value = True
confirm_calls: list[Any] = []
def confirm(*args: Any, **kwargs: Any) -> bool:
confirm_calls.append((args, kwargs))
raise RuntimeError("prompt failure")
error_typer = MagicMock()
error_typer.confirm.side_effect = confirm
try:
os.environ.pop("CI", None)
os.environ.pop("BEHAVE_TESTING", None)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = ""
with (
patch("sys.stdin", fake_stdin),
patch.dict(sys.modules, {"typer": error_typer}),
):
context.prompt_result = MigrationRunner._default_prompt_for_migration(
"Apply migrations after unexpected failure?"
)
finally:
_restore_env(snapshot)
context.prompt_unexpected_confirm_calls = list(confirm_calls)
@then("the default prompt should reject migration after the unexpected failure")
def step_then_prompt_reject_after_unexpected_failure(context) -> None:
assert context.prompt_result is False
assert len(getattr(context, "prompt_unexpected_confirm_calls", [])) == 1
@given("the MigrationRunner class is available")
def step_given_migration_runner_available(context) -> None:
"""Make MigrationRunner available in context for testing alembic.ini location."""
context.migration_runner_class = MigrationRunner
@when("I call _find_alembic_ini() to locate the configuration file")
def step_when_find_alembic_ini(context) -> None:
"""Call the _find_alembic_ini method to locate alembic.ini from package."""
try:
context.alembic_ini_path = MigrationRunner._find_alembic_ini()
context.find_alembic_error = None
except FileNotFoundError as exc:
context.find_alembic_error = exc
context.alembic_ini_path = None
@then("it should find alembic.ini at the package location")
def step_then_alembic_ini_found_at_package(context) -> None:
"""Verify alembic.ini is found in the package location."""
assert context.find_alembic_error is None, (
f"Expected to find alembic.ini but got error: {context.find_alembic_error}"
)
assert context.alembic_ini_path is not None
assert context.alembic_ini_path.exists(), (
f"alembic.ini path {context.alembic_ini_path} does not exist"
)
assert "migrations" in str(context.alembic_ini_path).lower(), (
f"Expected alembic.ini in migrations directory, got {context.alembic_ini_path}"
)
@then("the alembic.ini should be readable and valid")
def step_then_alembic_ini_readable(context) -> None:
"""Verify alembic.ini is readable and contains expected configuration."""
assert context.alembic_ini_path.is_file()
content = context.alembic_ini_path.read_text(encoding="utf-8")
assert "[loggers]" in content, "alembic.ini missing loggers section"
assert "script_location" in content, "alembic.ini missing script_location"