srx-lib-azure 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of srx-lib-azure might be problematic. Click here for more details.
- srx_lib_azure/__init__.py +6 -0
- srx_lib_azure/blob.py +113 -0
- srx_lib_azure/email.py +41 -0
- srx_lib_azure/table.py +79 -0
- srx_lib_azure-0.1.2.dist-info/METADATA +70 -0
- srx_lib_azure-0.1.2.dist-info/RECORD +7 -0
- srx_lib_azure-0.1.2.dist-info/WHEEL +4 -0
srx_lib_azure/blob.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from typing import Optional, BinaryIO, Tuple
|
|
4
|
+
|
|
5
|
+
from azure.storage.blob import BlobServiceClient, BlobSasPermissions, generate_blob_sas
|
|
6
|
+
from fastapi import UploadFile
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AzureBlobService:
|
|
12
|
+
"""Minimal Azure Blob helper with SAS URL generation."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.container_name = os.getenv("AZURE_BLOB_CONTAINER", "uploads")
|
|
16
|
+
self.connection_string = os.getenv("AZURE_STORAGE_CONNECTION_STRING")
|
|
17
|
+
self.account_key = os.getenv("AZURE_STORAGE_ACCOUNT_KEY")
|
|
18
|
+
self.sas_token = os.getenv("AZURE_SAS_TOKEN")
|
|
19
|
+
self.base_blob_url = os.getenv("AZURE_BLOB_URL")
|
|
20
|
+
|
|
21
|
+
if not self.connection_string:
|
|
22
|
+
logger.warning(
|
|
23
|
+
"Azure Storage connection string not configured; blob operations will fail."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def _get_blob_service(self) -> BlobServiceClient:
|
|
27
|
+
if not self.connection_string:
|
|
28
|
+
raise RuntimeError("AZURE_STORAGE_CONNECTION_STRING not configured")
|
|
29
|
+
clean = self.connection_string.strip().strip('"').strip("'")
|
|
30
|
+
return BlobServiceClient.from_connection_string(clean)
|
|
31
|
+
|
|
32
|
+
def _parse_account_from_connection_string(self) -> Tuple[Optional[str], Optional[str]]:
|
|
33
|
+
if not self.connection_string:
|
|
34
|
+
return None, None
|
|
35
|
+
try:
|
|
36
|
+
clean = self.connection_string.strip().strip('"').strip("'")
|
|
37
|
+
parts = dict(
|
|
38
|
+
seg.split("=", 1) for seg in clean.split(";") if "=" in seg
|
|
39
|
+
)
|
|
40
|
+
account_name = parts.get("AccountName")
|
|
41
|
+
account_key = parts.get("AccountKey") or self.account_key
|
|
42
|
+
return account_name, account_key
|
|
43
|
+
except Exception:
|
|
44
|
+
return None, None
|
|
45
|
+
|
|
46
|
+
def _ensure_container(self, client: BlobServiceClient) -> None:
|
|
47
|
+
try:
|
|
48
|
+
client.create_container(self.container_name)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def _generate_sas_url(self, blob_name: str, expiry_days: int = 730) -> str:
|
|
53
|
+
account_name, account_key = self._parse_account_from_connection_string()
|
|
54
|
+
if not account_name:
|
|
55
|
+
try:
|
|
56
|
+
client = self._get_blob_service()
|
|
57
|
+
account_name = getattr(client, "account_name", None)
|
|
58
|
+
except Exception:
|
|
59
|
+
account_name = None
|
|
60
|
+
|
|
61
|
+
account_key = account_key or self.account_key
|
|
62
|
+
if not account_name or not account_key:
|
|
63
|
+
raise RuntimeError("Azure Storage account name/key not configured; cannot generate SAS")
|
|
64
|
+
|
|
65
|
+
start_time = datetime.now(timezone.utc) - timedelta(minutes=5)
|
|
66
|
+
expiry_time = start_time + timedelta(days=expiry_days)
|
|
67
|
+
sas = generate_blob_sas(
|
|
68
|
+
account_name=account_name,
|
|
69
|
+
container_name=self.container_name,
|
|
70
|
+
blob_name=blob_name,
|
|
71
|
+
account_key=account_key,
|
|
72
|
+
permission=BlobSasPermissions(read=True),
|
|
73
|
+
start=start_time,
|
|
74
|
+
expiry=expiry_time,
|
|
75
|
+
protocol="https",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if self.base_blob_url:
|
|
79
|
+
base_url = self.base_blob_url.strip().strip('"').strip("'").rstrip("/")
|
|
80
|
+
return f"{base_url}/{blob_name}?{sas}"
|
|
81
|
+
return f"https://{account_name}.blob.core.windows.net/{self.container_name}/{blob_name}?{sas}"
|
|
82
|
+
|
|
83
|
+
async def upload_file(self, file: UploadFile, blob_path: str) -> Optional[str]:
|
|
84
|
+
if not self.connection_string:
|
|
85
|
+
logger.error("Azure Storage connection string not configured")
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
client = self._get_blob_service()
|
|
89
|
+
self._ensure_container(client)
|
|
90
|
+
container = client.get_container_client(self.container_name)
|
|
91
|
+
content = await file.read()
|
|
92
|
+
blob_client = container.get_blob_client(blob_path)
|
|
93
|
+
blob_client.upload_blob(content, overwrite=True, content_type=file.content_type or "application/octet-stream")
|
|
94
|
+
return self._generate_sas_url(blob_path)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(f"Failed to upload file {file.filename}: {e}")
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
async def upload_stream(self, stream: BinaryIO, blob_path: str, content_type: str = "application/octet-stream") -> Optional[str]:
|
|
100
|
+
if not self.connection_string:
|
|
101
|
+
logger.error("Azure Storage connection string not configured")
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
client = self._get_blob_service()
|
|
105
|
+
self._ensure_container(client)
|
|
106
|
+
container = client.get_container_client(self.container_name)
|
|
107
|
+
blob_client = container.get_blob_client(blob_path)
|
|
108
|
+
blob_client.upload_blob(stream, overwrite=True, content_type=content_type)
|
|
109
|
+
return self._generate_sas_url(blob_path)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error(f"Failed to upload stream to {blob_path}: {e}")
|
|
112
|
+
return None
|
|
113
|
+
|
srx_lib_azure/email.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from azure.communication.email.aio import EmailClient
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EmailService:
|
|
10
|
+
"""Thin wrapper over Azure Communication Services EmailClient."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.connection_string = os.getenv("ACS_CONNECTION_STRING")
|
|
14
|
+
self.sender_address = os.getenv("EMAIL_SENDER")
|
|
15
|
+
if not self.connection_string or not self.sender_address:
|
|
16
|
+
raise ValueError("Missing ACS_CONNECTION_STRING or EMAIL_SENDER in environment variables")
|
|
17
|
+
self.email_client = EmailClient.from_connection_string(self.connection_string)
|
|
18
|
+
|
|
19
|
+
async def send_notification(self, recipient: str, subject: str, body: str, html: bool = False):
|
|
20
|
+
message = {
|
|
21
|
+
"content": {"subject": subject},
|
|
22
|
+
"recipients": {"to": [{"address": recipient}]},
|
|
23
|
+
"senderAddress": self.sender_address,
|
|
24
|
+
}
|
|
25
|
+
if html:
|
|
26
|
+
message["content"]["html"] = body
|
|
27
|
+
else:
|
|
28
|
+
message["content"]["plainText"] = body
|
|
29
|
+
try:
|
|
30
|
+
poller = await self.email_client.begin_send(message)
|
|
31
|
+
result = await poller.result()
|
|
32
|
+
message_id = result.get("id")
|
|
33
|
+
if message_id:
|
|
34
|
+
logger.info("Email sent to %s with Message ID: %s", recipient, message_id)
|
|
35
|
+
return {"status": "success", "message": "Email sent successfully", "message_id": message_id}
|
|
36
|
+
logger.error("Failed to send email. Result: %s", result)
|
|
37
|
+
return {"status": "error", "message": f"Failed to send email: {result}"}
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.error("Email send exception: %s", e)
|
|
40
|
+
return {"status": "error", "message": str(e)}
|
|
41
|
+
|
srx_lib_azure/table.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, Iterable, Optional
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from azure.data.tables import TableServiceClient
|
|
12
|
+
except Exception: # pragma: no cover
|
|
13
|
+
TableServiceClient = None # type: ignore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now_iso() -> str:
|
|
17
|
+
return datetime.now(timezone.utc).isoformat()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AzureTableService:
|
|
22
|
+
connection_string: Optional[str] = os.getenv("AZURE_STORAGE_CONNECTION_STRING")
|
|
23
|
+
|
|
24
|
+
def _get_client(self) -> "TableServiceClient":
|
|
25
|
+
if not self.connection_string:
|
|
26
|
+
raise RuntimeError("AZURE_STORAGE_CONNECTION_STRING not configured")
|
|
27
|
+
if TableServiceClient is None:
|
|
28
|
+
raise RuntimeError("azure-data-tables not installed; install to use table operations")
|
|
29
|
+
clean = self.connection_string.strip().strip('"').strip("'")
|
|
30
|
+
return TableServiceClient.from_connection_string(conn_str=clean)
|
|
31
|
+
|
|
32
|
+
def ensure_table(self, table_name: str) -> None:
|
|
33
|
+
client = self._get_client()
|
|
34
|
+
try:
|
|
35
|
+
client.create_table_if_not_exists(table_name=table_name)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.warning("ensure_table(%s) warning: %s", table_name, e)
|
|
38
|
+
|
|
39
|
+
def put_entity(self, table_name: str, entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
40
|
+
client = self._get_client()
|
|
41
|
+
table = client.get_table_client(table_name)
|
|
42
|
+
res = table.create_entity(entity=entity)
|
|
43
|
+
logger.info(
|
|
44
|
+
"Inserted entity into %s: PK=%s RK=%s",
|
|
45
|
+
table_name,
|
|
46
|
+
entity.get("PartitionKey"),
|
|
47
|
+
entity.get("RowKey"),
|
|
48
|
+
)
|
|
49
|
+
return {"etag": getattr(res, "etag", None), "ts": _now_iso()}
|
|
50
|
+
|
|
51
|
+
def upsert_entity(self, table_name: str, entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
52
|
+
client = self._get_client()
|
|
53
|
+
table = client.get_table_client(table_name)
|
|
54
|
+
res = table.upsert_entity(entity=entity, mode="merge")
|
|
55
|
+
logger.info(
|
|
56
|
+
"Upserted entity into %s: PK=%s RK=%s",
|
|
57
|
+
table_name,
|
|
58
|
+
entity.get("PartitionKey"),
|
|
59
|
+
entity.get("RowKey"),
|
|
60
|
+
)
|
|
61
|
+
return {"etag": getattr(res, "etag", None), "ts": _now_iso()}
|
|
62
|
+
|
|
63
|
+
def delete_entity(self, table_name: str, partition_key: str, row_key: str) -> bool:
|
|
64
|
+
client = self._get_client()
|
|
65
|
+
table = client.get_table_client(table_name)
|
|
66
|
+
try:
|
|
67
|
+
table.delete_entity(partition_key=partition_key, row_key=row_key)
|
|
68
|
+
logger.info("Deleted entity in %s (%s/%s)", table_name, partition_key, row_key)
|
|
69
|
+
return True
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.error("Failed to delete entity in %s: %s", table_name, exc)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def query(self, table_name: str, filter_query: str) -> Iterable[Dict[str, Any]]:
|
|
75
|
+
client = self._get_client()
|
|
76
|
+
table = client.get_table_client(table_name)
|
|
77
|
+
for entity in table.query_entities(filter=filter_query):
|
|
78
|
+
yield dict(entity)
|
|
79
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: srx-lib-azure
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Azure helpers for SRX services: Blob, Email, Table
|
|
5
|
+
Author-email: SRX <dev@srx.id>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: azure-communication-email>=1.0.0
|
|
8
|
+
Requires-Dist: azure-data-tables>=12.7.0
|
|
9
|
+
Requires-Dist: azure-storage-blob>=12.22.0
|
|
10
|
+
Requires-Dist: loguru>=0.7.2
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# srx-lib-azure
|
|
14
|
+
|
|
15
|
+
Lightweight wrappers over Azure SDKs used across SRX services.
|
|
16
|
+
|
|
17
|
+
What it includes:
|
|
18
|
+
- Blob: upload/download helpers, SAS URL generation
|
|
19
|
+
- Email (Azure Communication Services): simple async sender
|
|
20
|
+
- Table: simple CRUD helpers
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
PyPI (public):
|
|
25
|
+
|
|
26
|
+
- `pip install srx-lib-azure`
|
|
27
|
+
|
|
28
|
+
uv (pyproject):
|
|
29
|
+
```
|
|
30
|
+
[project]
|
|
31
|
+
dependencies = ["srx-lib-azure>=0.1.0"]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Blob:
|
|
37
|
+
```
|
|
38
|
+
from srx_lib_azure.blob import AzureBlobService
|
|
39
|
+
blob = AzureBlobService()
|
|
40
|
+
url = await blob.upload_file(upload_file, "documents/report.pdf")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Email:
|
|
44
|
+
```
|
|
45
|
+
from srx_lib_azure.email import EmailService
|
|
46
|
+
svc = EmailService()
|
|
47
|
+
await svc.send_notification("user@example.com", "Subject", "Hello", html=False)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Table:
|
|
51
|
+
```
|
|
52
|
+
from srx_lib_azure.table import AzureTableService
|
|
53
|
+
store = AzureTableService()
|
|
54
|
+
store.ensure_table("events")
|
|
55
|
+
store.upsert_entity("events", {"PartitionKey":"p","RowKey":"r","EventType":"x"})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Environment Variables
|
|
59
|
+
|
|
60
|
+
- Blob & Table: `AZURE_STORAGE_CONNECTION_STRING` (required)
|
|
61
|
+
- Email (ACS): `ACS_CONNECTION_STRING`, `EMAIL_SENDER`
|
|
62
|
+
- Optional: `AZURE_STORAGE_ACCOUNT_KEY`, `AZURE_BLOB_URL`, `AZURE_SAS_TOKEN`
|
|
63
|
+
|
|
64
|
+
## Release
|
|
65
|
+
|
|
66
|
+
Tag `vX.Y.Z` to publish to GitHub Packages via Actions.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
Proprietary © SRX
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
srx_lib_azure/__init__.py,sha256=K0UCmkKw7HWJMshp6Xv3SxD4y26r7bdcPtb_2aRc2rs,174
|
|
2
|
+
srx_lib_azure/blob.py,sha256=Y720DmrfYkyZIXLSGDwp_fPmU0ZKa-cAxM5-KdeWC5Q,4886
|
|
3
|
+
srx_lib_azure/email.py,sha256=H8KCnYFuQ2dKzpWx3BsKv9tVCV-pEmm7vXUJkOnpVh4,1719
|
|
4
|
+
srx_lib_azure/table.py,sha256=_5DCsk1SLqCc27F7469hxnRASS3XeffqK_MsJE1cD7Y,3022
|
|
5
|
+
srx_lib_azure-0.1.2.dist-info/METADATA,sha256=9ZxUBkAXwWgHejaGse890yA5i9dWpvTu1qthrfn7IaA,1600
|
|
6
|
+
srx_lib_azure-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
srx_lib_azure-0.1.2.dist-info/RECORD,,
|