|
| 1 | +# ------------------------------------------------------------------------- |
| 2 | +# Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | +# Licensed under the MIT License. See License.txt in the project root for |
| 4 | +# license information. |
| 5 | +# -------------------------------------------------------------------------- |
| 6 | +from typing import Union, Dict, Any, Optional |
| 7 | +from urllib.parse import quote |
| 8 | +from uuid import UUID |
| 9 | +from datetime import datetime, timezone |
| 10 | + |
| 11 | +from ._entity import EntityProperty, EdmType, TableEntity |
| 12 | +from ._common_conversion import _decode_base64_to_bytes |
| 13 | + |
| 14 | + |
| 15 | +class TablesEntityDatetime(datetime): |
| 16 | + @property |
| 17 | + def tables_service_value(self): |
| 18 | + try: |
| 19 | + return self._service_value |
| 20 | + except AttributeError: |
| 21 | + return "" |
| 22 | + |
| 23 | + |
| 24 | +def url_quote(url): |
| 25 | + return quote(url) |
| 26 | + |
| 27 | + |
| 28 | +def get_enum_value(value): |
| 29 | + if value is None or value in ["None", ""]: |
| 30 | + return None |
| 31 | + try: |
| 32 | + return value.value |
| 33 | + except AttributeError: |
| 34 | + return value |
| 35 | + |
| 36 | + |
| 37 | +def _from_entity_binary(value: str) -> bytes: |
| 38 | + return _decode_base64_to_bytes(value) |
| 39 | + |
| 40 | + |
| 41 | +def _from_entity_int32(value: str) -> int: |
| 42 | + return int(value) |
| 43 | + |
| 44 | + |
| 45 | +def _from_entity_int64(value: str) -> EntityProperty: |
| 46 | + return EntityProperty(int(value), EdmType.INT64) |
| 47 | + |
| 48 | + |
| 49 | +def _from_entity_datetime(value): |
| 50 | + # Cosmos returns this with a decimal point that throws an error on deserialization |
| 51 | + cleaned_value = clean_up_dotnet_timestamps(value) |
| 52 | + try: |
| 53 | + dt_obj = TablesEntityDatetime.strptime(cleaned_value, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) |
| 54 | + except ValueError: |
| 55 | + dt_obj = TablesEntityDatetime.strptime(cleaned_value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) |
| 56 | + dt_obj._service_value = value # pylint:disable=protected-access,assigning-non-slot |
| 57 | + return dt_obj |
| 58 | + |
| 59 | + |
| 60 | +def clean_up_dotnet_timestamps(value): |
| 61 | + # .NET has more decimal places than Python supports in datetime objects, this truncates |
| 62 | + # values after 6 decimal places. |
| 63 | + value = value.split(".") |
| 64 | + ms = "" |
| 65 | + if len(value) == 2: |
| 66 | + ms = value[-1].replace("Z", "") |
| 67 | + if len(ms) > 6: |
| 68 | + ms = ms[:6] |
| 69 | + ms = ms + "Z" |
| 70 | + return ".".join([value[0], ms]) |
| 71 | + |
| 72 | + return value[0] |
| 73 | + |
| 74 | + |
| 75 | +def deserialize_iso(value): |
| 76 | + if not value: |
| 77 | + return value |
| 78 | + return _from_entity_datetime(value) |
| 79 | + |
| 80 | + |
| 81 | +def _from_entity_guid(value): |
| 82 | + return UUID(value) |
| 83 | + |
| 84 | + |
| 85 | +def _from_entity_str(value: Union[str, bytes]) -> str: |
| 86 | + if isinstance(value, bytes): |
| 87 | + return value.decode("utf-8") |
| 88 | + return value |
| 89 | + |
| 90 | + |
| 91 | +_EDM_TYPES = [ |
| 92 | + EdmType.BINARY, |
| 93 | + EdmType.INT64, |
| 94 | + EdmType.GUID, |
| 95 | + EdmType.DATETIME, |
| 96 | + EdmType.STRING, |
| 97 | + EdmType.INT32, |
| 98 | + EdmType.DOUBLE, |
| 99 | + EdmType.BOOLEAN, |
| 100 | +] |
| 101 | + |
| 102 | +_ENTITY_TO_PYTHON_CONVERSIONS = { |
| 103 | + EdmType.BINARY: _from_entity_binary, |
| 104 | + EdmType.INT32: _from_entity_int32, |
| 105 | + EdmType.INT64: _from_entity_int64, |
| 106 | + EdmType.DOUBLE: float, |
| 107 | + EdmType.DATETIME: _from_entity_datetime, |
| 108 | + EdmType.GUID: _from_entity_guid, |
| 109 | + EdmType.STRING: _from_entity_str, |
| 110 | +} |
| 111 | + |
| 112 | + |
| 113 | +def _convert_to_entity(entry_element): |
| 114 | + """Convert json response to entity. |
| 115 | + The entity format is: |
| 116 | + { |
| 117 | + "Address":"Mountain View", |
| 118 | + "Age":23, |
| 119 | + "AmountDue":200.23, |
| 120 | + |
| 121 | + "CustomerCode":"c9da6455-213d-42c9-9a79-3e9149a57833", |
| 122 | + "[email protected]":"Edm.DateTime", |
| 123 | + "CustomerSince":"2008-07-10T00:00:00", |
| 124 | + "IsActive":true, |
| 125 | + |
| 126 | + "NumberOfOrders":"255", |
| 127 | + "PartitionKey":"my_partition_key", |
| 128 | + "RowKey":"my_row_key" |
| 129 | + } |
| 130 | +
|
| 131 | + :param entry_element: The entity in response. |
| 132 | + :type entry_element: Mapping[str, Any] |
| 133 | + :return: An entity dict with additional metadata. |
| 134 | + :rtype: dict[str, Any] |
| 135 | + """ |
| 136 | + entity = TableEntity() |
| 137 | + |
| 138 | + properties = {} |
| 139 | + edmtypes = {} |
| 140 | + odata = {} |
| 141 | + |
| 142 | + for name, value in entry_element.items(): |
| 143 | + if name.startswith("odata."): |
| 144 | + odata[name[6:]] = value |
| 145 | + elif name.endswith("@odata.type"): |
| 146 | + edmtypes[name[:-11]] = value |
| 147 | + else: |
| 148 | + properties[name] = value |
| 149 | + |
| 150 | + # TODO: investigate whether we can entirely remove lines 160-168 |
| 151 | + # Partition key is a known property |
| 152 | + partition_key = properties.pop("PartitionKey", None) |
| 153 | + if partition_key is not None: |
| 154 | + entity["PartitionKey"] = partition_key |
| 155 | + |
| 156 | + # Row key is a known property |
| 157 | + row_key = properties.pop("RowKey", None) |
| 158 | + if row_key is not None: |
| 159 | + entity["RowKey"] = row_key |
| 160 | + |
| 161 | + # Timestamp is a known property |
| 162 | + timestamp = properties.pop("Timestamp", None) |
| 163 | + |
| 164 | + for name, value in properties.items(): |
| 165 | + mtype = edmtypes.get(name) |
| 166 | + |
| 167 | + # Add type for Int32/64 |
| 168 | + if isinstance(value, int) and mtype is None: |
| 169 | + mtype = EdmType.INT32 |
| 170 | + |
| 171 | + if value >= 2**31 or value < (-(2**31)): |
| 172 | + mtype = EdmType.INT64 |
| 173 | + |
| 174 | + # Add type for String |
| 175 | + if isinstance(value, str) and mtype is None: |
| 176 | + mtype = EdmType.STRING |
| 177 | + |
| 178 | + # no type info, property should parse automatically |
| 179 | + if not mtype: |
| 180 | + entity[name] = value |
| 181 | + elif mtype in [EdmType.STRING, EdmType.INT32]: |
| 182 | + entity[name] = value |
| 183 | + else: # need an object to hold the property |
| 184 | + convert = _ENTITY_TO_PYTHON_CONVERSIONS.get(mtype) |
| 185 | + if convert is not None: |
| 186 | + new_property = convert(value) |
| 187 | + else: |
| 188 | + new_property = EntityProperty(mtype, value) |
| 189 | + entity[name] = new_property |
| 190 | + |
| 191 | + # extract etag from entry |
| 192 | + etag = odata.pop("etag", None) |
| 193 | + odata.pop("metadata", None) |
| 194 | + if timestamp: |
| 195 | + if not etag: |
| 196 | + etag = "W/\"datetime'" + url_quote(timestamp) + "'\"" |
| 197 | + timestamp = _from_entity_datetime(timestamp) |
| 198 | + odata.update({"etag": etag, "timestamp": timestamp}) |
| 199 | + entity._metadata = odata # pylint: disable=protected-access |
| 200 | + return entity |
| 201 | + |
| 202 | + |
| 203 | +def _extract_etag(response): |
| 204 | + """Extracts the etag from the response headers. |
| 205 | +
|
| 206 | + :param response: The PipelineResponse object. |
| 207 | + :type response: ~azure.core.pipeline.PipelineResponse |
| 208 | + :return: The etag from the response headers |
| 209 | + :rtype: str or None |
| 210 | + """ |
| 211 | + if response and response.headers: |
| 212 | + return response.headers.get("etag") |
| 213 | + |
| 214 | + return None |
| 215 | + |
| 216 | + |
| 217 | +def _extract_continuation_token(continuation_token): |
| 218 | + """Extract list entity continuation headers from token. |
| 219 | +
|
| 220 | + :param Dict(str,str) continuation_token: The listing continuation token. |
| 221 | + :returns: The next partition key and next row key in a tuple |
| 222 | + :rtype: (str,str) |
| 223 | + """ |
| 224 | + if not continuation_token: |
| 225 | + return None, None |
| 226 | + try: |
| 227 | + return continuation_token.get("PartitionKey"), continuation_token.get("RowKey") |
| 228 | + except AttributeError as exc: |
| 229 | + raise ValueError("Invalid continuation token format.") from exc |
| 230 | + |
| 231 | + |
| 232 | +def _normalize_headers(headers): |
| 233 | + normalized = {} |
| 234 | + for key, value in headers.items(): |
| 235 | + if key.startswith("x-ms-"): |
| 236 | + key = key[5:] |
| 237 | + normalized[key.lower().replace("-", "_")] = get_enum_value(value) |
| 238 | + return normalized |
| 239 | + |
| 240 | + |
| 241 | +def _return_headers_and_deserialized(_, deserialized, response_headers): |
| 242 | + return _normalize_headers(response_headers), deserialized |
| 243 | + |
| 244 | + |
| 245 | +def _return_context_and_deserialized(response, deserialized, response_headers): |
| 246 | + return response.context["location_mode"], deserialized, response_headers |
| 247 | + |
| 248 | + |
| 249 | +def _trim_service_metadata(metadata: Dict[str, str], content: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: |
| 250 | + result: Dict[str, Any] = { |
| 251 | + "date": metadata.pop("date", None), |
| 252 | + "etag": metadata.pop("etag", None), |
| 253 | + "version": metadata.pop("version", None), |
| 254 | + } |
| 255 | + preference = metadata.pop("preference_applied", None) |
| 256 | + if preference: |
| 257 | + result["preference_applied"] = preference |
| 258 | + result["content"] = content |
| 259 | + return result |
0 commit comments