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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 13 additions & 1 deletion dbbackup/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
"""
Expand Down
7 changes: 7 additions & 0 deletions docs/src/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion tests/commands/test_dbrestore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@
TARED_FILE,
TEST_DATABASE,
TEST_MONGODB,
LocationPrefixedFakeStorage,
add_private_gpg,
clean_gpg_keys,
get_dump,
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions tests/test_connectors/test_django.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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())
Expand Down
17 changes: 15 additions & 2 deletions tests/test_storage.py
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"
Expand Down
Loading