From 108592f2896749cd461b6e7baa9593f04baed199 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Jun 2026 17:13:17 +0200 Subject: [PATCH] feat(backup): wrapper de stockage S3 (boto3) + faux stockage de test (#89) --- src/backup/storage.py | 53 +++++++++++++++++++++++++++++++++ tests/backup/fakes.py | 15 ++++++++++ tests/backup/test_storage_s3.py | 31 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/backup/storage.py create mode 100644 tests/backup/fakes.py create mode 100644 tests/backup/test_storage_s3.py diff --git a/src/backup/storage.py b/src/backup/storage.py new file mode 100644 index 0000000..0e7b734 --- /dev/null +++ b/src/backup/storage.py @@ -0,0 +1,53 @@ +from typing import Protocol + +import boto3 + +from src.backup.config import BackupConfig + + +class Storage(Protocol): + def upload_bytes(self, key: str, data: bytes) -> None: ... + def download_bytes(self, key: str) -> bytes: ... + def list_keys(self, prefix: str) -> list[str]: ... + def delete(self, key: str) -> None: ... + + +class S3Storage: + def __init__( + self, bucket, endpoint_url, access_key, secret_key, region="us-east-1" + ): + self._bucket = bucket + self._client = boto3.client( + "s3", + endpoint_url=endpoint_url, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + ) + + @classmethod + def from_config(cls, config: BackupConfig) -> "S3Storage": + return cls( + bucket=config.bucket, + endpoint_url=config.endpoint_url, + access_key=config.access_key, + secret_key=config.secret_key, + ) + + def upload_bytes(self, key: str, data: bytes) -> None: + self._client.put_object(Bucket=self._bucket, Key=key, Body=data) + + def download_bytes(self, key: str) -> bytes: + resp = self._client.get_object(Bucket=self._bucket, Key=key) + return resp["Body"].read() + + def list_keys(self, prefix: str) -> list[str]: + keys: list[str] = [] + paginator = self._client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix): + for obj in page.get("Contents", []): + keys.append(obj["Key"]) + return keys + + def delete(self, key: str) -> None: + self._client.delete_object(Bucket=self._bucket, Key=key) diff --git a/tests/backup/fakes.py b/tests/backup/fakes.py new file mode 100644 index 0000000..9eb1c50 --- /dev/null +++ b/tests/backup/fakes.py @@ -0,0 +1,15 @@ +class FakeStorage: + def __init__(self): + self.objects: dict[str, bytes] = {} + + def upload_bytes(self, key, data): + self.objects[key] = data + + def download_bytes(self, key): + return self.objects[key] + + def list_keys(self, prefix): + return [k for k in self.objects if k.startswith(prefix)] + + def delete(self, key): + self.objects.pop(key, None) diff --git a/tests/backup/test_storage_s3.py b/tests/backup/test_storage_s3.py new file mode 100644 index 0000000..ff58e33 --- /dev/null +++ b/tests/backup/test_storage_s3.py @@ -0,0 +1,31 @@ +import boto3 +import pytest +from moto import mock_aws + +from src.backup.storage import S3Storage + +BUCKET = "decp-backups" + + +@pytest.fixture +def s3_storage(): + with mock_aws(): + boto3.client("s3", region_name="us-east-1").create_bucket(Bucket=BUCKET) + yield S3Storage( + bucket=BUCKET, + endpoint_url=None, + access_key="testing", + secret_key="testing", + ) + + +def test_upload_list_download_delete(s3_storage): + s3_storage.upload_bytes("backups/a.enc", b"alpha") + s3_storage.upload_bytes("backups/b.enc", b"beta") + s3_storage.upload_bytes("autre/c.enc", b"gamma") + + assert set(s3_storage.list_keys("backups/")) == {"backups/a.enc", "backups/b.enc"} + assert s3_storage.download_bytes("backups/a.enc") == b"alpha" + + s3_storage.delete("backups/a.enc") + assert s3_storage.list_keys("backups/") == ["backups/b.enc"]