Bin
2025-12-17 05a69820e0c402b0b33c063d3b922f0a0571cbbb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import fnmatch
import logging
import re
import types
 
from azure.storage.blob import BlobServiceClient
from core.utils.params import get_env
from django.conf import settings
from io_storages.utils import parse_range
 
logger = logging.getLogger(__name__)
 
 
class AZURE(object):
    @staticmethod
    def download_stream_response(blob_client, total_size, content_type, range_header, properties, max_range_size=None):
        """Prepare Azure blob streaming response with unified range handling.
 
        Shared Azure Blob streaming helper used by both OSS Azure Blob and Enterprise Azure SPI providers.
 
        Responsibilities:
        - Parse and normalize HTTP Range requests (including special probes)
        - Configure Azure SDK streaming parameters
        - Generate a downloader with a unified ``iter_chunks`` API
        - Build response metadata (Content-Range, Content-Length, ETag, Last-Modified)
 
        Args:
            blob_client: Azure Blob SDK client for the target blob.
            total_size (int): Size of the blob in bytes.
            content_type (str|None): Blob content type.
            range_header (str|None): Incoming HTTP Range header, e.g. 'bytes=0-'.
            properties: Blob properties (for ETag/Last-Modified extraction).
            max_range_size (int|None): Optional override for initial open-ended range size.
 
        Returns:
            tuple: (downloader, resolved_content_type, metadata)
        """
        resolved_content_type = content_type or 'application/octet-stream'
 
        streaming = True
        start, end = parse_range(range_header)
 
        if start is None and end is None:
            streaming = False
            start, end = 0, total_size
        elif start == 0 and end == 0:
            start, end = 0, 1
        elif start == 0 and (end == '' or end is None):
            mr = max_range_size if max_range_size is not None else settings.RESOLVER_PROXY_MAX_RANGE_SIZE
            end = start + mr
 
        if start is None:
            start = 0
 
        try:
            blob_client._config.max_single_get_size = 1024  # 1KB
        except Exception:
            pass
 
        if end is not None and end != '':
            length = end - start
        else:
            length = None
 
        if streaming:
            downloader = blob_client.download_blob(offset=start, length=length)
        else:
            length = total_size
            downloader = blob_client.download_blob()
 
        def _iter_chunks(self_downloader, chunk_size=1024 * 1024):
            try:
                self_downloader._config.max_chunk_get_size = chunk_size
            except Exception:
                pass
            total = 0
            for chunk in self_downloader.chunks():
                yield chunk
                total += len(chunk)
                if length is not None and total >= length:
                    return
 
        downloader.iter_chunks = types.MethodType(_iter_chunks, downloader)
        downloader.close = types.MethodType(lambda self: None, downloader)
 
        if streaming and length is not None:
            actual_length = min(length, max(0, total_size - start))
            content_length = actual_length
        else:
            content_length = length if length is not None else max(0, total_size - start)
 
        if length is not None:
            actual_end = min(start + length - 1, max(0, total_size - 1))
        else:
            actual_end = max(0, total_size - 1)
 
        status_code = 206 if streaming else 200
 
        metadata = {
            'ETag': getattr(properties, 'etag', ''),
            'ContentLength': content_length,
            'ContentRange': f'bytes {start}-{actual_end}/{total_size or 0}',
            'LastModified': getattr(properties, 'last_modified', None),
            'StatusCode': status_code,
        }
 
        return downloader, resolved_content_type, metadata
 
    @classmethod
    def get_client_and_container(cls, container, account_name=None, account_key=None):
        # get account name and key from params or from environment variables
        account_name = str(account_name) if account_name else get_env('AZURE_BLOB_ACCOUNT_NAME')
        account_key = str(account_key) if account_key else get_env('AZURE_BLOB_ACCOUNT_KEY')
        # check that both account name and key are set
        if not account_name or not account_key:
            raise ValueError(
                'Azure account name and key must be set using '
                'environment variables AZURE_BLOB_ACCOUNT_NAME and AZURE_BLOB_ACCOUNT_KEY'
            )
        connection_string = (
            'DefaultEndpointsProtocol=https;AccountName='
            + account_name
            + ';AccountKey='
            + account_key
            + ';EndpointSuffix=core.windows.net'
        )
        client = BlobServiceClient.from_connection_string(conn_str=connection_string)
        container = client.get_container_client(str(container))
        return client, container
 
    @classmethod
    def get_blob_metadata(cls, url: str, container: str, account_name: str = None, account_key: str = None) -> dict:
        """
        Get blob metadata by url
        :param url: Object key
        :param container: Azure container name
        :param account_name: Azure account name
        :param account_key: Azure account key
        :return: Object metadata dict("name": "value")
        """
        _, container = cls.get_client_and_container(container, account_name=account_name, account_key=account_key)
        blob = container.get_blob_client(url)
        return dict(blob.get_blob_properties())
 
    @classmethod
    def validate_pattern(cls, storage, pattern, glob_pattern=True):
        """
        Validate pattern against Azure Blob Storage
        :param storage: AzureBlobStorage instance
        :param pattern: Pattern to validate
        :param glob_pattern: If True, pattern is a glob pattern, otherwise it is a regex pattern
        :return: Message if pattern is not valid, empty string otherwise
        """
        logger.debug('Validating Azure Blob Storage pattern.')
        client, container = storage.get_client_and_container()
        if storage.prefix:
            generator = container.list_blob_names(
                name_starts_with=storage.prefix,
                results_per_page=settings.CLOUD_STORAGE_CHECK_FOR_RECORDS_PAGE_SIZE,
                timeout=settings.CLOUD_STORAGE_CHECK_FOR_RECORDS_TIMEOUT,
            )
        else:
            generator = container.list_blob_names(
                results_per_page=settings.CLOUD_STORAGE_CHECK_FOR_RECORDS_PAGE_SIZE,
                timeout=settings.CLOUD_STORAGE_CHECK_FOR_RECORDS_TIMEOUT,
            )
        # compile pattern to regex
        if glob_pattern:
            pattern = fnmatch.translate(pattern)
        regex = re.compile(str(pattern))
        # match pattern against all keys in the container
        for index, key in enumerate(generator):
            # skip directories
            if key.endswith('/'):
                logger.debug(key + ' is skipped because it is a folder')
                continue
            if regex and regex.match(key):
                logger.debug(key + ' matches file pattern')
                return ''
        return 'No objects found matching the provided glob pattern'