diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 087b6931..367ca7c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e95421..570a588a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,10 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! +### Fixed + +- Fix compression when using `DjangoConnector`. +- Fix an issue with paramiko `os.stat` sizes were larger on the target compared to the source. ## [5.2.0] - 2026-02-10 diff --git a/dbbackup/db/django.py b/dbbackup/db/django.py index a0144dae..33dc7cf1 100644 --- a/dbbackup/db/django.py +++ b/dbbackup/db/django.py @@ -6,6 +6,7 @@ any Django-supported database backend. """ +import codecs import contextlib import os import tempfile @@ -35,8 +36,11 @@ def _create_dump(self): Returns a file-like object containing the serialized database data in JSON format. """ - # Create a SpooledTemporaryFile in text mode for direct use with dumpdata - dump_file = SpooledTemporaryFile(mode="w+t", encoding="utf-8") + + binary_dump_file = SpooledTemporaryFile(mode="w+b") + + # Wrap Binary SpooledTemporaryFile in text mode for direct use with dumpdata + dump_file = codecs.getwriter("utf-8")(binary_dump_file) # Prepare arguments for dumpdata command dump_args = [] @@ -108,8 +112,8 @@ def _create_dump(self): call_command("dumpdata", *dump_args, **dump_kwargs) # Reset file position to beginning for reading - dump_file.seek(0) - return dump_file + binary_dump_file.seek(0) + return binary_dump_file def _restore_dump(self, dump): """ diff --git a/pyproject.toml b/pyproject.toml index 25c36550..628d0e9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,32 +18,32 @@ keywords = [ ] license = "BSD-3-Clause" authors = [{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" }] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Environment :: Console", - "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Database", "Topic :: System :: Archiving", "Topic :: System :: Archiving :: Backup", "Topic :: System :: Archiving :: Compression", ] -dependencies = ["django>=4.2"] +dependencies = ["django>=5.0"] dynamic = ["version"] urls.Changelog = "https://archmonger.github.io/django-dbbackup/latest/changelog/" urls.Documentation = "https://archmonger.github.io/django-dbbackup" @@ -80,11 +80,6 @@ matrix-name-format = "{variable}-{value}" [tool.hatch.envs.hatch-test.env-vars] DJANGO_SETTINGS_MODULE = "tests.settings" -# Django 4.2 -[[tool.hatch.envs.hatch-test.matrix]] -python = ["3.9", "3.10", "3.11", "3.12"] -django = ["4.2"] - # Django 5.0 [[tool.hatch.envs.hatch-test.matrix]] python = ["3.10", "3.11", "3.12"] @@ -100,11 +95,13 @@ django = ["5.1"] python = ["3.10", "3.11", "3.12", "3.13"] django = ["5.2"] +# Django 6.0 +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.12", "3.13", "3.14"] +django = ["6.0"] + [tool.hatch.envs.hatch-test.overrides] matrix.django.dependencies = [ - { if = [ - "4.2", - ], value = "django>=4.2,<4.3" }, { if = [ "5.0", ], value = "django>=5.0,<5.1" }, @@ -114,6 +111,9 @@ matrix.django.dependencies = [ { if = [ "5.2", ], value = "django>=5.2,<5.3" }, + { if = [ + "6.0", + ], value = "django>=6.0,<6.1" }, ] # >>> Documentation Scripts <<< @@ -191,7 +191,6 @@ postgres = ["python scripts/postgres_live_test.py {args}"] [tool.ruff] line-length = 120 -target-version = "py39" extend-exclude = [".eggs/*", ".tox/*", ".venv/*", "build/*", "*/migrations/*"] format.preview = true lint.extend-ignore = [ diff --git a/tests/test_connectors/test_django.py b/tests/test_connectors/test_django.py index 3e5b65f9..739fef22 100644 --- a/tests/test_connectors/test_django.py +++ b/tests/test_connectors/test_django.py @@ -47,9 +47,9 @@ def mock_dumpdata(*args, **kwargs): # Verify dump content assert isinstance(dump, SpooledTemporaryFile) dump.seek(0) - content = dump.read() # Already a string in text mode - assert '"model": "auth.user"' in content - assert '"username": "test"' in content + content = dump.read() + assert b'"model": "auth.user"' in content + assert b'"username": "test"' in content @patch("dbbackup.db.django.call_command") def test_create_dump_with_exclude_app_model_format(self, mock_call_command): @@ -243,3 +243,27 @@ def mock_dumpdata(*args, **kwargs): # Verify loaddata was called assert mock_call_command.call_count == 1 + + @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: + kwargs["stdout"].write('[{"model": "auth.user", "pk": 1, "fields": {"username": "test"}}]') + + mock_call_command.side_effect = mock_dumpdata + + # Create the dump + dump_file = self.connector.create_dump() + dump_file.seek(0) + + # 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()) + + # Check that we have compressed data + compressed_file.seek(0) + assert len(compressed_file.read()) > 0