Release v0.7.0 #113
@@ -18,6 +18,7 @@ backfill releases from before this changelog.
|
||||
- Jump to the first or last point with Home and End on every graph, and a week at a time with PgUp and PgDn in the Day and History views.
|
||||
- Move through graph points with the mouse wheel, focus any panel by clicking it, and click the footer and activity tool chips to run their actions.
|
||||
- Open a Diagnostics panel with `i`: the decoded critical warning, power-on hours, power cycles, unsafe shutdowns, media errors and available spare with their change over the selection, plus hourly or daily temperature, active/idle/powered-off/unknown split and coverage. History bars draw reads beside writes while it is open, and a set critical warning shows as a badge in the endurance outlook.
|
||||
- Record more drive evidence from each collection run (host read and write commands, controller busy time, error log entries, warning and critical temperature time, and thermal management transitions where the drive reports them) and show it in the Diagnostics panel. Existing stores migrate automatically; earlier samples show n/a for the new counters.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -51,8 +51,25 @@ DIAGNOSTIC_COUNTERS = (
|
||||
("unsafe_shutdowns", "Unsafe shutdowns", ""),
|
||||
("media_errors", "Media errors", ""),
|
||||
("available_spare", "Available spare", "%"),
|
||||
("host_reads", "Host reads", ""),
|
||||
("host_writes", "Host writes", ""),
|
||||
("controller_busy_time", "Controller busy time", " min"),
|
||||
("num_err_log_entries", "Error log entries", ""),
|
||||
("warning_temp_time", "Warning temperature time", " min"),
|
||||
("critical_comp_time", "Critical temperature time", " min"),
|
||||
("thm_temp1_trans_count", "Thermal management 1 transitions", ""),
|
||||
("thm_temp1_total_time", "Thermal management 1 time", " s"),
|
||||
("thm_temp2_trans_count", "Thermal management 2 transitions", ""),
|
||||
("thm_temp2_total_time", "Thermal management 2 time", " s"),
|
||||
)
|
||||
|
||||
# Reported only by drives that implement thermal management; with no value the
|
||||
# panel omits them instead of listing four permanent n/a rows.
|
||||
_OPTIONAL_DIAGNOSTICS = frozenset((
|
||||
"thm_temp1_trans_count", "thm_temp1_total_time",
|
||||
"thm_temp2_trans_count", "thm_temp2_total_time",
|
||||
))
|
||||
|
||||
LOCAL_ACTIVITY_STATE_LABELS = {
|
||||
"so_far": "totals so far",
|
||||
"incomplete": "incomplete",
|
||||
@@ -689,7 +706,10 @@ def _counter_deltas(
|
||||
inside the window; with fewer than two distinct samples, or across a
|
||||
controller-segment change, there is no delta.
|
||||
"""
|
||||
columns = ", ".join(key for key, _, _ in DIAGNOSTIC_COUNTERS)
|
||||
# A store not yet migrated lacks the newer counter columns: read them as unknown.
|
||||
present = {row[1] for row in conn.execute("PRAGMA table_info(samples)")}
|
||||
columns = ", ".join(
|
||||
key if key in present else "NULL" for key, _, _ in DIAGNOSTIC_COUNTERS)
|
||||
columns += ", segment_id"
|
||||
last = conn.execute(
|
||||
"SELECT id, %s FROM samples WHERE ts <= ? ORDER BY ts DESC, id DESC "
|
||||
@@ -1016,6 +1036,8 @@ def diagnostics_text(activity: ActivityReadout) -> str:
|
||||
for key, label, unit in DIAGNOSTIC_COUNTERS:
|
||||
counter = diag["counters"][key]
|
||||
if counter["value"] is None:
|
||||
if key in _OPTIONAL_DIAGNOSTICS:
|
||||
continue
|
||||
lines.append("%s n/a" % label)
|
||||
continue
|
||||
delta = "Δ n/a" if counter["delta"] is None else "Δ " + _signed(counter["delta"])
|
||||
|
||||
@@ -19,7 +19,7 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from .derive import derive_hours_from_interval, find_previous_sample
|
||||
from .monitoring_periods import ensure_period_open
|
||||
from .store import init_store, get_store_path
|
||||
from .store import DIAGNOSTIC_SAMPLE_COLUMNS, init_store, get_store_path
|
||||
|
||||
|
||||
class AcquisitionError(Exception):
|
||||
@@ -103,6 +103,8 @@ def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"bytes_written": log["data_units_written"] * 512000,
|
||||
"bytes_read": log["data_units_read"] * 512000,
|
||||
"critical_warning": log.get("critical_warning", 0),
|
||||
# Diagnostic counters (issue #111); absent keys stay None, never zero.
|
||||
**{column: log.get(column) for column in DIAGNOSTIC_SAMPLE_COLUMNS},
|
||||
}
|
||||
|
||||
|
||||
@@ -259,9 +261,10 @@ def write_sample(
|
||||
percentage_used, available_spare, media_errors, power_on_hours,
|
||||
power_cycles, unsafe_shutdowns, temperature_c,
|
||||
data_units_written, data_units_read, bytes_written, bytes_read,
|
||||
critical_warning, segment_id, local_tz
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
critical_warning, segment_id, local_tz, %s
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)
|
||||
""" % (", ".join(DIAGNOSTIC_SAMPLE_COLUMNS),
|
||||
", ".join("?" * len(DIAGNOSTIC_SAMPLE_COLUMNS))),
|
||||
(
|
||||
sample["ts"],
|
||||
sample["device"],
|
||||
@@ -284,6 +287,8 @@ def write_sample(
|
||||
sample["critical_warning"],
|
||||
segment_id,
|
||||
sample.get("local_tz"),
|
||||
# Samples staged by an earlier version lack these keys.
|
||||
*(sample.get(column) for column in DIAGNOSTIC_SAMPLE_COLUMNS),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+45
-2
@@ -11,7 +11,23 @@ from typing import Optional
|
||||
|
||||
|
||||
# Schema version - increment on each migration
|
||||
SCHEMA_VERSION = 6
|
||||
SCHEMA_VERSION = 7
|
||||
|
||||
# Diagnostic counters added in schema 7 (issue #111), named after the
|
||||
# NVMe health-log keys they come from. All nullable: legacy rows and drives that
|
||||
# do not report a counter stay unknown rather than zero.
|
||||
DIAGNOSTIC_SAMPLE_COLUMNS = (
|
||||
"host_reads",
|
||||
"host_writes",
|
||||
"controller_busy_time",
|
||||
"num_err_log_entries",
|
||||
"warning_temp_time",
|
||||
"critical_comp_time",
|
||||
"thm_temp1_trans_count",
|
||||
"thm_temp1_total_time",
|
||||
"thm_temp2_trans_count",
|
||||
"thm_temp2_total_time",
|
||||
)
|
||||
|
||||
|
||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||
@@ -109,7 +125,17 @@ def _create_schema(conn: sqlite3.Connection):
|
||||
bytes_read INTEGER,
|
||||
critical_warning INTEGER,
|
||||
segment_id INTEGER,
|
||||
local_tz TEXT
|
||||
local_tz TEXT,
|
||||
host_reads INTEGER,
|
||||
host_writes INTEGER,
|
||||
controller_busy_time INTEGER,
|
||||
num_err_log_entries INTEGER,
|
||||
warning_temp_time INTEGER,
|
||||
critical_comp_time INTEGER,
|
||||
thm_temp1_trans_count INTEGER,
|
||||
thm_temp1_total_time INTEGER,
|
||||
thm_temp2_trans_count INTEGER,
|
||||
thm_temp2_total_time INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -303,6 +329,7 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
||||
4: _migrate_3_to_4,
|
||||
5: _migrate_4_to_5,
|
||||
6: _migrate_5_to_6,
|
||||
7: _migrate_6_to_7,
|
||||
}
|
||||
while current_version < SCHEMA_VERSION:
|
||||
target_version = current_version + 1
|
||||
@@ -408,6 +435,22 @@ def _migrate_5_to_6(conn: sqlite3.Connection) -> None:
|
||||
repair_legacy_local_day_evidence(conn)
|
||||
|
||||
|
||||
def _migrate_6_to_7(conn: sqlite3.Connection) -> None:
|
||||
"""Add nullable diagnostic counters to samples (issue #111).
|
||||
|
||||
Additive only: existing rows are untouched and read NULL (unknown).
|
||||
Rollback (SQLite 3.35+, with the collector stopped): for each column in
|
||||
DIAGNOSTIC_SAMPLE_COLUMNS run ``ALTER TABLE samples DROP COLUMN <name>``,
|
||||
then ``PRAGMA user_version=6``. Dropping loses only the new counters.
|
||||
"""
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
|
||||
if not cols:
|
||||
return
|
||||
for column in DIAGNOSTIC_SAMPLE_COLUMNS:
|
||||
if column not in cols:
|
||||
conn.execute(f"ALTER TABLE samples ADD COLUMN {column} INTEGER")
|
||||
|
||||
|
||||
def migrate_to_latest(store_path: Path) -> int:
|
||||
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ class TestSchemaMigration:
|
||||
|
||||
# Migrate
|
||||
steps = migrate_to_latest(db)
|
||||
assert steps == 5 # v1→v2→v3→v4→v5→v6
|
||||
assert steps == 6 # v1→v2→v3→v4→v5→v6→v7
|
||||
|
||||
# Verify data preserved
|
||||
conn = sqlite3.connect(str(db))
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Additional smartctl diagnostics (issue #111).
|
||||
|
||||
Field list fixed from real `smartctl -a -j` output (smartctl 7.5, NVMe 1.4):
|
||||
nvme_smart_health_information_log carries warning_temp_time, critical_comp_time,
|
||||
num_err_log_entries, host_reads, host_writes and controller_busy_time. The
|
||||
thermal-management transition fields (thm_temp1/2_trans_count, thm_temp1/2_total_time)
|
||||
appear only on drives that implement them, so they stay nullable.
|
||||
"""
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.activity_readout import diagnostics_text, readout
|
||||
from fenris.activity_selection import ActivitySelection
|
||||
from fenris.collector import acquire_from_smartctl, run_collection
|
||||
from fenris.store import SCHEMA_VERSION, init_store, migrate_to_latest
|
||||
|
||||
NEW_COLUMNS = (
|
||||
"host_reads", "host_writes", "controller_busy_time", "num_err_log_entries",
|
||||
"warning_temp_time", "critical_comp_time",
|
||||
"thm_temp1_trans_count", "thm_temp1_total_time",
|
||||
"thm_temp2_trans_count", "thm_temp2_total_time",
|
||||
)
|
||||
|
||||
|
||||
def _smartctl(**extra_log):
|
||||
"""Shape of real smartctl -a -j output for an NVMe drive."""
|
||||
log = {
|
||||
"critical_warning": 0, "temperature": 37, "available_spare": 100,
|
||||
"available_spare_threshold": 5, "percentage_used": 52,
|
||||
"data_units_read": 207986339, "data_units_written": 202978899,
|
||||
"host_reads": 3543447796, "host_writes": 2458441042,
|
||||
"controller_busy_time": 61626, "power_cycles": 5324,
|
||||
"power_on_hours": 11906, "unsafe_shutdowns": 159, "media_errors": 0,
|
||||
"num_err_log_entries": 0, "warning_temp_time": 0, "critical_comp_time": 0,
|
||||
"temperature_sensors": [37],
|
||||
}
|
||||
log.update(extra_log)
|
||||
return {
|
||||
"nvme_smart_health_information_log": log,
|
||||
"user_capacity": {"bytes": 512110190592},
|
||||
"model_name": "Drive", "serial_number": "SN1", "firmware_version": "FW1",
|
||||
}
|
||||
|
||||
|
||||
class TestAcquisition:
|
||||
def test_real_fields_are_captured(self):
|
||||
got = acquire_from_smartctl(_smartctl())
|
||||
assert got["host_reads"] == 3543447796
|
||||
assert got["host_writes"] == 2458441042
|
||||
assert got["controller_busy_time"] == 61626
|
||||
assert got["num_err_log_entries"] == 0
|
||||
assert got["warning_temp_time"] == 0
|
||||
assert got["critical_comp_time"] == 0
|
||||
|
||||
def test_absent_thermal_management_fields_are_none_not_zero(self):
|
||||
got = acquire_from_smartctl(_smartctl())
|
||||
for key in ("thm_temp1_trans_count", "thm_temp1_total_time",
|
||||
"thm_temp2_trans_count", "thm_temp2_total_time"):
|
||||
assert got[key] is None
|
||||
|
||||
def test_thermal_management_fields_captured_when_reported(self):
|
||||
got = acquire_from_smartctl(_smartctl(
|
||||
thm_temp1_trans_count=3, thm_temp1_total_time=120,
|
||||
thm_temp2_trans_count=1, thm_temp2_total_time=45))
|
||||
assert (got["thm_temp1_trans_count"], got["thm_temp1_total_time"]) == (3, 120)
|
||||
assert (got["thm_temp2_trans_count"], got["thm_temp2_total_time"]) == (1, 45)
|
||||
|
||||
def test_missing_optional_counters_do_not_fail_the_run(self):
|
||||
data = _smartctl()
|
||||
for key in ("host_reads", "host_writes", "controller_busy_time",
|
||||
"num_err_log_entries", "warning_temp_time", "critical_comp_time"):
|
||||
del data["nvme_smart_health_information_log"][key]
|
||||
got = acquire_from_smartctl(data)
|
||||
assert got["host_reads"] is None and got["warning_temp_time"] is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sysfs(tmp_path):
|
||||
ctrl = tmp_path / "nvme0"
|
||||
ctrl.mkdir()
|
||||
for name, value in (("subsysnqn", "nqn.test"), ("model", "Drive"),
|
||||
("serial", "SN1"), ("firmware_rev", "FW1")):
|
||||
(ctrl / name).write_text(value + "\n")
|
||||
return ctrl
|
||||
|
||||
|
||||
class _Clock:
|
||||
def utcnow(self):
|
||||
return datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_collection_persists_new_fields(tmp_path, sysfs):
|
||||
config = {"device": "/dev/nvme0", "store_path": str(tmp_path / "o.db")}
|
||||
result = run_collection(smartctl_data=_smartctl(thm_temp1_trans_count=3),
|
||||
sysfs_path=sysfs, config=config, clock=_Clock())
|
||||
assert result["ok"], result
|
||||
conn = sqlite3.connect(config["store_path"])
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute("SELECT * FROM samples").fetchone()
|
||||
assert row["host_reads"] == 3543447796
|
||||
assert row["controller_busy_time"] == 61626
|
||||
assert row["thm_temp1_trans_count"] == 3
|
||||
assert row["thm_temp2_trans_count"] is None
|
||||
|
||||
|
||||
def _legacy_store(path):
|
||||
"""A populated schema-6 store, as shipped before this change."""
|
||||
conn = init_store(path)
|
||||
for column in reversed(NEW_COLUMNS):
|
||||
conn.execute(f"ALTER TABLE samples DROP COLUMN {column}")
|
||||
conn.execute("PRAGMA user_version=6")
|
||||
for i in range(3):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, bytes_written, bytes_read, "
|
||||
"power_on_hours, critical_warning, segment_id) VALUES (?,?,?,?,?,?,1)",
|
||||
("2026-09-30T1%d:00:00+00:00" % i, "/dev/nvme0", 10 + i, 5, 100 + i, 0))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestMigration:
|
||||
def test_schema_version_is_seven(self):
|
||||
assert SCHEMA_VERSION == 7
|
||||
|
||||
def test_populated_legacy_store_keeps_rows_and_gains_null_columns(self, tmp_path):
|
||||
db = tmp_path / "o.db"
|
||||
_legacy_store(db)
|
||||
before = sqlite3.connect(db).execute(
|
||||
"SELECT id, ts, device, bytes_written, power_on_hours FROM samples "
|
||||
"ORDER BY id").fetchall()
|
||||
assert migrate_to_latest(db) == 1
|
||||
conn = sqlite3.connect(db)
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == 7
|
||||
assert conn.execute(
|
||||
"SELECT id, ts, device, bytes_written, power_on_hours FROM samples "
|
||||
"ORDER BY id").fetchall() == before
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(samples)")}
|
||||
assert set(NEW_COLUMNS) <= cols
|
||||
nulls = conn.execute(
|
||||
"SELECT %s FROM samples" % " OR ".join(c + " IS NOT NULL" for c in NEW_COLUMNS)
|
||||
).fetchall()
|
||||
assert nulls == [(0,)] * 3 # legacy rows stay unknown, never zero
|
||||
|
||||
def test_migration_is_idempotent_and_init_store_applies_it(self, tmp_path):
|
||||
db = tmp_path / "o.db"
|
||||
_legacy_store(db)
|
||||
conn = init_store(db)
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == 7
|
||||
conn.close()
|
||||
assert migrate_to_latest(db) == 0
|
||||
|
||||
def test_pending_payload_without_new_fields_still_publishes(self, tmp_path):
|
||||
"""Observations staged by the previous version lack the new keys."""
|
||||
db = tmp_path / "o.db"
|
||||
conn = init_store(db)
|
||||
from fenris.collector import write_sample
|
||||
sample = {k: v for k, v in acquire_from_smartctl(_smartctl()).items()
|
||||
if k not in NEW_COLUMNS}
|
||||
sample.update(ts="2026-09-01T12:00:00+00:00", device="/dev/nvme0")
|
||||
write_sample(sample, {"subnqn": "nqn.test"}, conn,
|
||||
datetime(2026, 9, 1, 12, tzinfo=timezone.utc))
|
||||
assert conn.execute("SELECT host_reads FROM samples").fetchone() == (None,)
|
||||
|
||||
def test_rollback_notes_procedure_restores_prior_schema(self, tmp_path):
|
||||
"""Documented rollback: drop the columns, set user_version back to 6."""
|
||||
db = tmp_path / "o.db"
|
||||
_legacy_store(db)
|
||||
migrate_to_latest(db)
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"UPDATE samples SET host_reads = 1 WHERE id = 1")
|
||||
for column in NEW_COLUMNS:
|
||||
conn.execute(f"ALTER TABLE samples DROP COLUMN {column}")
|
||||
conn.execute("PRAGMA user_version=6")
|
||||
conn.commit()
|
||||
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone() == (3,)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(samples)")}
|
||||
assert not set(NEW_COLUMNS) & cols
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 30, 20, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _diag_sample(conn, ts, **values):
|
||||
cols = ["ts", "device", "bytes_written", "bytes_read", "segment_id"] + list(values)
|
||||
conn.execute(
|
||||
"INSERT INTO samples (%s) VALUES (%s)" % (
|
||||
",".join(cols), ",".join("?" * len(cols))),
|
||||
[ts, "/dev/nvme0n1", 1, 1, 1] + list(values.values()))
|
||||
conn.commit()
|
||||
|
||||
|
||||
class TestDiagnosticsPanel:
|
||||
@pytest.fixture
|
||||
def conn(self, tmp_path):
|
||||
c = init_store(tmp_path / "o.db")
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
def _text(self, conn):
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
return diagnostics_text(readout(conn, selection, NOW))
|
||||
|
||||
def test_new_counters_show_value_and_delta(self, conn):
|
||||
_diag_sample(conn, "2026-09-30T18:00:00+00:00", host_reads=1000,
|
||||
host_writes=500, controller_busy_time=60,
|
||||
num_err_log_entries=0, warning_temp_time=2,
|
||||
critical_comp_time=0)
|
||||
_diag_sample(conn, "2026-09-30T19:50:00+00:00", host_reads=1300,
|
||||
host_writes=550, controller_busy_time=65,
|
||||
num_err_log_entries=1, warning_temp_time=2,
|
||||
critical_comp_time=0)
|
||||
text = self._text(conn)
|
||||
assert "Host reads 1300 Δ +300" in text
|
||||
assert "Host writes 550 Δ +50" in text
|
||||
assert "Controller busy time 65 min Δ +5" in text
|
||||
assert "Error log entries 1 Δ +1" in text
|
||||
assert "Warning temperature time 2 min Δ ±0" in text
|
||||
assert "Critical temperature time 0 min Δ ±0" in text
|
||||
|
||||
def test_legacy_rows_read_n_a_and_absent_thermal_management_is_hidden(self, conn):
|
||||
_diag_sample(conn, "2026-09-30T19:50:00+00:00", power_on_hours=5)
|
||||
text = self._text(conn)
|
||||
assert "Host reads n/a" in text
|
||||
assert "Thermal management" not in text
|
||||
|
||||
def test_thermal_management_shown_when_the_drive_reports_it(self, conn):
|
||||
_diag_sample(conn, "2026-09-30T19:50:00+00:00", thm_temp1_trans_count=3,
|
||||
thm_temp1_total_time=120, thm_temp2_trans_count=1,
|
||||
thm_temp2_total_time=45)
|
||||
text = self._text(conn)
|
||||
assert "Thermal management 1 transitions 3 Δ n/a" in text
|
||||
assert "Thermal management 1 time 120 s Δ n/a" in text
|
||||
assert "Thermal management 2 transitions 1 Δ n/a" in text
|
||||
|
||||
def test_unmigrated_store_still_renders_diagnostics(self, tmp_path):
|
||||
"""The dashboard may open a store before the upgrade migrates it."""
|
||||
db = tmp_path / "legacy.db"
|
||||
_legacy_store(db)
|
||||
conn = sqlite3.connect(db)
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
text = diagnostics_text(readout(conn, selection, NOW))
|
||||
assert "Host reads n/a" in text and "Power-on hours" in text
|
||||
@@ -12,7 +12,7 @@ from fenris import collector
|
||||
from fenris.projection import compute_projection
|
||||
from fenris.status import _query_drive_facts, get_status, read_status
|
||||
from fenris.status_composition import render_status_tui
|
||||
from fenris.store import init_store
|
||||
from fenris.store import SCHEMA_VERSION, init_store
|
||||
|
||||
|
||||
class FakeClock:
|
||||
@@ -288,7 +288,7 @@ def test_v3_readers_ignore_pending_table_until_store_migrates(tmp_path):
|
||||
|
||||
migrated = init_store(store_path)
|
||||
try:
|
||||
assert migrated.execute("PRAGMA user_version").fetchone()[0] == 6
|
||||
assert migrated.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
||||
assert migrated.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||
finally:
|
||||
migrated.close()
|
||||
|
||||
Reference in New Issue
Block a user