Skip to content

Commit 89402df

Browse files
saberrazclaude
andcommitted
Add Azure Blob Storage driver support
Implements AzureBlobDriver following the same pattern as MinioDriver, allowing media files to be synced to Azure Blob Storage containers. Changes: - drivers.py: Add DriverType.AZURE enum value and AzureBlobDriver class using azure-storage-blob SDK; supports optional blob_path_prefix - config.py: Add AZURE to supported driver check and validate azure_blob config block (account_name, account_key, container required) - config.yaml.default: Add azure_blob config section with all options - Pipfile: Add azure-storage-blob ~=12.0 dependency - test/conftest.py: Add TEST_AZURE_STORAGE_* env vars and reset defaults - test/test_sync.py: Add test_azure_blob_backend covering invalid config, basic upload verification, and blob_path_prefix behaviour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 60268a9 commit 89402df

6 files changed

Lines changed: 185 additions & 2 deletions

File tree

Pipfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ minio = "~=7.1"
1212
mergin-client = "==0.9.3"
1313
dynaconf = {extras = ["ini"],version = "~=3.1"}
1414
google-api-python-client = "==2.24"
15+
azure-storage-blob = "~=12.0"
1516

1617
[requires]
1718
python_version = "3"

config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def validate_config(config):
3333
config.driver == DriverType.LOCAL
3434
or config.driver == DriverType.MINIO
3535
or config.driver == DriverType.GOOGLE_DRIVE
36+
or config.driver == DriverType.AZURE
3637
):
3738
raise ConfigError("Config error: Unsupported driver")
3839

@@ -78,6 +79,17 @@ def validate_config(config):
7879
):
7980
raise ConfigError("Config error: Incorrect GoogleDrive driver settings")
8081

82+
if config.driver == DriverType.AZURE and not (
83+
hasattr(config, "azure_blob")
84+
and hasattr(config.azure_blob, "account_name")
85+
and hasattr(config.azure_blob, "account_key")
86+
and hasattr(config.azure_blob, "container")
87+
and config.azure_blob.account_name
88+
and config.azure_blob.account_key
89+
and config.azure_blob.container
90+
):
91+
raise ConfigError("Config error: Incorrect Azure Blob Storage driver settings")
92+
8193

8294
def update_config_path(
8395
path_param: str,

config.yaml.default

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,16 @@ minio:
2525
bucket_subpath:
2626

2727
google_drive:
28-
service_account_file:
28+
service_account_file:
2929
folder:
3030
share_with:
3131

32+
azure_blob:
33+
account_name:
34+
account_key:
35+
container:
36+
blob_path_prefix:
37+
3238
references:
3339
- file: survey.gpkg
3440
table: notes

drivers.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@
2121
from googleapiclient.discovery import build, Resource
2222
from googleapiclient.http import MediaFileUpload
2323

24+
from azure.storage.blob import BlobServiceClient
25+
from azure.core.exceptions import AzureError
26+
2427

2528
class DriverType(enum.Enum):
2629
LOCAL = "local"
2730
MINIO = "minio"
2831
GOOGLE_DRIVE = "google_drive"
32+
AZURE = "azure"
2933

3034
def __eq__(self, value):
3135
if isinstance(value, str):
@@ -282,6 +286,46 @@ def _get_share_with(self, config_google_drive) -> typing.List[str]:
282286
return emails_to_share_with
283287

284288

289+
class AzureBlobDriver(Driver):
290+
"""Driver to handle connection to Azure Blob Storage"""
291+
292+
def __init__(self, config):
293+
super(AzureBlobDriver, self).__init__(config)
294+
295+
try:
296+
self.account_name = config.azure_blob.account_name
297+
connection_string = (
298+
f"DefaultEndpointsProtocol=https;"
299+
f"AccountName={self.account_name};"
300+
f"AccountKey={config.azure_blob.account_key};"
301+
f"EndpointSuffix=core.windows.net"
302+
)
303+
service_client = BlobServiceClient.from_connection_string(connection_string)
304+
self.container = config.azure_blob.container
305+
container_client = service_client.get_container_client(self.container)
306+
if not container_client.exists():
307+
container_client.create_container()
308+
self.client = container_client
309+
310+
self.blob_path_prefix = None
311+
if hasattr(config.azure_blob, "blob_path_prefix"):
312+
if config.azure_blob.blob_path_prefix:
313+
self.blob_path_prefix = config.azure_blob.blob_path_prefix
314+
315+
except AzureError as e:
316+
raise DriverError("Azure Blob Storage driver init error: " + str(e))
317+
318+
def upload_file(self, src: str, obj_path: str) -> str:
319+
if self.blob_path_prefix:
320+
obj_path = f"{self.blob_path_prefix}/{obj_path}"
321+
try:
322+
with open(src, "rb") as data:
323+
self.client.upload_blob(name=obj_path, data=data, overwrite=True)
324+
except AzureError as e:
325+
raise DriverError("Azure Blob Storage driver error: " + str(e))
326+
return f"https://{self.account_name}.blob.core.windows.net/{self.container}/{obj_path}"
327+
328+
285329
def create_driver(config):
286330
"""Create driver object based on type defined in config"""
287331
driver = None
@@ -291,4 +335,6 @@ def create_driver(config):
291335
driver = MinioDriver(config)
292336
elif config.driver == DriverType.GOOGLE_DRIVE:
293337
driver = GoogleDriveDriver(config)
338+
elif config.driver == DriverType.AZURE:
339+
driver = AzureBlobDriver(config)
294340
return driver

test/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
GOOGLE_DRIVE_SERVICE_ACCOUNT_FILE = os.environ.get(
2121
"TEST_GOOGLE_DRIVE_SERVICE_ACCOUNT_FILE"
2222
)
23+
AZURE_STORAGE_ACCOUNT_NAME = os.environ.get("TEST_AZURE_STORAGE_ACCOUNT_NAME")
24+
AZURE_STORAGE_ACCOUNT_KEY = os.environ.get("TEST_AZURE_STORAGE_ACCOUNT_KEY")
25+
AZURE_STORAGE_CONTAINER = os.environ.get("TEST_AZURE_STORAGE_CONTAINER")
2326

2427

2528
@pytest.fixture(scope="function")
@@ -49,6 +52,10 @@ def setup_config():
4952
"MINIO__BUCKET_SUBPATH": "",
5053
"MINIO__SECURE": False,
5154
"MINIO__REGION": "",
55+
"AZURE_BLOB__ACCOUNT_NAME": "",
56+
"AZURE_BLOB__ACCOUNT_KEY": "",
57+
"AZURE_BLOB__CONTAINER": "",
58+
"AZURE_BLOB__BLOB_PATH_PREFIX": "",
5259
}
5360
)
5461

test/test_sync.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import shutil
1212
import sqlite3
1313

14-
from drivers import MinioDriver, LocalDriver, GoogleDriveDriver
14+
from drivers import MinioDriver, LocalDriver, GoogleDriveDriver, AzureBlobDriver
1515
from media_sync import (
1616
main,
1717
config,
@@ -33,6 +33,9 @@
3333
MINIO_SECRET_KEY,
3434
GOOGLE_DRIVE_SERVICE_ACCOUNT_FILE,
3535
GOOGLE_DRIVE_FOLDER,
36+
AZURE_STORAGE_ACCOUNT_NAME,
37+
AZURE_STORAGE_ACCOUNT_KEY,
38+
AZURE_STORAGE_CONTAINER,
3639
cleanup,
3740
prepare_mergin_project,
3841
)
@@ -634,3 +637,111 @@ def test_google_drive_backend(mc):
634637
# files in mergin project still exist (copy mode)
635638
assert os.path.exists(os.path.join(work_project_dir, "img1.png"))
636639
assert os.path.exists(os.path.join(work_project_dir, "images", "img2.jpg"))
640+
641+
642+
def test_azure_blob_backend(mc):
643+
"""Test media sync connected to Azure Blob Storage backend (needs valid Azure credentials)"""
644+
project_name = "mediasync_test_azure"
645+
full_project_name = WORKSPACE + "/" + project_name
646+
work_project_dir = os.path.join(TMP_DIR, project_name + "_work")
647+
648+
cleanup(mc, full_project_name, [work_project_dir])
649+
prepare_mergin_project(mc, full_project_name)
650+
651+
# invalid config - missing required fields
652+
config.update(
653+
{
654+
"MERGIN__USERNAME": API_USER,
655+
"MERGIN__PASSWORD": USER_PWD,
656+
"MERGIN__URL": SERVER_URL,
657+
"MERGIN__PROJECT_NAME": full_project_name,
658+
"PROJECT_WORKING_DIR": work_project_dir,
659+
"OPERATION_MODE": "copy",
660+
"REFERENCES": [
661+
{
662+
"file": None,
663+
"table": None,
664+
"local_path_column": None,
665+
"driver_path_column": None,
666+
}
667+
],
668+
"DRIVER": "azure",
669+
"AZURE_BLOB__ACCOUNT_NAME": AZURE_STORAGE_ACCOUNT_NAME,
670+
"AZURE_BLOB__ACCOUNT_KEY": "",
671+
"AZURE_BLOB__CONTAINER": AZURE_STORAGE_CONTAINER,
672+
}
673+
)
674+
675+
with pytest.raises(ConfigError):
676+
validate_config(config)
677+
678+
# patch config to fit testing purposes
679+
config.update(
680+
{
681+
"MERGIN__USERNAME": API_USER,
682+
"MERGIN__PASSWORD": USER_PWD,
683+
"MERGIN__URL": SERVER_URL,
684+
"MERGIN__PROJECT_NAME": full_project_name,
685+
"PROJECT_WORKING_DIR": work_project_dir,
686+
"OPERATION_MODE": "copy",
687+
"REFERENCES": [
688+
{
689+
"file": None,
690+
"table": None,
691+
"local_path_column": None,
692+
"driver_path_column": None,
693+
}
694+
],
695+
"DRIVER": "azure",
696+
"AZURE_BLOB__ACCOUNT_NAME": AZURE_STORAGE_ACCOUNT_NAME,
697+
"AZURE_BLOB__ACCOUNT_KEY": AZURE_STORAGE_ACCOUNT_KEY,
698+
"AZURE_BLOB__CONTAINER": AZURE_STORAGE_CONTAINER,
699+
}
700+
)
701+
702+
main()
703+
704+
# verify files were uploaded to Azure Blob Storage
705+
driver = AzureBlobDriver(config)
706+
blob_names = [b.name for b in driver.client.list_blobs()]
707+
assert "img1.png" in blob_names
708+
assert "images/img2.jpg" in blob_names
709+
710+
# files in mergin project still exist (copy mode)
711+
assert os.path.exists(os.path.join(work_project_dir, "img1.png"))
712+
assert os.path.exists(os.path.join(work_project_dir, "images", "img2.jpg"))
713+
714+
# test with blob_path_prefix
715+
cleanup(mc, full_project_name, [work_project_dir])
716+
prepare_mergin_project(mc, full_project_name)
717+
718+
config.update(
719+
{
720+
"MERGIN__USERNAME": API_USER,
721+
"MERGIN__PASSWORD": USER_PWD,
722+
"MERGIN__URL": SERVER_URL,
723+
"MERGIN__PROJECT_NAME": full_project_name,
724+
"PROJECT_WORKING_DIR": work_project_dir,
725+
"OPERATION_MODE": "copy",
726+
"REFERENCES": [
727+
{
728+
"file": None,
729+
"table": None,
730+
"local_path_column": None,
731+
"driver_path_column": None,
732+
}
733+
],
734+
"DRIVER": "azure",
735+
"AZURE_BLOB__ACCOUNT_NAME": AZURE_STORAGE_ACCOUNT_NAME,
736+
"AZURE_BLOB__ACCOUNT_KEY": AZURE_STORAGE_ACCOUNT_KEY,
737+
"AZURE_BLOB__CONTAINER": AZURE_STORAGE_CONTAINER,
738+
"AZURE_BLOB__BLOB_PATH_PREFIX": "subPath",
739+
}
740+
)
741+
742+
main()
743+
744+
driver = AzureBlobDriver(config)
745+
blob_names = [b.name for b in driver.client.list_blobs()]
746+
assert "subPath/img1.png" in blob_names
747+
assert "subPath/images/img2.jpg" in blob_names

0 commit comments

Comments
 (0)