diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5930217..ea8a5f0 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - 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@v3 diff --git a/django_sqlite_backup/aws.py b/django_sqlite_backup/aws.py index 0955813..ed43e4b 100644 --- a/django_sqlite_backup/aws.py +++ b/django_sqlite_backup/aws.py @@ -57,3 +57,17 @@ def restore_db(self, date_str: str) -> None: with open(db_name, "wb") as f: f.write(response.get("Body").read()) + + +class AwsListDb: + def list_db(self) -> list[str]: + bucket_name = settings.SQLITE_BACKUP.get("BUCKET_NAME") + + if bucket_name is None: + raise ImproperlyConfigured("`BUCKET_NAME` is not defined") + + response = s3().list_objects( + Bucket=bucket_name, + ) + + return [backup_date["Key"] for backup_date in response["Contents"]] diff --git a/django_sqlite_backup/list_backups.py b/django_sqlite_backup/list_backups.py new file mode 100644 index 0000000..f8d69df --- /dev/null +++ b/django_sqlite_backup/list_backups.py @@ -0,0 +1,26 @@ +from typing import Protocol +from typing import runtime_checkable + +from django.conf import settings +from django.utils.module_loading import import_string + + +@runtime_checkable +class SqliteList(Protocol): + def list_db(self) -> list[str]: + pass + + +DEFAULT_LIST_CLASS = "django_sqlite_backup.aws.AwsListDb" + + +def get_list_class() -> type[SqliteList]: + class_string = getattr(settings, "SQLITE_BACKUP", {}).get("LIST_CLASS") or DEFAULT_LIST_CLASS + + return import_string(class_string) + + +def do_list() -> list[str]: + list_class = get_list_class() + list_class_instance = list_class() + return list_class_instance.list_db() diff --git a/django_sqlite_backup/management/commands/list_backups.py b/django_sqlite_backup/management/commands/list_backups.py new file mode 100644 index 0000000..1f9bf70 --- /dev/null +++ b/django_sqlite_backup/management/commands/list_backups.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from django_sqlite_backup import list_backups + + +class Command(BaseCommand): + def handle(self, *args, **options) -> None: + backups = list_backups.do_list() + + for backup in backups: + self.stdout.write(backup) diff --git a/pyproject.toml b/pyproject.toml index 59b9446..fe3040e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "django-sqlite-backup" version = "0.2.0" description = "A Django app to easily backup your sqlite database through an endpoint." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { file = "LICENSE" } authors = [ {name = "Ferran Jovell", email = "ferran.jovell+gh@gmail.com"} @@ -12,10 +12,11 @@ keywords = ["django"] classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.8", - "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", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", "Environment :: Web Environment", diff --git a/testing/dummy_backup.py b/testing/dummy_backup.py index 7d919e6..1e5196d 100644 --- a/testing/dummy_backup.py +++ b/testing/dummy_backup.py @@ -9,3 +9,8 @@ def get_database_name(self) -> str: class DummySqliteRestore: def restore_db(self, date_str: str = "") -> None: pass + + +class DummyListBackups: + def list_db(self) -> list[str]: + return [] diff --git a/tests/test_aws_list_backups.py b/tests/test_aws_list_backups.py new file mode 100644 index 0000000..e286b9a --- /dev/null +++ b/tests/test_aws_list_backups.py @@ -0,0 +1,33 @@ +import pytest +from django.conf import ImproperlyConfigured +from moto import mock_aws + +from django_sqlite_backup.aws import AwsListDb +from testing.constants import TEST_DATE_STR +from testing.constants import TEST_DB_NAME + + +@pytest.fixture +def instance(): + return AwsListDb() + + +@mock_aws +@pytest.mark.usefixtures("aws_credentials") +def test_aws_sqlite_list_db_raises_exception_if_bucket_name_not_defined( + instance, setup_test_bucket, test_settings +): + setup_test_bucket() + del test_settings.SQLITE_BACKUP["BUCKET_NAME"] + with pytest.raises(ImproperlyConfigured): + instance.list_db() + + +@mock_aws +@pytest.mark.usefixtures("aws_credentials") +def test_aws_sqlite_list_db_returns_list_of_backups( + instance, setup_sqlite_restore, default_settings, fake_db +): + setup_sqlite_restore() + default_settings.DATABASES["default"]["NAME"] = TEST_DB_NAME + assert instance.list_db() == [f"sqlite_backup/{TEST_DATE_STR}/fake.db"] diff --git a/tests/test_decorators.py b/tests/test_decorators.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_list_backup.py b/tests/test_list_backup.py new file mode 100644 index 0000000..6999641 --- /dev/null +++ b/tests/test_list_backup.py @@ -0,0 +1,27 @@ +from unittest.mock import patch + +import pytest + +from django_sqlite_backup import list_backups +from django_sqlite_backup.aws import AwsListDb + + +def test_get_list_class_returns_default_value(): + cls = list_backups.get_list_class() + assert cls is AwsListDb + + +def test_get_backup_class_with_wrong_settings_raises_exception(default_settings): + default_settings.SQLITE_BACKUP["LIST_CLASS"] = "foo.bar.Baz" + + with pytest.raises(ModuleNotFoundError): + list_backups.get_list_class() + + +def test_do_list_backups_calls_do_list_method(default_settings): + default_settings.SQLITE_BACKUP["LIST_CLASS"] = "testing.dummy_backup.DummyListBackups" + + with patch("testing.dummy_backup.DummyListBackups.list_db") as mocked: + list_backups.do_list() + + mocked.assert_called_with() diff --git a/tests/test_list_backup_command.py b/tests/test_list_backup_command.py new file mode 100644 index 0000000..5acbfbe --- /dev/null +++ b/tests/test_list_backup_command.py @@ -0,0 +1,28 @@ +from unittest.mock import patch + +import pytest +from django.core.management import call_command + + +@pytest.fixture +def mock_backup(): + with patch( + "django_sqlite_backup.list_backups.do_list", + return_value=["foo/bar/baz.db"], + ) as mocked: + yield mocked + + +@pytest.fixture +def list_run(mock_backup): + def _(*args, **kwatgs): + call_command("list_backups") + + return _ + + +def test_list_backups_command_writes_to_stdout_on_success(list_run, capsys): + list_run() + out, err = capsys.readouterr() + assert out == "foo/bar/baz.db\n" + assert err == ""