diff --git a/pyproject.toml b/pyproject.toml index 14a24c5..81f1ca8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,52 @@ exclude = ["tests*"] [tool.setuptools.package-data] "baudolo.restore.db" = ["*.sql"] + +[tool.ruff] +respect-gitignore = true +# The package still declares >=3.9, so pyupgrade must not propose 3.10+ syntax. +target-version = "py39" +exclude = ["build", "dist", "*.egg-info", ".venv", "venv"] + +[tool.ruff.lint] +# Adopted from infinito-nexus-core so both repositories are held to one bar; +# see that project's pyproject.toml for what each selector buys. +select = [ + "E", "F", "I", "B", "UP", "RUF", "SIM", "C4", "PERF", "RET", "PIE", + "T10", "PGH", "EXE", "RSE", "ICN", "DTZ", + "TID", "LOG", "G", + "S", + "PTH", + "FURB", "W", "FA", "YTT", "A", "ISC", "SLOT", "FLY", + "PYI", + "TC", "N", + "PLE0605", + "PLW1510", "PLW2901", "PLW0108", "PLW0603", + "PLR5501", "PLC0207", "PLR1722", "PLR1714", + "TRY002", "TRY004", "TRY300", "TRY301", + "BLE001", +] + +# E501: `ruff format` reflows what it can; the rest is unsplittable literals. +# RUF001/002/003: the prose uses em-dashes deliberately, not homoglyphs. +# S603/S607: this tool's whole job is running `docker` / dump binaries from +# PATH in list form, which is already injection-safe. +# PTH207/PTH208: changing `glob.glob`/`os.listdir` return shapes needs a +# per-call-site review, not a blanket rewrite. +ignore = [ + "E501", + "RUF001", "RUF002", "RUF003", + "S603", "S607", + "PTH207", "PTH208", +] + +[tool.ruff.lint.per-file-ignores] +# Test code legitimately uses what flake8-bandit flags in production code: +# asserts, dummy credentials, /tmp fixtures, broad excepts in teardown, and +# SQL built from fixture names (S608) to set the databases under test up. +"tests/**" = [ + "S101", "S102", "S105", "S106", "S108", "S110", "S112", "S608", "BLE001", +] + +# The e2e helpers package is a deliberate re-export aggregator. +"tests/e2e/helpers/__init__.py" = ["F403"] diff --git a/src/baudolo/backup/compose.py b/src/baudolo/backup/compose.py index b3846ff..a32263e 100644 --- a/src/baudolo/backup/compose.py +++ b/src/baudolo/backup/compose.py @@ -82,7 +82,7 @@ def handle_docker_compose_services( continue dir_path = entry.path - name = os.path.basename(dir_path) + name = Path(dir_path).name print(f"Checking directory: {dir_path}", flush=True) diff --git a/src/baudolo/databases.py b/src/baudolo/databases.py index 62833dd..bab385a 100644 --- a/src/baudolo/databases.py +++ b/src/baudolo/databases.py @@ -14,6 +14,7 @@ from __future__ import annotations import csv import re +from pathlib import Path from typing import NamedTuple COLUMNS = ("instance", "database", "username", "password") @@ -87,7 +88,7 @@ def read_rows(csv_path: str) -> list[Row]: DatabasesCsvError: a row holds fewer columns than :data:`COLUMNS`. """ rows: list[Row] = [] - with open(csv_path, newline="", encoding="utf-8") as handle: + with Path(csv_path).open(newline="", encoding="utf-8") as handle: reader = csv.reader(handle, delimiter=DELIMITER) next(reader, None) for raw in reader: diff --git a/src/baudolo/restore/__main__.py b/src/baudolo/restore/__main__.py index 33abf5c..705f7b2 100644 --- a/src/baudolo/restore/__main__.py +++ b/src/baudolo/restore/__main__.py @@ -165,7 +165,7 @@ def main(argv: list[str] | None = None) -> int: return 0 parser.error("Unhandled command") - return 2 + return 2 # noqa: TRY300 - the try wraps the whole dispatch on purpose except Exception as e: # noqa: BLE001 - CLI boundary: any failure becomes exit 1 print(f"ERROR: {e}", file=sys.stderr) diff --git a/src/baudolo/restore/db/cluster.py b/src/baudolo/restore/db/cluster.py index bf1ace8..928a1cd 100644 --- a/src/baudolo/restore/db/cluster.py +++ b/src/baudolo/restore/db/cluster.py @@ -19,16 +19,20 @@ the implementation: from __future__ import annotations -import os import re import tempfile -from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import TYPE_CHECKING + +from baudolo.restore.run import docker_exec -from ..run import docker_exec from .version import guard +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + CONTROL_DB = "postgres" -_CLUSTER_PRECLEAN_SQL = os.path.join(os.path.dirname(__file__), "cluster_preclean.sql") +_CLUSTER_PRECLEAN_SQL = Path(__file__).parent / "cluster_preclean.sql" _CREATE_ROLE = re.compile(rb'^CREATE ROLE "?([^";]+)"?;\s*$') _CREATE_DATABASE = re.compile(rb"^CREATE DATABASE\s+(.*)$") _CREATE_ROLE_LINE = re.compile(rb"^CREATE ROLE\s+(.*)$") @@ -92,7 +96,7 @@ def dump_inventory(sql_path: str) -> tuple[list[str], list[str]]: """ databases: list[str] = [] roles: list[str] = [] - with open(sql_path, "rb") as handle: + with Path(sql_path).open("rb") as handle: for raw in handle: line = raw.decode("utf-8", "replace") for pattern, sink, read in ( @@ -111,7 +115,7 @@ def dump_inventory(sql_path: str) -> tuple[list[str], list[str]]: def preclean_sql() -> str: """The catalog-wide pre-clean, safe only behind the instance check.""" - with open(_CLUSTER_PRECLEAN_SQL, encoding="utf-8") as preclean: + with _CLUSTER_PRECLEAN_SQL.open(encoding="utf-8") as preclean: return preclean.read() @@ -158,7 +162,7 @@ def assert_instance_matches_dump( if foreign: raise RuntimeError( f"{container} also holds {', '.join(foreign)}, which " - f"{os.path.basename(sql_path)} does not carry. --empty wipes the " + f"{Path(sql_path).name} does not carry. --empty wipes the " "instance, so those would be destroyed with nothing to restore " "them from. Move them off this instance, or drop them yourself if " "they are disposable." @@ -216,7 +220,7 @@ def restore_cluster_sql( check_version: refuse a dump from a newer major version than the running engine before anything is dropped. """ - if not os.path.isfile(sql_path): + if not Path(sql_path).is_file(): raise FileNotFoundError(sql_path) if check_version: @@ -239,10 +243,10 @@ def restore_cluster_sql( docker_env=docker_env, ) - with open(sql_path, "rb") as src, tempfile.TemporaryFile() as filtered: + with Path(sql_path).open("rb") as src, tempfile.TemporaryFile() as filtered: for line in filter_own_role_creation(src, user): filtered.write(line) filtered.seek(0) docker_exec(container, _psql(user), stdin=filtered, docker_env=docker_env) - print(f"PostgreSQL cluster restore complete from '{os.path.basename(sql_path)}'.") + print(f"PostgreSQL cluster restore complete from '{Path(sql_path).name}'.") diff --git a/src/baudolo/restore/db/mariadb.py b/src/baudolo/restore/db/mariadb.py index ce7baed..26f4eb8 100644 --- a/src/baudolo/restore/db/mariadb.py +++ b/src/baudolo/restore/db/mariadb.py @@ -1,11 +1,14 @@ from __future__ import annotations -import os import sys +from pathlib import Path + +from baudolo.restore.run import docker_exec, docker_exec_sh -from ..run import docker_exec, docker_exec_sh from .version import guard +_NO_CLIENT = "ERROR: neither 'mariadb' nor 'mysql' found in container." + def _pick_client(container: str) -> str: """ @@ -20,14 +23,13 @@ exit 42 """ try: out = docker_exec_sh(container, script, capture=True).stdout.decode().strip() - if not out: - raise RuntimeError("empty client detection output") - return out except Exception: - print( - "ERROR: neither 'mariadb' nor 'mysql' found in container.", file=sys.stderr - ) + print(_NO_CLIENT, file=sys.stderr) raise + if not out: + print(_NO_CLIENT, file=sys.stderr) + raise RuntimeError("empty client detection output") + return out def restore_mariadb_sql( @@ -42,7 +44,7 @@ def restore_mariadb_sql( ) -> None: client = _pick_client(container) - if not os.path.isfile(sql_path): + if not Path(sql_path).is_file(): raise FileNotFoundError(sql_path) if check_version: @@ -66,7 +68,7 @@ def restore_mariadb_sql( f"--password={password}", "-N", "-e", - f"SELECT table_name FROM information_schema.tables WHERE table_schema = '{db_name}';", + f"SELECT table_name FROM information_schema.tables WHERE table_schema = '{db_name}';", # noqa: S608 - validate_database() constrains the name to ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ], capture=True, ) @@ -94,7 +96,7 @@ def restore_mariadb_sql( ], ) - with open(sql_path, "rb") as f: + with Path(sql_path).open("rb") as f: docker_exec( container, [client, "-u", user, f"--password={password}", db_name], stdin=f ) diff --git a/src/baudolo/restore/db/postgres.py b/src/baudolo/restore/db/postgres.py index 27ff59a..a4013dd 100644 --- a/src/baudolo/restore/db/postgres.py +++ b/src/baudolo/restore/db/postgres.py @@ -1,14 +1,18 @@ from __future__ import annotations -import os import tempfile -from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import TYPE_CHECKING + +from baudolo.restore.run import docker_exec -from ..run import docker_exec from .version import guard +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + _SUPERUSER_ONLY_PREFIXES = (b"COMMENT ON EXTENSION", b"ALTER DEFAULT PRIVILEGES") -_EMPTY_PRECLEAN_SQL = os.path.join(os.path.dirname(__file__), "empty_preclean.sql") +_EMPTY_PRECLEAN_SQL = Path(__file__).parent / "empty_preclean.sql" def filter_superuser_only_lines(lines: Iterable[bytes]) -> Iterator[bytes]: @@ -49,7 +53,7 @@ def restore_postgres_sql( empty: bool, check_version: bool = True, ) -> None: - if not os.path.isfile(sql_path): + if not Path(sql_path).is_file(): raise FileNotFoundError(sql_path) if check_version: @@ -64,7 +68,7 @@ def restore_postgres_sql( docker_env = {"PGPASSWORD": password} if empty: - with open(_EMPTY_PRECLEAN_SQL, encoding="utf-8") as preclean: + with _EMPTY_PRECLEAN_SQL.open(encoding="utf-8") as preclean: drop_sql = preclean.read() docker_exec( container, @@ -76,7 +80,7 @@ def restore_postgres_sql( # Filter into a spooled temp file instead of building the whole dump in # memory: production dumps reach many GB and the previous read/splitlines/ # join needed roughly three times the dump size in RSS. - with open(sql_path, "rb") as src, tempfile.TemporaryFile() as filtered: + with Path(sql_path).open("rb") as src, tempfile.TemporaryFile() as filtered: for line in filter_superuser_only_lines(src): filtered.write(line) filtered.seek(0) diff --git a/src/baudolo/restore/db/version.py b/src/baudolo/restore/db/version.py index 0db51eb..9ad4172 100644 --- a/src/baudolo/restore/db/version.py +++ b/src/baudolo/restore/db/version.py @@ -24,8 +24,9 @@ with the cluster banner and the roles section, and the first from __future__ import annotations import re +from pathlib import Path -from ..run import docker_exec, stdout_of +from baudolo.restore.run import docker_exec, stdout_of SCAN_LINES = 2000 DUMP_VERSION = { @@ -34,7 +35,7 @@ DUMP_VERSION = { } -class VersionMismatch(Exception): +class VersionMismatchError(Exception): """The dump cannot be replayed into this engine.""" @@ -46,11 +47,11 @@ def major_of(version: str) -> int: ``11.8.8-MariaDB-ubu2404``. Raises: - VersionMismatch: the string does not start with a number. + VersionMismatchError: the string does not start with a number. """ leading = re.match(r"(\d+)", version) if not leading: - raise VersionMismatch(f"cannot read a major version from '{version}'") + raise VersionMismatchError(f"cannot read a major version from '{version}'") return int(leading.group(1)) @@ -65,10 +66,10 @@ def dump_version(sql_path: str, engine: str) -> str: The version string as the dump spells it. Raises: - VersionMismatch: no version line within the first ``SCAN_LINES``. + VersionMismatchError: no version line within the first ``SCAN_LINES``. """ pattern = DUMP_VERSION[engine] - with open(sql_path, encoding="utf-8", errors="replace") as handle: + with Path(sql_path).open(encoding="utf-8", errors="replace") as handle: for _ in range(SCAN_LINES): line = handle.readline() if not line: @@ -76,7 +77,7 @@ def dump_version(sql_path: str, engine: str) -> str: found = pattern.search(line) if found: return found.group(1) - raise VersionMismatch( + raise VersionMismatchError( f"{sql_path} carries no {engine} version header in its first {SCAN_LINES} lines" ) @@ -120,10 +121,10 @@ def assert_replayable(sql_path: str, engine: str, dumped: str, serving: str) -> server rejects and the pre-clean would already have dropped the schema. Raises: - VersionMismatch: the dump is newer than the engine. + VersionMismatchError: the dump is newer than the engine. """ if major_of(dumped) > major_of(serving): - raise VersionMismatch( + raise VersionMismatchError( f"{sql_path} came from {engine} {dumped} but {serving} is running; " "a newer dump does not replay into an older engine, and --empty " "would drop the schema before finding out" diff --git a/src/baudolo/restore/files.py b/src/baudolo/restore/files.py index 7756343..ff05112 100644 --- a/src/baudolo/restore/files.py +++ b/src/baudolo/restore/files.py @@ -12,6 +12,7 @@ from __future__ import annotations import os import sys +from pathlib import Path from .run import docker_volume_exists, run, stdout_of @@ -21,7 +22,7 @@ INSPECT_FORMAT = ( def restore_volume_files(volume_name: str, backup_files_dir: str) -> int: - if not os.path.isdir(backup_files_dir): + if not Path(backup_files_dir).is_dir(): print(f"ERROR: backup files dir not found: {backup_files_dir}", file=sys.stderr) return 2 @@ -44,7 +45,7 @@ def restore_volume_files(volume_name: str, backup_files_dir: str) -> int: ) return 2 - driver, options = (fields + ["local", "plain"])[1:3] + driver, options = ([*fields, "local", "plain"])[1:3] if (driver != "local" or options == "opts") and not os.path.ismount(mountpoint): print( f"ERROR: volume {volume_name} has a backing store of its own " @@ -55,8 +56,9 @@ def restore_volume_files(volume_name: str, backup_files_dir: str) -> int: ) return 2 - src = os.path.join(backup_files_dir, "") - dest = os.path.join(mountpoint, "") + # rsync reads "dir/" as its contents and "dir" as the directory itself. + src = f"{Path(backup_files_dir)}{os.sep}" + dest = f"{Path(mountpoint)}{os.sep}" run(["rsync", "-avv", "--delete", src, dest]) print("File restore complete.") return 0 diff --git a/src/baudolo/seed/__main__.py b/src/baudolo/seed/__main__.py index 45f15e8..9ffdcad 100644 --- a/src/baudolo/seed/__main__.py +++ b/src/baudolo/seed/__main__.py @@ -1,8 +1,8 @@ from __future__ import annotations import argparse -import os import sys +from pathlib import Path import pandas as pd from pandas.errors import EmptyDataError @@ -30,7 +30,7 @@ def check_and_add_entry( """ database = validate_database(database, instance=instance) - if os.path.exists(file_path): + if Path(file_path).exists(): try: df = pd.read_csv( file_path, diff --git a/tests/e2e/helpers/fixtures.py b/tests/e2e/helpers/fixtures.py index 59de1c0..4d4182b 100644 --- a/tests/e2e/helpers/fixtures.py +++ b/tests/e2e/helpers/fixtures.py @@ -95,7 +95,7 @@ def write_databases_csv(path: str, rows: list[tuple[str, str, str, str]]) -> Non database may be '' (empty) to trigger pg_dumpall behavior if you want, but here we use db name. """ Path(path).parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: + with Path(path).open("w", encoding="utf-8") as f: f.write("instance;database;username;password\n") f.writelines(f"{inst};{db};{user};{pw}\n" for inst, db, user, pw in rows) diff --git a/tests/e2e/test_e2e_mariadb_anonymous_preemption.py b/tests/e2e/test_e2e_mariadb_anonymous_preemption.py index 75641d9..cfb135b 100644 --- a/tests/e2e/test_e2e_mariadb_anonymous_preemption.py +++ b/tests/e2e/test_e2e_mariadb_anonymous_preemption.py @@ -21,13 +21,14 @@ are verifying is in the DB-dump stage, so testing backup_database() directly keeps the assertion focused and the test runnable both on-host and in DinD. """ -import os import tempfile import unittest +from pathlib import Path -import pandas +import pandas as pd from baudolo.backup import db as db_mod +from baudolo.generation import DUMP_SUFFIX, SQL_DIR from .helpers import ( MARIADB_DATA_DIR, @@ -140,7 +141,7 @@ class TestE2EMariaDBAnonymousPreemption(unittest.TestCase): # paths — just the dump that the negative-control proved is failing # under the same preemption setup. with tempfile.TemporaryDirectory() as volume_dir: - df = pandas.DataFrame( + df = pd.DataFrame( [(self.db_container, self.db_name, self.db_user, self.db_password)], columns=["instance", "database", "username", "password"], ) @@ -153,9 +154,9 @@ class TestE2EMariaDBAnonymousPreemption(unittest.TestCase): database_containers=[self.db_container], ) self.assertTrue(produced, "backup_database did not produce a dump") - dump_path = os.path.join(volume_dir, "sql", f"{self.db_name}.backup.sql") - self.assertTrue(os.path.isfile(dump_path), f"expected dump at {dump_path}") - with open(dump_path, "r", encoding="utf-8", errors="replace") as f: + dump_path = Path(volume_dir) / SQL_DIR / f"{self.db_name}{DUMP_SUFFIX}" + self.assertTrue(dump_path.is_file(), f"expected dump at {dump_path}") + with dump_path.open(encoding="utf-8", errors="replace") as f: content = f.read() self.assertIn("INSERT INTO", content) self.assertIn("'ok'", content) diff --git a/tests/unit/backup/compose_fixture.py b/tests/unit/backup/compose_fixture.py index 5c470a2..505b8f1 100644 --- a/tests/unit/backup/compose_fixture.py +++ b/tests/unit/backup/compose_fixture.py @@ -3,7 +3,10 @@ from __future__ import annotations import shutil -from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path def touch(p: Path) -> None: diff --git a/tests/unit/backup/test_app_databases_csv.py b/tests/unit/backup/test_app_databases_csv.py index 5b1030a..1612c2c 100644 --- a/tests/unit/backup/test_app_databases_csv.py +++ b/tests/unit/backup/test_app_databases_csv.py @@ -1,8 +1,8 @@ import io -import os import tempfile import unittest from contextlib import redirect_stderr +from pathlib import Path import pandas as pd @@ -15,7 +15,7 @@ EXPECTED_COLUMNS = ["instance", "database", "username", "password"] class TestLoadDatabasesDf(unittest.TestCase): def test_missing_csv_is_handled_with_warning_and_empty_df(self) -> None: with tempfile.TemporaryDirectory() as td: - missing_path = os.path.join(td, "does-not-exist.csv") + missing_path = str(Path(td) / "does-not-exist.csv") buf = io.StringIO() with redirect_stderr(buf): @@ -31,8 +31,8 @@ class TestLoadDatabasesDf(unittest.TestCase): def test_empty_csv_is_handled_with_warning_and_empty_df(self) -> None: with tempfile.TemporaryDirectory() as td: - empty_path = os.path.join(td, "databases.csv") - with open(empty_path, "w", encoding="utf-8") as f: + empty_path = Path(td) / "databases.csv" + with empty_path.open("w", encoding="utf-8") as f: f.write("") buf = io.StringIO() @@ -49,10 +49,10 @@ class TestLoadDatabasesDf(unittest.TestCase): def test_valid_csv_loads_without_warning(self) -> None: with tempfile.TemporaryDirectory() as td: - csv_path = os.path.join(td, "databases.csv") + csv_path = Path(td) / "databases.csv" content = "instance;database;username;password\nmyapp;*;dbuser;secret\n" - with open(csv_path, "w", encoding="utf-8") as f: + with csv_path.open("w", encoding="utf-8") as f: f.write(content) buf = io.StringIO() diff --git a/tests/unit/backup/test_db_mariadb_dump.py b/tests/unit/backup/test_db_mariadb_dump.py index b2c6da5..d485596 100644 --- a/tests/unit/backup/test_db_mariadb_dump.py +++ b/tests/unit/backup/test_db_mariadb_dump.py @@ -2,15 +2,13 @@ import tempfile import unittest from unittest.mock import patch -import pandas +import pandas as pd from baudolo.backup import db as db_mod def _df(rows): - return pandas.DataFrame( - rows, columns=["instance", "database", "username", "password"] - ) + return pd.DataFrame(rows, columns=["instance", "database", "username", "password"]) def _capture_dumps(*, db_type, rows, container, dump_tool="mariadb-dump"): diff --git a/tests/unit/backup/test_snapshot_faithfulness.py b/tests/unit/backup/test_snapshot_faithfulness.py index e301dcd..bfdb666 100644 --- a/tests/unit/backup/test_snapshot_faithfulness.py +++ b/tests/unit/backup/test_snapshot_faithfulness.py @@ -10,6 +10,7 @@ from __future__ import annotations import os import tempfile import unittest +from pathlib import Path from unittest import mock from baudolo.backup.snapshot import SnapshotError, snapshot_source, unsnapshotted @@ -19,8 +20,8 @@ from baudolo.backup.volume import Backing class TestUnsnapshotted(unittest.TestCase): def setUp(self) -> None: self.subject = tempfile.mkdtemp() - self.mountpoint = os.path.join(self.subject, "volumes", "app", "_data") - os.makedirs(self.mountpoint) + self.mountpoint = str(Path(self.subject) / "volumes" / "app" / "_data") + Path(self.mountpoint).mkdir(parents=True) def backing(self, **kwargs) -> Backing: return Backing(kwargs.pop("mountpoint", self.mountpoint), **kwargs) @@ -71,7 +72,7 @@ class TestUnsnapshotted(unittest.TestCase): def test_an_unreadable_mountpoint_is_not(self) -> None: reason = unsnapshotted( - self.backing(mountpoint=os.path.join(self.subject, "gone")), self.subject + self.backing(mountpoint=str(Path(self.subject) / "gone")), self.subject ) self.assertIn("could not be read", reason) @@ -79,12 +80,12 @@ class TestUnsnapshotted(unittest.TestCase): class TestSnapshotSource(unittest.TestCase): def setUp(self) -> None: self.subject = tempfile.mkdtemp() - self.mountpoint = os.path.join(self.subject, "volumes", "app", "_data") - os.makedirs(self.mountpoint) - self.snapshot = os.path.join( - self.subject, ".baudolo-tag", "volumes", "app", "_data" + self.mountpoint = str(Path(self.subject) / "volumes" / "app" / "_data") + Path(self.mountpoint).mkdir(parents=True) + self.snapshot = str( + Path(self.subject) / ".baudolo-tag" / "volumes" / "app" / "_data" ) - os.makedirs(self.snapshot) + Path(self.snapshot).mkdir(parents=True) self.backing = Backing(self.mountpoint) def test_a_captured_volume_reads_from_the_snapshot(self) -> None: @@ -114,7 +115,7 @@ class TestSnapshotSource(unittest.TestCase): def test_a_volume_created_after_the_snapshot_degrades(self) -> None: source, reason = snapshot_source( - lambda path: os.path.join(self.subject, "absent") + "/", + lambda path: str(Path(self.subject) / "absent") + "/", self.backing, self.subject, ) diff --git a/tests/unit/restore/test_version_gate.py b/tests/unit/restore/test_version_gate.py index e90e465..a5af28d 100644 --- a/tests/unit/restore/test_version_gate.py +++ b/tests/unit/restore/test_version_gate.py @@ -1,6 +1,6 @@ -import os import tempfile import unittest +from pathlib import Path from unittest.mock import MagicMock, patch from baudolo.restore.db import cluster as cluster_mod @@ -53,10 +53,10 @@ def cluster_header(roles: int) -> str: def dump_file(text: str) -> str: - path = os.path.join(tempfile.mkdtemp(), "app.backup.sql") - with open(path, "w", encoding="utf-8") as handle: + path = Path(tempfile.mkdtemp()) / "app.backup.sql" + with path.open("w", encoding="utf-8") as handle: handle.write(text) - return path + return str(path) class TestDumpVersion(unittest.TestCase): @@ -78,19 +78,19 @@ class TestDumpVersion(unittest.TestCase): def test_cluster_dump_states_its_version_far_below_the_header(self) -> None: path = dump_file(cluster_header(roles=200)) - with open(path, encoding="utf-8") as handle: + with Path(path).open(encoding="utf-8") as handle: offset = next(i for i, line in enumerate(handle) if "Dumped from" in line) self.assertGreater(offset, 100, "fixture must exercise the deep scan") self.assertEqual(ver.dump_version(path, "postgres"), "17.11") def test_a_version_beyond_the_scan_limit_is_refused_not_ignored(self) -> None: path = dump_file(cluster_header(roles=ver.SCAN_LINES)) - with self.assertRaises(ver.VersionMismatch): + with self.assertRaises(ver.VersionMismatchError): ver.dump_version(path, "postgres") def test_a_dump_without_a_version_header_is_refused(self) -> None: path = dump_file("CREATE TABLE t (id int);\n") - with self.assertRaises(ver.VersionMismatch): + with self.assertRaises(ver.VersionMismatchError): ver.dump_version(path, "postgres") @@ -102,13 +102,13 @@ class TestMajorOf(unittest.TestCase): self.assertEqual(ver.major_of("18beta1"), 18) def test_refuses_an_unreadable_version(self) -> None: - with self.assertRaises(ver.VersionMismatch): + with self.assertRaises(ver.VersionMismatchError): ver.major_of("unknown") class TestAssertReplayable(unittest.TestCase): def test_newer_dump_into_older_engine_is_refused(self) -> None: - with self.assertRaises(ver.VersionMismatch) as caught: + with self.assertRaises(ver.VersionMismatchError) as caught: ver.assert_replayable("/b/app.sql", "postgres", "17.11", "15.6") self.assertIn("17.11", str(caught.exception)) self.assertIn("15.6", str(caught.exception)) @@ -154,7 +154,7 @@ class TestGateStopsBeforeDestroying(unittest.TestCase): path = dump_file(POSTGRES_HEADER) with ( patch.object(pg_mod, "docker_exec") as replay, - self.assertRaises(ver.VersionMismatch), + self.assertRaises(ver.VersionMismatchError), ): pg_mod.restore_postgres_sql( container="db", @@ -171,7 +171,7 @@ class TestGateStopsBeforeDestroying(unittest.TestCase): path = dump_file(cluster_header(roles=3)) with ( patch.object(cluster_mod, "docker_exec") as replay, - self.assertRaises(ver.VersionMismatch), + self.assertRaises(ver.VersionMismatchError), ): cluster_mod.restore_cluster_sql( container="db", @@ -188,7 +188,7 @@ class TestGateStopsBeforeDestroying(unittest.TestCase): with ( patch.object(mdb_mod, "_pick_client", return_value="mariadb"), patch.object(mdb_mod, "docker_exec") as replay, - self.assertRaises(ver.VersionMismatch), + self.assertRaises(ver.VersionMismatchError), ): mdb_mod.restore_mariadb_sql( container="db", @@ -235,7 +235,7 @@ class TestGateStopsBeforeDestroying(unittest.TestCase): db_name="app", user="app", password="pw", - sql_path=os.path.join(tempfile.mkdtemp(), "absent.sql"), + sql_path=str(Path(tempfile.mkdtemp()) / "absent.sql"), empty=True, ) diff --git a/tests/unit/seed/test_main.py b/tests/unit/seed/test_main.py index e49f6ca..4da6585 100644 --- a/tests/unit/seed/test_main.py +++ b/tests/unit/seed/test_main.py @@ -51,7 +51,7 @@ class TestSeedMain(unittest.TestCase): return df - @patch("baudolo.seed.__main__.os.path.exists", return_value=False) + @patch("baudolo.seed.__main__.Path.exists", return_value=False) @patch("baudolo.seed.__main__.pd.read_csv") @patch("baudolo.seed.__main__._empty_df") @patch("baudolo.seed.__main__.pd.concat") @@ -83,7 +83,7 @@ class TestSeedMain(unittest.TestCase): "/tmp/databases.csv", sep=";", index=False ) - @patch("baudolo.seed.__main__.os.path.exists", return_value=True) + @patch("baudolo.seed.__main__.Path.exists", return_value=True) @patch("baudolo.seed.__main__.pd.read_csv", side_effect=EmptyDataError("empty")) @patch("baudolo.seed.__main__._empty_df") @patch("baudolo.seed.__main__.pd.concat") @@ -110,8 +110,8 @@ class TestSeedMain(unittest.TestCase): password="pass", ) - exists.assert_called_once_with("/tmp/databases.csv") - read_csv.assert_called_once() + exists.assert_called_once_with() + self.assertEqual(read_csv.call_args.args, ("/tmp/databases.csv",)) empty_df.assert_called_once() concat.assert_called_once() @@ -133,7 +133,7 @@ class TestSeedMain(unittest.TestCase): "/tmp/databases.csv", sep=";", index=False ) - @patch("baudolo.seed.__main__.os.path.exists", return_value=True) + @patch("baudolo.seed.__main__.Path.exists", return_value=True) @patch("baudolo.seed.__main__.pd.read_csv") def test_check_and_add_entry_updates_existing_row( self,