From 6351bb150e57949c9637099e16725a286175fd6e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 15 Jan 2026 18:55:45 +0100 Subject: [PATCH 01/22] feat:add custom metadata support https://github.com/Archmonger/django-dbbackup/discussions/660 --- CHANGELOG.md | 3 + dbbackup/management/commands/dbbackup.py | 5 ++ dbbackup/management/commands/dbrestore.py | 6 ++ dbbackup/settings.py | 2 + dbbackup/utils.py | 71 ++++++++++++++++++++++- docs/src/configuration.md | 14 +++++ 6 files changed, 100 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e73442e..bd5e4140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Don't forget to remove deprecated code on each major release! - Nothing (yet)! +### Added +- Added support for custom metadata writing and validation during operations via `DBBACKUP_CUSTOM_METADATA_LOADER` and `DBBACKUP_CUSTOM_METADATA_VALIDATOR` settings. + ## [5.1.2] - 2026-01-14 ### Fixed diff --git a/dbbackup/management/commands/dbbackup.py b/dbbackup/management/commands/dbbackup.py index 3f83bff5..95bd27e6 100644 --- a/dbbackup/management/commands/dbbackup.py +++ b/dbbackup/management/commands/dbbackup.py @@ -101,6 +101,11 @@ def _save_metadata(self, filename, local=False): "connector": f"{self.connector.__module__}.{self.connector.__class__.__name__}", } metadata_filename = f"{filename}.metadata" + + # Load custom metadata if configured + custom_metadata = utils.load_custom_metadata() + metadata.update(custom_metadata) + metadata_content = json.dumps(metadata) if local: diff --git a/dbbackup/management/commands/dbrestore.py b/dbbackup/management/commands/dbrestore.py index 5ba4f1ad..63348d0f 100644 --- a/dbbackup/management/commands/dbrestore.py +++ b/dbbackup/management/commands/dbrestore.py @@ -152,6 +152,12 @@ def _check_metadata(self, filename): "Restoring to a different database engine is not supported." ) raise CommandError(msg) + + # Check if we custom metadata validation is configured + custom_metadata_valid = utils.validate_custom_metadata(metadata) + if not custom_metadata_valid: + msg = f"Custom metadata validation failed for backup file '{filename}'." + raise CommandError(msg) return metadata diff --git a/dbbackup/settings.py b/dbbackup/settings.py index 2689863b..95286452 100644 --- a/dbbackup/settings.py +++ b/dbbackup/settings.py @@ -42,3 +42,5 @@ SERVER_EMAIL = getattr(settings, "DBBACKUP_SERVER_EMAIL", settings.SERVER_EMAIL) ADMINS = getattr(settings, "DBBACKUP_ADMIN", settings.ADMINS) EMAIL_SUBJECT_PREFIX = getattr(settings, "DBBACKUP_EMAIL_SUBJECT_PREFIX", "[dbbackup] ") +CUSTOM_METADATA_LOADER = getattr(settings, "DBBACKUP_CUSTOM_METADATA_LOADER", None) +CUSTOM_METADATA_VALIDATOR = getattr(settings, "DBBACKUP_CUSTOM_METADATA_VALIDATOR", None) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index d6c5f761..eab66fbe 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -13,7 +13,8 @@ from functools import wraps from getpass import getpass from shutil import copyfileobj - +import json +from importlib import import_module from django.core.mail import EmailMultiAlternatives from django.db import connection from django.http import HttpRequest @@ -431,3 +432,71 @@ def filename_generate(extension, database_name="", servername=None, content_type filename = REG_FILENAME_CLEAN.sub("-", filename) filename = filename.removeprefix("-") return filename + +def _load_function_from_path(path): + """ + Load a callable from a dotted path. + + :param path: Dotted path to callable + :type path: str + + :returns: Callable object + :rtype: callable + """ + module_path, func_name = path.rsplit(".", 1) + try: + module = import_module(module_path) + except ImportError as e: + raise ImportError(f"Could not import module '{module_path}': {e}") from e + func = getattr(module, func_name) + if not callable(func): + raise ValueError(f"The object at '{path}' is not callable.") + return func + +def load_custom_metadata() -> dict: + """ + Load custom metadata from a callable defined in settings. + + :returns: Custom metadata dictionary + :rtype: dict + """ + custom_metadata = {} + loader_setting = settings.CUSTOM_METADATA_LOADER + if loader_setting: + loader_function = _load_function_from_path(loader_setting) + try: + custom_metadata = loader_function() + if not isinstance(custom_metadata, dict): + raise ValueError("DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary.") + except Exception as e: + logger = logging.getLogger("dbbackup") + logger.error(f"Error loading custom metadata: {e}") + # Validate that we can serialize the provided data + try: + json.dumps(custom_metadata) + except Exception as e: + raise ValueError(f"Custom metadata is not JSON serializable: {e}") + return custom_metadata + +def validate_custom_metadata(metadata): + """ + Validate custom metadata using a callable defined in settings. + Raise a CommandError to provide custom feedback if validation fails. + + :param metadata: Metadata dictionary to validate + :type metadata: dict + + :returns: True if validation passes, False otherwise + :rtype: bool + """ + validator_setting = settings.CUSTOM_METADATA_VALIDATOR + if validator_setting: + validator_function = _load_function_from_path(validator_setting) + try: + is_valid = validator_function(metadata) + if not isinstance(is_valid, bool): + raise ValueError("DBBACKUP_CUSTOM_METADATA_VALIDATOR must return a boolean.") + return is_valid + except Exception as e: + raise ValueError(f"Error during custom metadata validation: {e}") from e + return True diff --git a/docs/src/configuration.md b/docs/src/configuration.md index b2e6af55..6af74a9f 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -204,3 +204,17 @@ By default DBBackup uses values from `settings.DATABASES`. Use You must configure a storage backend (`STORAGES['dbbackup']`) to persist backups. See [Storage settings](storage.md) for supported options. + +## Custom metadata + +### DBBACKUP_CUSTOM_METADATA_LOADER + +Optional dotted path to a callable that returns a dictionary of custom metadata that will be written to each backup's metadata file. The callable should take no arguments. + +Default: `None` + +### DBBACKUP_CUSTOM_METADATA_VALIDATOR + +Optional dotted path to a callable that validates custom metadata during restore. The callable should accept a single argument: the metadata dictionary loaded from the backup's metadata file, and return `True` if the metadata is valid or `False` otherwise. It may raise a `CommandError` with a descriptive message if validation fails. + +Default: `None` From 85b3484d75cb39b111df3bca09468f6dbe0eb52e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 15 Jan 2026 22:59:01 +0100 Subject: [PATCH 02/22] add tests --- dbbackup/utils.py | 11 ++-- tests/test_custommeta.py | 113 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 tests/test_custommeta.py diff --git a/dbbackup/utils.py b/dbbackup/utils.py index eab66fbe..1caec6cf 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -466,11 +466,13 @@ def load_custom_metadata() -> dict: loader_function = _load_function_from_path(loader_setting) try: custom_metadata = loader_function() - if not isinstance(custom_metadata, dict): - raise ValueError("DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary.") except Exception as e: logger = logging.getLogger("dbbackup") logger.error(f"Error loading custom metadata: {e}") + + if not isinstance(custom_metadata, dict): + raise ValueError("DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary.") + # Validate that we can serialize the provided data try: json.dumps(custom_metadata) @@ -493,10 +495,7 @@ def validate_custom_metadata(metadata): if validator_setting: validator_function = _load_function_from_path(validator_setting) try: - is_valid = validator_function(metadata) - if not isinstance(is_valid, bool): - raise ValueError("DBBACKUP_CUSTOM_METADATA_VALIDATOR must return a boolean.") - return is_valid + return bool(validator_function(metadata)) except Exception as e: raise ValueError(f"Error during custom metadata validation: {e}") from e return True diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py new file mode 100644 index 00000000..5c627658 --- /dev/null +++ b/tests/test_custommeta.py @@ -0,0 +1,113 @@ +"""Tests for custom metadata.""" + +import re +import pytest +from django.test import TestCase, override_settings +import importlib +import importlib + +import dbbackup.settings +import dbbackup.utils +import dbbackup.settings +import dbbackup.utils + +TEST_VAL = "aabbcc-1122-3344_eu-west" + +def dummy_loader(): + return {"CSMT_VAL": TEST_VAL} + +def broken_loader(): + return ["not", "a", "dict"] + +class CSTM: + abc='123' + +def anotherbroken_loader(): + return {"CSMT_VAL": TEST_VAL, "CSTM_OBJ": CSTM()} + +def dummy_validator(metadata): + """Dummy validator for testing purposes that assues we only allow one type of customer and region. + + A real validator would probably do more complex checks. + """ + if val:=metadata.get("CSMT_VAL", ""): + if not val.startswith("aabbcc"): + raise ValueError("CSMT_VAL must start with 'aabbcc'") + if not val.endswith("eu-west"): + return False + return True + +def broken_validator(metadata): + print(1 / 0) # Always raises ZeroDivisionError + return True + +class CustomMetadataTest(TestCase): + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.dummy_loader") + def test_metadata_loader_valid(self): + """Test that setting DBBACKUP_CUSTOM_METADATA_LOADER works.""" + importlib.reload(dbbackup.settings) + + assert dbbackup.settings.CUSTOM_METADATA_LOADER == "tests.test_custommeta.dummy_loader" + assert dbbackup.utils.load_custom_metadata() == {"CSMT_VAL": TEST_VAL} + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="non.existent.loader") + def test_metadata_loader_invalid_1(self): + """Test that various loader missconfigurations do not work - Non-existent module.""" + importlib.reload(dbbackup.settings) + + with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): + dbbackup.utils.load_custom_metadata() + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.TEST_VAL") + def test_metadata_loader_invalid_2(self): + """Test that various loader missconfigurations do not work - Non-callable object.""" + importlib.reload(dbbackup.settings) + + with pytest.raises(ValueError, match="The object at 'tests.test_custommeta.TEST_VAL' is not callable."): + dbbackup.utils.load_custom_metadata() + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.broken_loader") + def test_metadata_loader_invalid_3(self): + """Test that various loader missconfigurations do not work - Wrong return type.""" + importlib.reload(dbbackup.settings) + + with pytest.raises(ValueError,match='DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary.'): + dbbackup.utils.load_custom_metadata() + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.anotherbroken_loader") + def test_metadata_loader_invalid_4(self): + """Test that various loader missconfigurations do not work - Not JSON serializable.""" + importlib.reload(dbbackup.settings) + + with pytest.raises(ValueError, match="Custom metadata is not JSON serializable"): + dbbackup.utils.load_custom_metadata() + + @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + def test_metadata_validator_valid(self): + """Test that setting DBBACKUP_CUSTOM_METADATA_VALIDATOR works.""" + with override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator"): + importlib.reload(dbbackup.settings) + + assert dbbackup.settings.CUSTOM_METADATA_VALIDATOR == "tests.test_custommeta.dummy_validator" + assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is True + + # Test that a validator can raise an exception + with pytest.raises(ValueError, match="CSMT_VAL must start with 'aabbcc'"): + dbbackup.utils.validate_custom_metadata({"CSMT_VAL": "123"}) + + # Test that a wrong also raises a ValueError + assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": "aabbcc-xyz_eu-central"}) is False + + @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="non.existent.validator") + def test_metadata_validator_invalid_1(self): + """Test that various validator missconfigurations do not work - Non-existent module.""" + importlib.reload(dbbackup.settings) + + with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): + dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) + + @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.broken_validator") + def test_metadata_validator_invalid_2(self): + """Test that various validator missconfigurations do not work - Exception during validation.""" + importlib.reload(dbbackup.settings) + + with pytest.raises(ValueError, match="Error during custom metadata validation:"): + assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is False + assert False, "Should not reach this point" + From 7dd456f5245338f62a39c179746487113d7cc9b5 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 15 Jan 2026 23:10:14 +0100 Subject: [PATCH 03/22] fix format --- tests/test_custommeta.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index 5c627658..920b42ee 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -1,46 +1,51 @@ """Tests for custom metadata.""" -import re import pytest from django.test import TestCase, override_settings import importlib -import importlib -import dbbackup.settings -import dbbackup.utils import dbbackup.settings import dbbackup.utils +# Helpers TEST_VAL = "aabbcc-1122-3344_eu-west" + def dummy_loader(): return {"CSMT_VAL": TEST_VAL} + def broken_loader(): return ["not", "a", "dict"] + class CSTM: - abc='123' + abc = "123" + def anotherbroken_loader(): return {"CSMT_VAL": TEST_VAL, "CSTM_OBJ": CSTM()} + def dummy_validator(metadata): """Dummy validator for testing purposes that assues we only allow one type of customer and region. - + A real validator would probably do more complex checks. """ - if val:=metadata.get("CSMT_VAL", ""): + if val := metadata.get("CSMT_VAL", ""): if not val.startswith("aabbcc"): raise ValueError("CSMT_VAL must start with 'aabbcc'") if not val.endswith("eu-west"): return False return True + def broken_validator(metadata): print(1 / 0) # Always raises ZeroDivisionError return True + +# Actual tests class CustomMetadataTest(TestCase): @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.dummy_loader") def test_metadata_loader_valid(self): @@ -49,6 +54,7 @@ def test_metadata_loader_valid(self): assert dbbackup.settings.CUSTOM_METADATA_LOADER == "tests.test_custommeta.dummy_loader" assert dbbackup.utils.load_custom_metadata() == {"CSMT_VAL": TEST_VAL} + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="non.existent.loader") def test_metadata_loader_invalid_1(self): """Test that various loader missconfigurations do not work - Non-existent module.""" @@ -56,6 +62,7 @@ def test_metadata_loader_invalid_1(self): with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): dbbackup.utils.load_custom_metadata() + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.TEST_VAL") def test_metadata_loader_invalid_2(self): """Test that various loader missconfigurations do not work - Non-callable object.""" @@ -63,13 +70,15 @@ def test_metadata_loader_invalid_2(self): with pytest.raises(ValueError, match="The object at 'tests.test_custommeta.TEST_VAL' is not callable."): dbbackup.utils.load_custom_metadata() + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.broken_loader") def test_metadata_loader_invalid_3(self): """Test that various loader missconfigurations do not work - Wrong return type.""" importlib.reload(dbbackup.settings) - with pytest.raises(ValueError,match='DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary.'): + with pytest.raises(ValueError, match="DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary."): dbbackup.utils.load_custom_metadata() + @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.anotherbroken_loader") def test_metadata_loader_invalid_4(self): """Test that various loader missconfigurations do not work - Not JSON serializable.""" @@ -110,4 +119,3 @@ def test_metadata_validator_invalid_2(self): with pytest.raises(ValueError, match="Error during custom metadata validation:"): assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is False assert False, "Should not reach this point" - From d62764459cb065eeadac868563318b56ed351c80 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 15 Jan 2026 23:32:32 +0100 Subject: [PATCH 04/22] add e2e test --- tests/commands/test_dbrestore_metadata.py | 36 ++++++++++++++++++++++- tests/test_custommeta.py | 3 +- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/commands/test_dbrestore_metadata.py b/tests/commands/test_dbrestore_metadata.py index a8804078..9b5eaa56 100644 --- a/tests/commands/test_dbrestore_metadata.py +++ b/tests/commands/test_dbrestore_metadata.py @@ -1,6 +1,8 @@ +import importlib import json from unittest.mock import Mock, patch - +from django.test.utils import override_settings +from dbbackup.management.commands import dbbackup import pytest from django.conf import settings from django.core.management.base import CommandError @@ -83,6 +85,38 @@ def test_django_connector_mismatch_allowed(self): # Should not raise self.command._check_metadata("backup.dump") + @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + def test_metadata_match_custom(self): + importlib.reload(dbbackup.settings) + # Setup metadata + metadata = {"engine": settings.DATABASES["default"]["ENGINE"], "CSMT_VAL": "aabbcc-1122-3344_eu-west"} + self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata)) + + # Should not raise + self.command._check_metadata("backup.dump") + + @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + def test_metadata_match_custom_fail(self): + importlib.reload(dbbackup.settings) + # Setup metadata + metadata = {"engine": settings.DATABASES["default"]["ENGINE"], "CSMT_VAL": "aabbcc-1122-3344_eu-ttt"} + self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata)) + + # Should raise CommandError due to validation failure + with pytest.raises(CommandError): + self.command._check_metadata("backup.dump") + + @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + def test_metadata_match_custom_failwithcustomerror(self): + importlib.reload(dbbackup.settings) + # Setup metadata + metadata = {"engine": settings.DATABASES["default"]["ENGINE"], "CSMT_VAL": "xx-1122-3344_eu-west"} + self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata)) + + # Should raise CommandError due to validation failure + with pytest.raises(ValueError, match="CSMT_VAL must start with 'aabbcc'"): + self.command._check_metadata("backup.dump") + class DbrestoreConnectorOverrideTest(TestCase): def setUp(self): diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index 920b42ee..4884b2b4 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -90,8 +90,7 @@ def test_metadata_loader_invalid_4(self): @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") def test_metadata_validator_valid(self): """Test that setting DBBACKUP_CUSTOM_METADATA_VALIDATOR works.""" - with override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator"): - importlib.reload(dbbackup.settings) + importlib.reload(dbbackup.settings) assert dbbackup.settings.CUSTOM_METADATA_VALIDATOR == "tests.test_custommeta.dummy_validator" assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is True From a1d95491c5f4bb64e273383df323f29d3522600b Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 25 Jan 2026 15:59:42 +0100 Subject: [PATCH 05/22] rename variables --- CHANGELOG.md | 2 +- dbbackup/settings.py | 4 +- dbbackup/utils.py | 12 +++--- docs/src/configuration.md | 4 +- tests/commands/test_dbrestore_metadata.py | 6 +-- tests/test_custommeta.py | 50 +++++++++++------------ 6 files changed, 39 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5e4140..f498c2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Don't forget to remove deprecated code on each major release! - Nothing (yet)! ### Added -- Added support for custom metadata writing and validation during operations via `DBBACKUP_CUSTOM_METADATA_LOADER` and `DBBACKUP_CUSTOM_METADATA_VALIDATOR` settings. +- Added support for custom metadata writing and validation during operations via `DBBACKUP_BACKUP_METADATA_SETTER` and `DBBACKUP_RESTORE_METADATA_VALIDATOR` settings. ## [5.1.2] - 2026-01-14 diff --git a/dbbackup/settings.py b/dbbackup/settings.py index 95286452..c97f3f10 100644 --- a/dbbackup/settings.py +++ b/dbbackup/settings.py @@ -42,5 +42,5 @@ SERVER_EMAIL = getattr(settings, "DBBACKUP_SERVER_EMAIL", settings.SERVER_EMAIL) ADMINS = getattr(settings, "DBBACKUP_ADMIN", settings.ADMINS) EMAIL_SUBJECT_PREFIX = getattr(settings, "DBBACKUP_EMAIL_SUBJECT_PREFIX", "[dbbackup] ") -CUSTOM_METADATA_LOADER = getattr(settings, "DBBACKUP_CUSTOM_METADATA_LOADER", None) -CUSTOM_METADATA_VALIDATOR = getattr(settings, "DBBACKUP_CUSTOM_METADATA_VALIDATOR", None) +BACKUP_METADATA_SETTER = getattr(settings, "DBBACKUP_BACKUP_METADATA_SETTER", None) +RESTORE_METADATA_VALIDATOR = getattr(settings, "DBBACKUP_RESTORE_METADATA_VALIDATOR", None) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 1caec6cf..84cfc746 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -461,17 +461,17 @@ def load_custom_metadata() -> dict: :rtype: dict """ custom_metadata = {} - loader_setting = settings.CUSTOM_METADATA_LOADER - if loader_setting: - loader_function = _load_function_from_path(loader_setting) + setter_setting = settings.BACKUP_METADATA_SETTER + if setter_setting: + setter_function = _load_function_from_path(setter_setting) try: - custom_metadata = loader_function() + custom_metadata = setter_function() except Exception as e: logger = logging.getLogger("dbbackup") logger.error(f"Error loading custom metadata: {e}") if not isinstance(custom_metadata, dict): - raise ValueError("DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary.") + raise ValueError("DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary.") # Validate that we can serialize the provided data try: @@ -491,7 +491,7 @@ def validate_custom_metadata(metadata): :returns: True if validation passes, False otherwise :rtype: bool """ - validator_setting = settings.CUSTOM_METADATA_VALIDATOR + validator_setting = settings.RESTORE_METADATA_VALIDATOR if validator_setting: validator_function = _load_function_from_path(validator_setting) try: diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 6af74a9f..34893d2b 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -207,13 +207,13 @@ backups. See [Storage settings](storage.md) for supported options. ## Custom metadata -### DBBACKUP_CUSTOM_METADATA_LOADER +### DBBACKUP_BACKUP_METADATA_SETTER Optional dotted path to a callable that returns a dictionary of custom metadata that will be written to each backup's metadata file. The callable should take no arguments. Default: `None` -### DBBACKUP_CUSTOM_METADATA_VALIDATOR +### DBBACKUP_RESTORE_METADATA_VALIDATOR Optional dotted path to a callable that validates custom metadata during restore. The callable should accept a single argument: the metadata dictionary loaded from the backup's metadata file, and return `True` if the metadata is valid or `False` otherwise. It may raise a `CommandError` with a descriptive message if validation fails. diff --git a/tests/commands/test_dbrestore_metadata.py b/tests/commands/test_dbrestore_metadata.py index 9b5eaa56..a6ece3ae 100644 --- a/tests/commands/test_dbrestore_metadata.py +++ b/tests/commands/test_dbrestore_metadata.py @@ -85,7 +85,7 @@ def test_django_connector_mismatch_allowed(self): # Should not raise self.command._check_metadata("backup.dump") - @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") def test_metadata_match_custom(self): importlib.reload(dbbackup.settings) # Setup metadata @@ -95,7 +95,7 @@ def test_metadata_match_custom(self): # Should not raise self.command._check_metadata("backup.dump") - @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") def test_metadata_match_custom_fail(self): importlib.reload(dbbackup.settings) # Setup metadata @@ -106,7 +106,7 @@ def test_metadata_match_custom_fail(self): with pytest.raises(CommandError): self.command._check_metadata("backup.dump") - @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") def test_metadata_match_custom_failwithcustomerror(self): importlib.reload(dbbackup.settings) # Setup metadata diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index 4884b2b4..a9f8447c 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -11,11 +11,11 @@ TEST_VAL = "aabbcc-1122-3344_eu-west" -def dummy_loader(): +def dummy_setter(): return {"CSMT_VAL": TEST_VAL} -def broken_loader(): +def broken_setter(): return ["not", "a", "dict"] @@ -23,7 +23,7 @@ class CSTM: abc = "123" -def anotherbroken_loader(): +def anotherbroken_setter(): return {"CSMT_VAL": TEST_VAL, "CSTM_OBJ": CSTM()} @@ -47,52 +47,52 @@ def broken_validator(metadata): # Actual tests class CustomMetadataTest(TestCase): - @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.dummy_loader") - def test_metadata_loader_valid(self): - """Test that setting DBBACKUP_CUSTOM_METADATA_LOADER works.""" + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.dummy_setter") + def test_metadata_setter_valid(self): + """Test that setting DBBACKUP_BACKUP_METADATA_SETTER works.""" importlib.reload(dbbackup.settings) - assert dbbackup.settings.CUSTOM_METADATA_LOADER == "tests.test_custommeta.dummy_loader" + assert dbbackup.settings.BACKUP_METADATA_SETTER == "tests.test_custommeta.dummy_setter" assert dbbackup.utils.load_custom_metadata() == {"CSMT_VAL": TEST_VAL} - @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="non.existent.loader") - def test_metadata_loader_invalid_1(self): - """Test that various loader missconfigurations do not work - Non-existent module.""" + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="non.existent.loader") + def test_metadata_setter_invalid_1(self): + """Test that various setter missconfigurations do not work - Non-existent module.""" importlib.reload(dbbackup.settings) with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): dbbackup.utils.load_custom_metadata() - @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.TEST_VAL") - def test_metadata_loader_invalid_2(self): - """Test that various loader missconfigurations do not work - Non-callable object.""" + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.TEST_VAL") + def test_metadata_setter_invalid_2(self): + """Test that various setter missconfigurations do not work - Non-callable object.""" importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="The object at 'tests.test_custommeta.TEST_VAL' is not callable."): dbbackup.utils.load_custom_metadata() - @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.broken_loader") - def test_metadata_loader_invalid_3(self): - """Test that various loader missconfigurations do not work - Wrong return type.""" + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.broken_setter") + def test_metadata_setter_invalid_3(self): + """Test that various setter missconfigurations do not work - Wrong return type.""" importlib.reload(dbbackup.settings) - with pytest.raises(ValueError, match="DBBACKUP_CUSTOM_METADATA_LOADER must return a dictionary."): + with pytest.raises(ValueError, match="DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary."): dbbackup.utils.load_custom_metadata() - @override_settings(DBBACKUP_CUSTOM_METADATA_LOADER="tests.test_custommeta.anotherbroken_loader") - def test_metadata_loader_invalid_4(self): - """Test that various loader missconfigurations do not work - Not JSON serializable.""" + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.anotherbroken_setter") + def test_metadata_setter_invalid_4(self): + """Test that various setter missconfigurations do not work - Not JSON serializable.""" importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="Custom metadata is not JSON serializable"): dbbackup.utils.load_custom_metadata() - @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") def test_metadata_validator_valid(self): - """Test that setting DBBACKUP_CUSTOM_METADATA_VALIDATOR works.""" + """Test that setting DBBACKUP_RESTORE_METADATA_VALIDATOR works.""" importlib.reload(dbbackup.settings) - assert dbbackup.settings.CUSTOM_METADATA_VALIDATOR == "tests.test_custommeta.dummy_validator" + assert dbbackup.settings.RESTORE_METADATA_VALIDATOR == "tests.test_custommeta.dummy_validator" assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is True # Test that a validator can raise an exception @@ -102,7 +102,7 @@ def test_metadata_validator_valid(self): # Test that a wrong also raises a ValueError assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": "aabbcc-xyz_eu-central"}) is False - @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="non.existent.validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="non.existent.validator") def test_metadata_validator_invalid_1(self): """Test that various validator missconfigurations do not work - Non-existent module.""" importlib.reload(dbbackup.settings) @@ -110,7 +110,7 @@ def test_metadata_validator_invalid_1(self): with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) - @override_settings(DBBACKUP_CUSTOM_METADATA_VALIDATOR="tests.test_custommeta.broken_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.broken_validator") def test_metadata_validator_invalid_2(self): """Test that various validator missconfigurations do not work - Exception during validation.""" importlib.reload(dbbackup.settings) From 91fcaa884a09a5cfd23d4bab51a3e24bd3a6b14d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 25 Jan 2026 16:01:03 +0100 Subject: [PATCH 06/22] pass curwrent medatata to setter --- dbbackup/management/commands/dbbackup.py | 2 +- dbbackup/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dbbackup/management/commands/dbbackup.py b/dbbackup/management/commands/dbbackup.py index 95bd27e6..13acac12 100644 --- a/dbbackup/management/commands/dbbackup.py +++ b/dbbackup/management/commands/dbbackup.py @@ -103,7 +103,7 @@ def _save_metadata(self, filename, local=False): metadata_filename = f"{filename}.metadata" # Load custom metadata if configured - custom_metadata = utils.load_custom_metadata() + custom_metadata = utils.load_custom_metadata(metadata) metadata.update(custom_metadata) metadata_content = json.dumps(metadata) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 84cfc746..9c34d4f1 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -453,7 +453,7 @@ def _load_function_from_path(path): raise ValueError(f"The object at '{path}' is not callable.") return func -def load_custom_metadata() -> dict: +def load_custom_metadata(metadata: dict) -> dict: """ Load custom metadata from a callable defined in settings. @@ -465,7 +465,7 @@ def load_custom_metadata() -> dict: if setter_setting: setter_function = _load_function_from_path(setter_setting) try: - custom_metadata = setter_function() + custom_metadata = setter_function(metadata) except Exception as e: logger = logging.getLogger("dbbackup") logger.error(f"Error loading custom metadata: {e}") From f3f576bef495bc9c25caa920922f46eb38f26c38 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 25 Jan 2026 16:13:22 +0100 Subject: [PATCH 07/22] allow RESTORE_METADATA_VALIDATOR to have None as a no-op --- dbbackup/management/commands/dbrestore.py | 2 +- dbbackup/utils.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dbbackup/management/commands/dbrestore.py b/dbbackup/management/commands/dbrestore.py index 63348d0f..a5c985b0 100644 --- a/dbbackup/management/commands/dbrestore.py +++ b/dbbackup/management/commands/dbrestore.py @@ -155,7 +155,7 @@ def _check_metadata(self, filename): # Check if we custom metadata validation is configured custom_metadata_valid = utils.validate_custom_metadata(metadata) - if not custom_metadata_valid: + if custom_metadata_valid==False: msg = f"Custom metadata validation failed for backup file '{filename}'." raise CommandError(msg) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 9c34d4f1..d33effb4 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -495,7 +495,10 @@ def validate_custom_metadata(metadata): if validator_setting: validator_function = _load_function_from_path(validator_setting) try: - return bool(validator_function(metadata)) + custom_metadata = validator_function(metadata) + if custom_metadata is None: + return None + return bool(custom_metadata) except Exception as e: raise ValueError(f"Error during custom metadata validation: {e}") from e return True From 780c76374c356e5e0c11f2b5560750b9ddb1b3aa Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 25 Jan 2026 22:05:12 +0100 Subject: [PATCH 08/22] enhance metadata testing case --- dbbackup/utils.py | 3 ++- tests/test_custommeta.py | 17 +++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index d33effb4..877eea9e 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -19,6 +19,7 @@ from django.db import connection from django.http import HttpRequest from django.utils import timezone +from typing import Optional from dbbackup import settings @@ -453,7 +454,7 @@ def _load_function_from_path(path): raise ValueError(f"The object at '{path}' is not callable.") return func -def load_custom_metadata(metadata: dict) -> dict: +def load_custom_metadata(metadata: Optional[dict] = None) -> dict: """ Load custom metadata from a callable defined in settings. diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index a9f8447c..fc14eb40 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -11,11 +11,11 @@ TEST_VAL = "aabbcc-1122-3344_eu-west" -def dummy_setter(): +def dummy_setter(metadata): return {"CSMT_VAL": TEST_VAL} -def broken_setter(): +def broken_setter(metadata): return ["not", "a", "dict"] @@ -23,7 +23,7 @@ class CSTM: abc = "123" -def anotherbroken_setter(): +def anotherbroken_setter(metadata): return {"CSMT_VAL": TEST_VAL, "CSTM_OBJ": CSTM()} @@ -44,6 +44,7 @@ def broken_validator(metadata): print(1 / 0) # Always raises ZeroDivisionError return True +DEFAULT_META = {'ENGINE': 'django.db.backends.sqlite3'} # Actual tests class CustomMetadataTest(TestCase): @@ -53,7 +54,7 @@ def test_metadata_setter_valid(self): importlib.reload(dbbackup.settings) assert dbbackup.settings.BACKUP_METADATA_SETTER == "tests.test_custommeta.dummy_setter" - assert dbbackup.utils.load_custom_metadata() == {"CSMT_VAL": TEST_VAL} + assert dbbackup.utils.load_custom_metadata(DEFAULT_META) == {"CSMT_VAL": TEST_VAL} @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="non.existent.loader") def test_metadata_setter_invalid_1(self): @@ -61,7 +62,7 @@ def test_metadata_setter_invalid_1(self): importlib.reload(dbbackup.settings) with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): - dbbackup.utils.load_custom_metadata() + dbbackup.utils.load_custom_metadata(DEFAULT_META) @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.TEST_VAL") def test_metadata_setter_invalid_2(self): @@ -69,7 +70,7 @@ def test_metadata_setter_invalid_2(self): importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="The object at 'tests.test_custommeta.TEST_VAL' is not callable."): - dbbackup.utils.load_custom_metadata() + dbbackup.utils.load_custom_metadata(DEFAULT_META) @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.broken_setter") def test_metadata_setter_invalid_3(self): @@ -77,7 +78,7 @@ def test_metadata_setter_invalid_3(self): importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary."): - dbbackup.utils.load_custom_metadata() + dbbackup.utils.load_custom_metadata(DEFAULT_META) @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.anotherbroken_setter") def test_metadata_setter_invalid_4(self): @@ -85,7 +86,7 @@ def test_metadata_setter_invalid_4(self): importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="Custom metadata is not JSON serializable"): - dbbackup.utils.load_custom_metadata() + dbbackup.utils.load_custom_metadata(DEFAULT_META) @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") def test_metadata_validator_valid(self): From a6dc03002d35bc48a42b226798f5b79c679d012d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 25 Jan 2026 22:05:20 +0100 Subject: [PATCH 09/22] also test no-op --- tests/test_custommeta.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index fc14eb40..7835e096 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -32,6 +32,8 @@ def dummy_validator(metadata): A real validator would probably do more complex checks. """ + if metadata.get("NO-OP", False): + return None if val := metadata.get("CSMT_VAL", ""): if not val.startswith("aabbcc"): raise ValueError("CSMT_VAL must start with 'aabbcc'") @@ -103,6 +105,9 @@ def test_metadata_validator_valid(self): # Test that a wrong also raises a ValueError assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": "aabbcc-xyz_eu-central"}) is False + # Test that no-op works + assert dbbackup.utils.validate_custom_metadata({"NO-OP": True}) is None + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="non.existent.validator") def test_metadata_validator_invalid_1(self): """Test that various validator missconfigurations do not work - Non-existent module.""" From 29979bcd06fd03cd958f2c4d36b236d06ba1548b Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 25 Jan 2026 23:57:19 +0100 Subject: [PATCH 10/22] adjust docstring --- dbbackup/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 877eea9e..21cca01e 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -481,7 +481,7 @@ def load_custom_metadata(metadata: Optional[dict] = None) -> dict: raise ValueError(f"Custom metadata is not JSON serializable: {e}") return custom_metadata -def validate_custom_metadata(metadata): +def validate_custom_metadata(metadata) -> Optional[bool]: """ Validate custom metadata using a callable defined in settings. Raise a CommandError to provide custom feedback if validation fails. @@ -489,8 +489,8 @@ def validate_custom_metadata(metadata): :param metadata: Metadata dictionary to validate :type metadata: dict - :returns: True if validation passes, False otherwise - :rtype: bool + :returns: True if validation passes (or None), False otherwise + :rtype: Optional[bool] """ validator_setting = settings.RESTORE_METADATA_VALIDATOR if validator_setting: From 5ac489b8271222c91e85f4743410b79afdd08804 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 26 Jan 2026 00:15:04 +0100 Subject: [PATCH 11/22] fix formatting --- dbbackup/management/commands/dbrestore.py | 4 +-- dbbackup/utils.py | 36 +++++++++++++++-------- tests/test_custommeta.py | 2 +- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/dbbackup/management/commands/dbrestore.py b/dbbackup/management/commands/dbrestore.py index a5c985b0..6a3496a2 100644 --- a/dbbackup/management/commands/dbrestore.py +++ b/dbbackup/management/commands/dbrestore.py @@ -152,10 +152,10 @@ def _check_metadata(self, filename): "Restoring to a different database engine is not supported." ) raise CommandError(msg) - + # Check if we custom metadata validation is configured custom_metadata_valid = utils.validate_custom_metadata(metadata) - if custom_metadata_valid==False: + if not custom_metadata_valid: msg = f"Custom metadata validation failed for backup file '{filename}'." raise CommandError(msg) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 21cca01e..3f2066a9 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -2,7 +2,10 @@ Utility functions for dbbackup. """ +from __future__ import annotations + import gzip +import json import logging import os import re @@ -12,14 +15,13 @@ from datetime import datetime from functools import wraps from getpass import getpass -from shutil import copyfileobj -import json from importlib import import_module +from shutil import copyfileobj + from django.core.mail import EmailMultiAlternatives from django.db import connection from django.http import HttpRequest from django.utils import timezone -from typing import Optional from dbbackup import settings @@ -434,6 +436,7 @@ def filename_generate(extension, database_name="", servername=None, content_type filename = filename.removeprefix("-") return filename + def _load_function_from_path(path): """ Load a callable from a dotted path. @@ -448,13 +451,16 @@ def _load_function_from_path(path): try: module = import_module(module_path) except ImportError as e: - raise ImportError(f"Could not import module '{module_path}': {e}") from e + msg = f"Could not import module '{module_path}': {e}" + raise ImportError(msg) from e func = getattr(module, func_name) if not callable(func): - raise ValueError(f"The object at '{path}' is not callable.") + msg = f"The object at '{path}' is not callable." + raise TypeError(msg) return func -def load_custom_metadata(metadata: Optional[dict] = None) -> dict: + +def load_custom_metadata(metadata: dict | None = None) -> dict: """ Load custom metadata from a callable defined in settings. @@ -467,21 +473,24 @@ def load_custom_metadata(metadata: Optional[dict] = None) -> dict: setter_function = _load_function_from_path(setter_setting) try: custom_metadata = setter_function(metadata) - except Exception as e: + except Exception: logger = logging.getLogger("dbbackup") - logger.error(f"Error loading custom metadata: {e}") + logger.exception("Error loading custom metadata: %s") if not isinstance(custom_metadata, dict): - raise ValueError("DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary.") + msg = "DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary." + raise ValueError(msg) # Validate that we can serialize the provided data try: json.dumps(custom_metadata) except Exception as e: - raise ValueError(f"Custom metadata is not JSON serializable: {e}") + msg = f"Custom metadata is not JSON serializable: {e}" + raise ValueError(msg) from e return custom_metadata -def validate_custom_metadata(metadata) -> Optional[bool]: + +def validate_custom_metadata(metadata) -> bool | None: """ Validate custom metadata using a callable defined in settings. Raise a CommandError to provide custom feedback if validation fails. @@ -499,7 +508,8 @@ def validate_custom_metadata(metadata) -> Optional[bool]: custom_metadata = validator_function(metadata) if custom_metadata is None: return None - return bool(custom_metadata) + return bool(custom_metadata) except Exception as e: - raise ValueError(f"Error during custom metadata validation: {e}") from e + msg = f"Error during custom metadata validation: {e}" + raise ValueError(msg) from e return True diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index 7835e096..1a286dfb 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -71,7 +71,7 @@ def test_metadata_setter_invalid_2(self): """Test that various setter missconfigurations do not work - Non-callable object.""" importlib.reload(dbbackup.settings) - with pytest.raises(ValueError, match="The object at 'tests.test_custommeta.TEST_VAL' is not callable."): + with pytest.raises(TypeError, match="The object at 'tests.test_custommeta.TEST_VAL' is not callable."): dbbackup.utils.load_custom_metadata(DEFAULT_META) @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.broken_setter") From 0fab14a17b4397c00fc60f1add4080309c9ed248 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 26 Jan 2026 00:21:53 +0100 Subject: [PATCH 12/22] validate that only bool'sche values are returned by validator --- dbbackup/utils.py | 5 ++++- tests/test_custommeta.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 3f2066a9..ff3003bf 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -508,7 +508,10 @@ def validate_custom_metadata(metadata) -> bool | None: custom_metadata = validator_function(metadata) if custom_metadata is None: return None - return bool(custom_metadata) + if not isinstance(custom_metadata, bool): + msg = "DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None." + raise ValueError(msg) + return custom_metadata except Exception as e: msg = f"Error during custom metadata validation: {e}" raise ValueError(msg) from e diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index 1a286dfb..5d7823af 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -46,6 +46,9 @@ def broken_validator(metadata): print(1 / 0) # Always raises ZeroDivisionError return True +def non_bool_validator(metadata): + return "not a boolean" + DEFAULT_META = {'ENGINE': 'django.db.backends.sqlite3'} # Actual tests @@ -124,3 +127,12 @@ def test_metadata_validator_invalid_2(self): with pytest.raises(ValueError, match="Error during custom metadata validation:"): assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is False assert False, "Should not reach this point" + + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.non_bool_validator") + def test_metadata_validator_invalid_3(self): + """Test that various validator missconfigurations do not work - Exception during validation.""" + importlib.reload(dbbackup.settings) + + with pytest.raises(ValueError, match="DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None"): + assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is False + assert False, "Should not reach this point" From 968ac9687840250129ceb940c2fbe34df8bcf32c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 26 Jan 2026 00:23:52 +0100 Subject: [PATCH 13/22] fomat again --- dbbackup/utils.py | 12 ++++++------ tests/test_custommeta.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index ff3003bf..0e603a8b 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -506,13 +506,13 @@ def validate_custom_metadata(metadata) -> bool | None: validator_function = _load_function_from_path(validator_setting) try: custom_metadata = validator_function(metadata) - if custom_metadata is None: - return None - if not isinstance(custom_metadata, bool): - msg = "DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None." - raise ValueError(msg) - return custom_metadata except Exception as e: msg = f"Error during custom metadata validation: {e}" raise ValueError(msg) from e + if custom_metadata is None: + return None + if not isinstance(custom_metadata, bool): + msg = "DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None." + raise TypeError(msg) + return custom_metadata return True diff --git a/tests/test_custommeta.py b/tests/test_custommeta.py index 5d7823af..db0253cd 100644 --- a/tests/test_custommeta.py +++ b/tests/test_custommeta.py @@ -133,6 +133,6 @@ def test_metadata_validator_invalid_3(self): """Test that various validator missconfigurations do not work - Exception during validation.""" importlib.reload(dbbackup.settings) - with pytest.raises(ValueError, match="DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None"): + with pytest.raises(TypeError, match="DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None"): assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is False assert False, "Should not reach this point" From a0d90897a53c472edb5faff78997dd6df9ad40dd Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 26 Jan 2026 01:45:23 +0100 Subject: [PATCH 14/22] add sample codeblock --- docs/src/configuration.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 34893d2b..e2a82c22 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -213,8 +213,31 @@ Optional dotted path to a callable that returns a dictionary of custom metadata Default: `None` +```python +import os + +def metadata_set(metadata): + region = os.environ.get('AWS_REGION') + return {'environment': 'AWS', 'region': region} + +DBBACKUP_BACKUP_METADATA_SETTER = metadata_set +``` + ### DBBACKUP_RESTORE_METADATA_VALIDATOR Optional dotted path to a callable that validates custom metadata during restore. The callable should accept a single argument: the metadata dictionary loaded from the backup's metadata file, and return `True` if the metadata is valid or `False` otherwise. It may raise a `CommandError` with a descriptive message if validation fails. Default: `None` + + +```python +import os + +def validate_restore(metadata): + region = os.environ.get('AWS_REGION') + if not metadata.get('region') == region: + raise CommandError(f"Backup region does not match current region {region}; cross region restores are not allowed due to SMP-0123.") + return True + +DBBACKUP_RESTORE_METADATA_VALIDATOR = validate_restore +``` From c62759391584186911eb7ed6cb988939802d9f27 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:26:42 -0800 Subject: [PATCH 15/22] custom metadata -> user metadata, allow setter to be a callable, allow setter to return None, `_VALIDATOR` is a "secondary" validator, ensure user cannot manipulate original metadata. --- dbbackup/management/commands/dbbackup.py | 4 +- dbbackup/management/commands/dbrestore.py | 4 +- dbbackup/utils.py | 48 ++++++---- docs/src/configuration.md | 33 +++++-- tests/commands/test_dbrestore_metadata.py | 28 +++++- ...st_custommeta.py => test_user_metadata.py} | 94 ++++++++++++++----- 6 files changed, 150 insertions(+), 61 deletions(-) rename tests/{test_custommeta.py => test_user_metadata.py} (59%) diff --git a/dbbackup/management/commands/dbbackup.py b/dbbackup/management/commands/dbbackup.py index 13acac12..443eebbc 100644 --- a/dbbackup/management/commands/dbbackup.py +++ b/dbbackup/management/commands/dbbackup.py @@ -103,8 +103,8 @@ def _save_metadata(self, filename, local=False): metadata_filename = f"{filename}.metadata" # Load custom metadata if configured - custom_metadata = utils.load_custom_metadata(metadata) - metadata.update(custom_metadata) + user_metadata = utils.get_user_metadata(metadata) + metadata.update(user_metadata) metadata_content = json.dumps(metadata) diff --git a/dbbackup/management/commands/dbrestore.py b/dbbackup/management/commands/dbrestore.py index 6a3496a2..cdd80110 100644 --- a/dbbackup/management/commands/dbrestore.py +++ b/dbbackup/management/commands/dbrestore.py @@ -154,8 +154,8 @@ def _check_metadata(self, filename): raise CommandError(msg) # Check if we custom metadata validation is configured - custom_metadata_valid = utils.validate_custom_metadata(metadata) - if not custom_metadata_valid: + user_metadata_valid = utils.validate_user_metadata(metadata) + if not user_metadata_valid: msg = f"Custom metadata validation failed for backup file '{filename}'." raise CommandError(msg) diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 0e603a8b..7dab4273 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -4,6 +4,7 @@ from __future__ import annotations +import copy import gzip import json import logging @@ -437,16 +438,20 @@ def filename_generate(extension, database_name="", servername=None, content_type return filename -def _load_function_from_path(path): +def _get_function_from_path(func_or_path): """ - Load a callable from a dotted path. + Load a callable from a dotted path or return the callable itself. - :param path: Dotted path to callable - :type path: str + :param func_or_path: Dotted path to callable or callable itself + :type func_or_path: str | callable :returns: Callable object :rtype: callable """ + if callable(func_or_path): + return func_or_path + + path = func_or_path module_path, func_name = path.rsplit(".", 1) try: module = import_module(module_path) @@ -460,39 +465,43 @@ def _load_function_from_path(path): return func -def load_custom_metadata(metadata: dict | None = None) -> dict: +def get_user_metadata(context: dict | None = None) -> dict: """ - Load custom metadata from a callable defined in settings. + Get user generated metadata from the user's custom metadata setter. :returns: Custom metadata dictionary :rtype: dict """ - custom_metadata = {} + user_metadata = {} setter_setting = settings.BACKUP_METADATA_SETTER if setter_setting: - setter_function = _load_function_from_path(setter_setting) + setter_function = _get_function_from_path(setter_setting) try: - custom_metadata = setter_function(metadata) + # We pass a copy to avoid side effects + user_metadata = setter_function(copy.deepcopy(context)) except Exception: logger = logging.getLogger("dbbackup") logger.exception("Error loading custom metadata: %s") - if not isinstance(custom_metadata, dict): + if user_metadata is None: + user_metadata = {} + + if not isinstance(user_metadata, dict): msg = "DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary." raise ValueError(msg) # Validate that we can serialize the provided data try: - json.dumps(custom_metadata) + json.dumps(user_metadata) except Exception as e: msg = f"Custom metadata is not JSON serializable: {e}" raise ValueError(msg) from e - return custom_metadata + return user_metadata -def validate_custom_metadata(metadata) -> bool | None: +def validate_user_metadata(metadata) -> bool | None: """ - Validate custom metadata using a callable defined in settings. + Validate custom metadata using a callable defined in `settings.py`. Raise a CommandError to provide custom feedback if validation fails. :param metadata: Metadata dictionary to validate @@ -503,16 +512,17 @@ def validate_custom_metadata(metadata) -> bool | None: """ validator_setting = settings.RESTORE_METADATA_VALIDATOR if validator_setting: - validator_function = _load_function_from_path(validator_setting) + validator_function = _get_function_from_path(validator_setting) try: - custom_metadata = validator_function(metadata) + # We pass a copy to avoid side effects + user_metadata = validator_function(copy.deepcopy(metadata)) except Exception as e: msg = f"Error during custom metadata validation: {e}" raise ValueError(msg) from e - if custom_metadata is None: + if user_metadata is None: return None - if not isinstance(custom_metadata, bool): + if not isinstance(user_metadata, bool): msg = "DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None." raise TypeError(msg) - return custom_metadata + return user_metadata return True diff --git a/docs/src/configuration.md b/docs/src/configuration.md index e2a82c22..f0e1307e 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -123,9 +123,9 @@ python manage.py dbrestore --decrypt Requirements: -- Install the python package python-gnupg: `pip install python-gnupg>=0.5.0`. -- You need a GPG key. ([GPG manual](https://www.gnupg.org/gph/en/manual/c14.html)) -- Set the setting `DBBACKUP_GPG_RECIPIENT` to the name of the GPG key. +- Install the python package python-gnupg: `pip install python-gnupg>=0.5.0`. +- You need a GPG key. ([GPG manual](https://www.gnupg.org/gph/en/manual/c14.html)) +- Set the setting `DBBACKUP_GPG_RECIPIENT` to the name of the GPG key. Note (Windows): The `gpg` executable must be installed and on your PATH for encryption/decryption. If it is absent, django-dbbackup still works; only encryption-related features are unavailable. The test suite will automatically skip encryption tests when `gpg` is not found. @@ -209,26 +209,31 @@ backups. See [Storage settings](storage.md) for supported options. ### DBBACKUP_BACKUP_METADATA_SETTER -Optional dotted path to a callable that returns a dictionary of custom metadata that will be written to each backup's metadata file. The callable should take no arguments. +Function or dotted path (string) to a callable that returns a dictionary of extra metadata. + +The function must accept a single argument: a metadata dictionary containing information about the current backup operation. If a dictionary is returned, it will be merged into the current backup's metadata file. If `None` is returned, it is treated as an empty dictionary. Default: `None` ```python -import os - def metadata_set(metadata): - region = os.environ.get('AWS_REGION') - return {'environment': 'AWS', 'region': region} + last_backup_time = datetime.now().isoformat() + if metadata and metadata.get('engine') == 'my.custom.Engine': + region = os.environ.get('AWS_REGION') + return {'environment': 'AWS', 'region': region, 'backup_time': last_backup_time} + else: + return {'backup_time': last_backup_time} DBBACKUP_BACKUP_METADATA_SETTER = metadata_set ``` ### DBBACKUP_RESTORE_METADATA_VALIDATOR -Optional dotted path to a callable that validates custom metadata during restore. The callable should accept a single argument: the metadata dictionary loaded from the backup's metadata file, and return `True` if the metadata is valid or `False` otherwise. It may raise a `CommandError` with a descriptive message if validation fails. +Function or dotted path (string) to a callable that performs additional validation on the backup's metadata during restore operations. This validator runs **after** the built-in validation (e.g. database engine checks). -Default: `None` +The callable should accept a single argument: the metadata dictionary loaded from the current restore operation's metadata file. If this function returns `True`, the metadata is valid, `False` means the restore operation will be aborted. `None` can be returned as a no-op. It may raise a `CommandError` with a descriptive message if validation fails. +Default: `None` ```python import os @@ -237,6 +242,14 @@ def validate_restore(metadata): region = os.environ.get('AWS_REGION') if not metadata.get('region') == region: raise CommandError(f"Backup region does not match current region {region}; cross region restores are not allowed due to SMP-0123.") + + last_backup_time = metadata.get('backup_time') + if datetime.now() - datetime.fromisoformat(last_backup_time) > timedelta(days=120): + return False # Skip restore since it is stale (>120 days) + + if os.environ.get('AWS_REGION') == None: + return None # No-op if performing a backup without AWS_REGION is not set + return True DBBACKUP_RESTORE_METADATA_VALIDATOR = validate_restore diff --git a/tests/commands/test_dbrestore_metadata.py b/tests/commands/test_dbrestore_metadata.py index a6ece3ae..d0a22935 100644 --- a/tests/commands/test_dbrestore_metadata.py +++ b/tests/commands/test_dbrestore_metadata.py @@ -1,13 +1,14 @@ import importlib import json from unittest.mock import Mock, patch -from django.test.utils import override_settings -from dbbackup.management.commands import dbbackup + import pytest from django.conf import settings from django.core.management.base import CommandError from django.test import TestCase +from django.test.utils import override_settings +from dbbackup.management.commands import dbbackup from dbbackup.management.commands.dbrestore import Command as DbrestoreCommand @@ -85,7 +86,7 @@ def test_django_connector_mismatch_allowed(self): # Should not raise self.command._check_metadata("backup.dump") - @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator") def test_metadata_match_custom(self): importlib.reload(dbbackup.settings) # Setup metadata @@ -95,7 +96,7 @@ def test_metadata_match_custom(self): # Should not raise self.command._check_metadata("backup.dump") - @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator") def test_metadata_match_custom_fail(self): importlib.reload(dbbackup.settings) # Setup metadata @@ -106,7 +107,7 @@ def test_metadata_match_custom_fail(self): with pytest.raises(CommandError): self.command._check_metadata("backup.dump") - @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator") def test_metadata_match_custom_failwithcustomerror(self): importlib.reload(dbbackup.settings) # Setup metadata @@ -117,6 +118,23 @@ def test_metadata_match_custom_failwithcustomerror(self): with pytest.raises(ValueError, match="CSMT_VAL must start with 'aabbcc'"): self.command._check_metadata("backup.dump") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator") + def test_metadata_mismatch_custom_valid(self): + """Test that built-in validation runs before custom validation.""" + importlib.reload(dbbackup.settings) + # Setup metadata with different engine (primary fail) but valid custom data + metadata = { + "engine": "django.db.backends.postgresql", + "CSMT_VAL": "aabbcc-1122-3344_eu-west" + } + self.command.storage.read_file.return_value = Mock(read=lambda: json.dumps(metadata)) + + # Should raise CommandError due to engine mismatch (primary), not custom validation + with pytest.raises(CommandError) as cm: + self.command._check_metadata("backup.dump") + + assert "Restoring to a different database engine is not supported" in str(cm.value) + class DbrestoreConnectorOverrideTest(TestCase): def setUp(self): diff --git a/tests/test_custommeta.py b/tests/test_user_metadata.py similarity index 59% rename from tests/test_custommeta.py rename to tests/test_user_metadata.py index db0253cd..7fa84e65 100644 --- a/tests/test_custommeta.py +++ b/tests/test_user_metadata.py @@ -1,8 +1,9 @@ """Tests for custom metadata.""" +import importlib + import pytest from django.test import TestCase, override_settings -import importlib import dbbackup.settings import dbbackup.utils @@ -19,6 +20,20 @@ def broken_setter(metadata): return ["not", "a", "dict"] +def setter_returning_none(metadata): + return None + + +def mutation_setter(context): + context["mutated"] = True + return {} + + +def mutation_validator(metadata): + metadata["mutated"] = True + return True + + class CSTM: abc = "123" @@ -53,13 +68,37 @@ def non_bool_validator(metadata): # Actual tests class CustomMetadataTest(TestCase): - @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.dummy_setter") + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_user_metadata.dummy_setter") def test_metadata_setter_valid(self): """Test that setting DBBACKUP_BACKUP_METADATA_SETTER works.""" importlib.reload(dbbackup.settings) - assert dbbackup.settings.BACKUP_METADATA_SETTER == "tests.test_custommeta.dummy_setter" - assert dbbackup.utils.load_custom_metadata(DEFAULT_META) == {"CSMT_VAL": TEST_VAL} + assert dbbackup.settings.BACKUP_METADATA_SETTER == "tests.test_user_metadata.dummy_setter" + assert dbbackup.utils.get_user_metadata(DEFAULT_META) == {"CSMT_VAL": TEST_VAL} + + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER=dummy_setter) + def test_metadata_setter_valid_callable(self): + """Test that setting DBBACKUP_BACKUP_METADATA_SETTER works with a callable.""" + importlib.reload(dbbackup.settings) + + assert dbbackup.settings.BACKUP_METADATA_SETTER == dummy_setter + assert dbbackup.utils.get_user_metadata(DEFAULT_META) == {"CSMT_VAL": TEST_VAL} + + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_user_metadata.setter_returning_none") + def test_metadata_setter_none(self): + """Test that setting DBBACKUP_BACKUP_METADATA_SETTER works when returning None.""" + importlib.reload(dbbackup.settings) + + assert dbbackup.utils.get_user_metadata(DEFAULT_META) == {} + + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_user_metadata.mutation_setter") + def test_metadata_setter_no_side_effects(self): + """Test that the setter cannot mutate the original context.""" + importlib.reload(dbbackup.settings) + context = {"original": True} + dbbackup.utils.get_user_metadata(context) + assert context == {"original": True} + assert "mutated" not in context @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="non.existent.loader") def test_metadata_setter_invalid_1(self): @@ -67,49 +106,58 @@ def test_metadata_setter_invalid_1(self): importlib.reload(dbbackup.settings) with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): - dbbackup.utils.load_custom_metadata(DEFAULT_META) + dbbackup.utils.get_user_metadata(DEFAULT_META) - @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.TEST_VAL") + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_user_metadata.TEST_VAL") def test_metadata_setter_invalid_2(self): """Test that various setter missconfigurations do not work - Non-callable object.""" importlib.reload(dbbackup.settings) - with pytest.raises(TypeError, match="The object at 'tests.test_custommeta.TEST_VAL' is not callable."): - dbbackup.utils.load_custom_metadata(DEFAULT_META) + with pytest.raises(TypeError, match="The object at 'tests.test_user_metadata.TEST_VAL' is not callable."): + dbbackup.utils.get_user_metadata(DEFAULT_META) - @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.broken_setter") + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_user_metadata.broken_setter") def test_metadata_setter_invalid_3(self): """Test that various setter missconfigurations do not work - Wrong return type.""" importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="DBBACKUP_BACKUP_METADATA_SETTER must return a dictionary."): - dbbackup.utils.load_custom_metadata(DEFAULT_META) + dbbackup.utils.get_user_metadata(DEFAULT_META) - @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_custommeta.anotherbroken_setter") + @override_settings(DBBACKUP_BACKUP_METADATA_SETTER="tests.test_user_metadata.anotherbroken_setter") def test_metadata_setter_invalid_4(self): """Test that various setter missconfigurations do not work - Not JSON serializable.""" importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="Custom metadata is not JSON serializable"): - dbbackup.utils.load_custom_metadata(DEFAULT_META) + dbbackup.utils.get_user_metadata(DEFAULT_META) - @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.dummy_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.dummy_validator") def test_metadata_validator_valid(self): """Test that setting DBBACKUP_RESTORE_METADATA_VALIDATOR works.""" importlib.reload(dbbackup.settings) - assert dbbackup.settings.RESTORE_METADATA_VALIDATOR == "tests.test_custommeta.dummy_validator" - assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is True + assert dbbackup.settings.RESTORE_METADATA_VALIDATOR == "tests.test_user_metadata.dummy_validator" + assert dbbackup.utils.validate_user_metadata({"CSMT_VAL": TEST_VAL}) is True # Test that a validator can raise an exception with pytest.raises(ValueError, match="CSMT_VAL must start with 'aabbcc'"): - dbbackup.utils.validate_custom_metadata({"CSMT_VAL": "123"}) + dbbackup.utils.validate_user_metadata({"CSMT_VAL": "123"}) # Test that a wrong also raises a ValueError - assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": "aabbcc-xyz_eu-central"}) is False + assert dbbackup.utils.validate_user_metadata({"CSMT_VAL": "aabbcc-xyz_eu-central"}) is False # Test that no-op works - assert dbbackup.utils.validate_custom_metadata({"NO-OP": True}) is None + assert dbbackup.utils.validate_user_metadata({"NO-OP": True}) is None + + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.mutation_validator") + def test_metadata_validator_no_side_effects(self): + """Test that the validator cannot mutate the original metadata.""" + importlib.reload(dbbackup.settings) + metadata = {"original": True} + assert dbbackup.utils.validate_user_metadata(metadata) is True + assert metadata == {"original": True} + assert "mutated" not in metadata @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="non.existent.validator") def test_metadata_validator_invalid_1(self): @@ -117,22 +165,22 @@ def test_metadata_validator_invalid_1(self): importlib.reload(dbbackup.settings) with pytest.raises(ImportError, match="Could not import module 'non.existent': No module named 'non'"): - dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) + dbbackup.utils.validate_user_metadata({"CSMT_VAL": TEST_VAL}) - @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.broken_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.broken_validator") def test_metadata_validator_invalid_2(self): """Test that various validator missconfigurations do not work - Exception during validation.""" importlib.reload(dbbackup.settings) with pytest.raises(ValueError, match="Error during custom metadata validation:"): - assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is False + assert dbbackup.utils.validate_user_metadata({"CSMT_VAL": TEST_VAL}) is False assert False, "Should not reach this point" - @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_custommeta.non_bool_validator") + @override_settings(DBBACKUP_RESTORE_METADATA_VALIDATOR="tests.test_user_metadata.non_bool_validator") def test_metadata_validator_invalid_3(self): """Test that various validator missconfigurations do not work - Exception during validation.""" importlib.reload(dbbackup.settings) with pytest.raises(TypeError, match="DBBACKUP_RESTORE_METADATA_VALIDATOR must return a boolean or None"): - assert dbbackup.utils.validate_custom_metadata({"CSMT_VAL": TEST_VAL}) is False + assert dbbackup.utils.validate_user_metadata({"CSMT_VAL": TEST_VAL}) is False assert False, "Should not reach this point" From c63cb042ff28b6361dff51d8cd6f57849490e286 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:36:04 -0800 Subject: [PATCH 16/22] Fix typos --- CHANGELOG.md | 2 -- dbbackup/utils.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f498c2aa..c147755e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,6 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! - ### Added - Added support for custom metadata writing and validation during operations via `DBBACKUP_BACKUP_METADATA_SETTER` and `DBBACKUP_RESTORE_METADATA_VALIDATOR` settings. diff --git a/dbbackup/utils.py b/dbbackup/utils.py index 7dab4273..238e1c18 100644 --- a/dbbackup/utils.py +++ b/dbbackup/utils.py @@ -465,7 +465,7 @@ def _get_function_from_path(func_or_path): return func -def get_user_metadata(context: dict | None = None) -> dict: +def get_user_metadata(metadata: dict | None = None) -> dict: """ Get user generated metadata from the user's custom metadata setter. @@ -478,7 +478,7 @@ def get_user_metadata(context: dict | None = None) -> dict: setter_function = _get_function_from_path(setter_setting) try: # We pass a copy to avoid side effects - user_metadata = setter_function(copy.deepcopy(context)) + user_metadata = setter_function(copy.deepcopy(metadata)) except Exception: logger = logging.getLogger("dbbackup") logger.exception("Error loading custom metadata: %s") From 91d6d7fc972e3dcc1a9ce32ad6872fd442e6a2df Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:50:19 -0800 Subject: [PATCH 17/22] fix broken docs tests --- .github/workflows/ci.yml | 2 +- docs/src/configuration.md | 4 ++-- docs/src/dictionary.txt | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 087b6931..df4e17ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: 3.x cache: pip - name: Install dependencies - run: pip install --upgrade hatch uv + run: pip install --upgrade setuptools hatch uv # FIXME: Link checking disabled due to github.com link HTTP rate limit issues. # - name: Check documentation links # run: hatch run docs:linkcheck diff --git a/docs/src/configuration.md b/docs/src/configuration.md index f0e1307e..4d405a41 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -231,7 +231,7 @@ DBBACKUP_BACKUP_METADATA_SETTER = metadata_set Function or dotted path (string) to a callable that performs additional validation on the backup's metadata during restore operations. This validator runs **after** the built-in validation (e.g. database engine checks). -The callable should accept a single argument: the metadata dictionary loaded from the current restore operation's metadata file. If this function returns `True`, the metadata is valid, `False` means the restore operation will be aborted. `None` can be returned as a no-op. It may raise a `CommandError` with a descriptive message if validation fails. +The callable should accept a single argument: the metadata dictionary loaded from the current restore operation's metadata file. If this function returns `True`, the metadata is valid, `False` means the restore operation will be aborted. `None` can be returned to do nothing and allow `dbbackup` to decide. It may raise a `CommandError` with a descriptive message if validation fails. Default: `None` @@ -248,7 +248,7 @@ def validate_restore(metadata): return False # Skip restore since it is stale (>120 days) if os.environ.get('AWS_REGION') == None: - return None # No-op if performing a backup without AWS_REGION is not set + return None # Do nothing if performing a backup outside AWS_REGION return True diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 08e7ba8d..a0a6bb38 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -48,3 +48,5 @@ postgis postgresql gzip tarfile +validator +no-op From 7dd1575eb8c3c60810cd5bbe4a298cfe725becb2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:56:37 -0800 Subject: [PATCH 18/22] add wheel --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df4e17ce..a6c5a198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: 3.x cache: pip - name: Install dependencies - run: pip install --upgrade setuptools hatch uv + run: pip install --upgrade setuptools wheel hatch uv # FIXME: Link checking disabled due to github.com link HTTP rate limit issues. # - name: Check documentation links # run: hatch run docs:linkcheck From 91172b86a715f3e49bf76eecb14d3db55cf4d8cd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:05:22 -0800 Subject: [PATCH 19/22] try to specify uv installer for docs environment --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index fa0cc1ce..e060b432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ matrix.django.dependencies = [ # >>> Documentation Scripts <<< [tool.hatch.envs.docs] +installer = "uv" template = "docs" detached = true dependencies = [ From e93168007034c200ff35242a9837e416c1a8b5be Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:13:03 -0800 Subject: [PATCH 20/22] try injecting dependencies via pyproject --- .github/workflows/ci.yml | 2 +- pyproject.toml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6c5a198..087b6931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: 3.x cache: pip - name: Install dependencies - run: pip install --upgrade setuptools wheel hatch uv + run: pip install --upgrade hatch uv # FIXME: Link checking disabled due to github.com link HTTP rate limit issues. # - name: Check documentation links # run: hatch run docs:linkcheck diff --git a/pyproject.toml b/pyproject.toml index e060b432..ee4c323f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,3 +224,8 @@ run.parallel = true run.source = ["dbbackup/"] paths.source = ["dbbackup/"] report.show_missing = true + +# `editdistpy` (v0.1.6) was included because `mkdocs-spellcheck[all]`, but has forgotten to +# declare `pkg_resources` as a dependency. +[tool.uv.extra-build-dependencies] +editdistpy = ["setuptools", "wheel"] From f3aee1ac7c0dfb6753e96f77c5ea20e53cca08a4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:22:56 -0800 Subject: [PATCH 21/22] Try reducing python version for docs environment --- pyproject.toml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee4c323f..5fb6ac9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ matrix.django.dependencies = [ [tool.hatch.envs.docs] installer = "uv" +python = "3.11" template = "docs" detached = true dependencies = [ @@ -224,8 +225,3 @@ run.parallel = true run.source = ["dbbackup/"] paths.source = ["dbbackup/"] report.show_missing = true - -# `editdistpy` (v0.1.6) was included because `mkdocs-spellcheck[all]`, but has forgotten to -# declare `pkg_resources` as a dependency. -[tool.uv.extra-build-dependencies] -editdistpy = ["setuptools", "wheel"] From 7e653de99d4b111fbaa737bc77754cd3b31b4de3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:25:22 -0800 Subject: [PATCH 22/22] Add comment --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5fb6ac9c..c2fc7eb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ matrix.django.dependencies = [ [tool.hatch.envs.docs] installer = "uv" -python = "3.11" +python = "3.11" # `editdistpy` (v0.1.6) is a dependency of `mkdocs-spellcheck[all]`, and is incompatible with Python 3.12+. template = "docs" detached = true dependencies = [