From 44c47912ba2e7a65db7e2b0839fb923d37927dbc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:57:55 -0700 Subject: [PATCH] fix #662 --- CHANGELOG.md | 1 + dbbackup/storage.py | 14 +++++++++++- docs/src/storage.md | 7 ++++++ tests/commands/test_dbrestore.py | 33 +++++++++++++++++++++++++++- tests/test_connectors/test_django.py | 2 ++ tests/test_storage.py | 17 ++++++++++++-- tests/utils.py | 8 +++++++ 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570a588a..9e1ce6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Don't forget to remove deprecated code on each major release! - Fix compression when using `DjangoConnector`. - Fix an issue with paramiko `os.stat` sizes were larger on the target compared to the source. +- Fix issue with `dbrestore` not finding backups when `location` is set in storage options. ## [5.2.0] - 2026-02-10 diff --git a/dbbackup/storage.py b/dbbackup/storage.py index 6a2629ab..5714f77b 100644 --- a/dbbackup/storage.py +++ b/dbbackup/storage.py @@ -76,7 +76,7 @@ def delete_file(self, filepath): self.storage.delete(name=filepath) def list_directory(self, path=""): - return self.storage.listdir(path)[1] + return [self._normalize_listed_name(name) for name in self.storage.listdir(path)[1]] def write_file(self, filehandle, filename): self.logger.debug("Writing file %s", filename) @@ -295,6 +295,18 @@ def _filename_to_date_or_min(filename: str) -> datetime: return file_date.replace(tzinfo=timezone.utc) return file_date + def _normalize_listed_name(self, name: str) -> str: + location = getattr(self.storage, "location", "") or "" + if not location: + return name + + normalized_location = location.replace("\\", "/").strip("/") + normalized_name = name.replace("\\", "/") + prefix = f"{normalized_location}/" + if normalized_location and normalized_name.startswith(prefix): + return normalized_name[len(prefix) :] + return name + def get_storage_class(path=None): """ diff --git a/docs/src/storage.md b/docs/src/storage.md index 3d553c00..e7681695 100644 --- a/docs/src/storage.md +++ b/docs/src/storage.md @@ -51,6 +51,13 @@ STORAGES = { Storing backups to local disk may also be useful for Dropbox if you already have the official Dropbox client installed on your system. +!!! note + + When a storage backend exposes a `location` or key prefix, django-dbbackup + treats backup names as relative to that location. This keeps + `listbackups`, `dbrestore`, and explicit `--input-filename` usage aligned + even if the backend returns prefixed names internally. + ## File system storage ### Setup diff --git a/tests/commands/test_dbrestore.py b/tests/commands/test_dbrestore.py index 3efb7185..c3052e67 100644 --- a/tests/commands/test_dbrestore.py +++ b/tests/commands/test_dbrestore.py @@ -13,12 +13,13 @@ from django.conf import settings from django.core.files import File from django.core.management.base import CommandError -from django.test import TestCase +from django.test import TestCase, override_settings from dbbackup import utils from dbbackup.db.base import get_connector from dbbackup.db.mongodb import MongoDumpConnector from dbbackup.db.postgresql import PgDumpConnector +from dbbackup.management.commands.dbbackup import Command as DbbackupCommand from dbbackup.management.commands.dbrestore import Command as DbrestoreCommand from dbbackup.settings import HOSTNAME from dbbackup.storage import get_storage @@ -28,6 +29,7 @@ TARED_FILE, TEST_DATABASE, TEST_MONGODB, + LocationPrefixedFakeStorage, add_private_gpg, clean_gpg_keys, get_dump, @@ -37,6 +39,35 @@ GPG_AVAILABLE = shutil.which("gpg") is not None +@patch("dbbackup.management.commands._base.input", return_value="y") +class DbrestoreCommandWithLocationTest(TestCase): + def setUp(self): + self.dbbackup_command = DbbackupCommand() + self.dbbackup_command.stdout = DEV_NULL + self.dbrestore_command = DbrestoreCommand() + self.dbrestore_command.stdout = DEV_NULL + self.dbrestore_command.interactive = False + HANDLED_FILES.clean() + + def tearDown(self): + clean_gpg_keys() + + @override_settings(DBBACKUP_STORAGE="tests.utils.LocationPrefixedFakeStorage") + def test_restore_with_storage_location(self, *args): + """ + GIVEN a storage that lists files with its location prefix + WHEN running dbrestore without --input-filename + THEN dbrestore should still resolve the latest backup correctly + """ + self.dbbackup_command.handle(verbosity=1) + + stored_names = [name for name, _file in HANDLED_FILES["written_files"]] + assert stored_names + assert all(not name.startswith(f"{LocationPrefixedFakeStorage.location}/") for name in stored_names) + + self.dbrestore_command.handle(verbosity=1) + + @patch("dbbackup.management.commands._base.input", return_value="y") class DbrestoreCommandRestoreBackupTest(TestCase): def setUp(self): diff --git a/tests/test_connectors/test_django.py b/tests/test_connectors/test_django.py index 739fef22..935d30e5 100644 --- a/tests/test_connectors/test_django.py +++ b/tests/test_connectors/test_django.py @@ -247,6 +247,7 @@ def mock_dumpdata(*args, **kwargs): @patch("dbbackup.db.django.call_command") def test_dump_is_binary(self, mock_call_command): """Test that the created dump is a binary file and can be compressed.""" + # Mock the dumpdata command to write JSON to stdout def mock_dumpdata(*args, **kwargs): if "stdout" in kwargs: @@ -260,6 +261,7 @@ def mock_dumpdata(*args, **kwargs): # Try to compress it (this will fail if it's not a binary file) import gzip + compressed_file = SpooledTemporaryFile() with gzip.GzipFile(fileobj=compressed_file, mode="wb") as gz_file: gz_file.write(dump_file.read()) diff --git a/tests/test_storage.py b/tests/test_storage.py index e33818d6..f16c4dc4 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,11 +1,11 @@ from unittest.mock import patch import pytest -from django.test import TestCase +from django.test import TestCase, override_settings from dbbackup import utils from dbbackup.storage import Storage, get_storage, get_storage_class -from tests.utils import HANDLED_FILES, FakeStorage +from tests.utils import HANDLED_FILES, FakeStorage, LocationPrefixedFakeStorage DEFAULT_STORAGE_PATH = "django.core.files.storage.FileSystemStorage" STORAGE_OPTIONS = {"location": "tmp"} @@ -151,6 +151,19 @@ def test_content_type_media(self): for file in files: assert ".tar" in file + @override_settings(DBBACKUP_STORAGE="tests.utils.LocationPrefixedFakeStorage") + def test_location_prefixed_entries_are_normalized(self): + storage = get_storage() + + HANDLED_FILES["written_files"] = [ + (utils.filename_generate("db", "foodb"), None), + ] + + files = storage.list_backups() + + assert files == [HANDLED_FILES["written_files"][0][0]] + assert not files[0].startswith(f"{LocationPrefixedFakeStorage.location}/") + class StorageGetLatestTest(TestCase): def setUp(self): diff --git a/tests/utils.py b/tests/utils.py index 086eea68..ecf68260 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -93,6 +93,14 @@ def delete(self, name): HANDLED_FILES["deleted_files"].append(name) +class LocationPrefixedFakeStorage(FakeStorage): + name = "LocationPrefixedFakeStorage" + location = "dev" + + def listdir(self, path): + return ([], [f"{self.location}/{f[0]}" for f in HANDLED_FILES["written_files"]]) + + def clean_gpg_keys(): with contextlib.suppress(Exception): cmd = f"gpg --batch --yes --delete-key '{GPG_FINGERPRINT}'"