odatapy 5.0.0a1__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.
Files changed (63) hide show
  1. odatapy/__init__.py +29 -0
  2. odatapy/client/__init__.py +26 -0
  3. odatapy/client/client.py +295 -0
  4. odatapy/client/filters.py +118 -0
  5. odatapy/client/responses.py +99 -0
  6. odatapy/client/serialization.py +210 -0
  7. odatapy/client/transport.py +109 -0
  8. odatapy/client/uri_builder.py +214 -0
  9. odatapy/data/__init__.py +301 -0
  10. odatapy/edm/__init__.py +75 -0
  11. odatapy/edm/csdl.py +294 -0
  12. odatapy/edm/geo.py +343 -0
  13. odatapy/edm/json_value.py +129 -0
  14. odatapy/edm/model.py +513 -0
  15. odatapy/edm/names.py +27 -0
  16. odatapy/edm/primitives.py +967 -0
  17. odatapy/edm/xml_io.py +303 -0
  18. odatapy/errors.py +122 -0
  19. odatapy/ext/__init__.py +5 -0
  20. odatapy/ext/sql.py +133 -0
  21. odatapy/http/__init__.py +22 -0
  22. odatapy/http/content_type.py +252 -0
  23. odatapy/http/header.py +27 -0
  24. odatapy/http/method.py +18 -0
  25. odatapy/http/prefer.py +82 -0
  26. odatapy/http/status.py +54 -0
  27. odatapy/py.typed +0 -0
  28. odatapy/server/__init__.py +20 -0
  29. odatapy/server/adapters/__init__.py +11 -0
  30. odatapy/server/adapters/asgi.py +86 -0
  31. odatapy/server/adapters/common.py +90 -0
  32. odatapy/server/adapters/wsgi.py +82 -0
  33. odatapy/server/apply.py +177 -0
  34. odatapy/server/batch.py +261 -0
  35. odatapy/server/default_processor.py +77 -0
  36. odatapy/server/deserializer/__init__.py +5 -0
  37. odatapy/server/deserializer/json_deserializer.py +179 -0
  38. odatapy/server/deserializer/xml_deserializer.py +81 -0
  39. odatapy/server/dispatcher.py +270 -0
  40. odatapy/server/eval.py +221 -0
  41. odatapy/server/handler.py +164 -0
  42. odatapy/server/inmemory.py +767 -0
  43. odatapy/server/negotiator.py +81 -0
  44. odatapy/server/odata.py +60 -0
  45. odatapy/server/processor.py +239 -0
  46. odatapy/server/request.py +116 -0
  47. odatapy/server/serializer/__init__.py +24 -0
  48. odatapy/server/serializer/factory.py +23 -0
  49. odatapy/server/serializer/json_serializer.py +304 -0
  50. odatapy/server/serializer/metadata.py +339 -0
  51. odatapy/server/serializer/xml_serializer.py +215 -0
  52. odatapy/server/uri/__init__.py +81 -0
  53. odatapy/server/uri/expression.py +214 -0
  54. odatapy/server/uri/expression_parser.py +279 -0
  55. odatapy/server/uri/parser.py +420 -0
  56. odatapy/server/uri/query_options.py +112 -0
  57. odatapy/server/uri/resource.py +177 -0
  58. odatapy/server/uri/validator.py +77 -0
  59. odatapy-5.0.0a1.dist-info/METADATA +240 -0
  60. odatapy-5.0.0a1.dist-info/RECORD +63 -0
  61. odatapy-5.0.0a1.dist-info/WHEEL +4 -0
  62. odatapy-5.0.0a1.dist-info/licenses/LICENSE +202 -0
  63. odatapy-5.0.0a1.dist-info/licenses/NOTICE +7 -0
odatapy/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """odatapy — an OData v4.0 / v4.01 server and client library for Python.
2
+
3
+ A port of Apache Olingo OData 5.0.0 (Java) to idiomatic Python.
4
+
5
+ Quick start (server):
6
+
7
+ from odatapy.server import OData
8
+ from odatapy.server.inmemory import InMemoryDataStore, InMemoryProcessor
9
+
10
+ odata = OData.new_instance()
11
+ service_metadata = odata.create_service_metadata(MyProvider())
12
+ handler = odata.create_handler(service_metadata)
13
+ handler.register(InMemoryProcessor(store))
14
+ # wrap with odatapy.server.adapters.ODataASGIApp / ODataWSGIApp
15
+
16
+ Quick start (client):
17
+
18
+ from odatapy.client import ODataClient
19
+ from odatapy.client.transport import HttpxTransport
20
+
21
+ client = ODataClient("http://host/svc", HttpxTransport())
22
+ products = client.get_entity_set(client.entity_set_uri("Products").filter("Price gt 10"))
23
+
24
+ See PYTHON_ODATA_PORT_PLAN.md for the full roadmap.
25
+ """
26
+
27
+ __version__ = "5.0.0a1"
28
+
29
+ __all__ = ["__version__"]
@@ -0,0 +1,26 @@
1
+ """OData client.
2
+
3
+ Build request URIs with :class:`URIBuilder`, execute them through a pluggable
4
+ :class:`~odatapy.client.transport.Transport` (httpx by default, or an in-process
5
+ handler), and get back payload objects. :class:`ODataClient` is the entry point.
6
+ """
7
+
8
+ from odatapy.client.client import EdmEnabledODataClient, ODataClient
9
+ from odatapy.client.filters import FilterFactory, and_, not_, or_
10
+ from odatapy.client.responses import ClientResponse, ODataClientError
11
+ from odatapy.client.transport import HandlerTransport, Transport
12
+ from odatapy.client.uri_builder import URIBuilder
13
+
14
+ __all__ = [
15
+ "ODataClient",
16
+ "EdmEnabledODataClient",
17
+ "URIBuilder",
18
+ "FilterFactory",
19
+ "and_",
20
+ "or_",
21
+ "not_",
22
+ "Transport",
23
+ "HandlerTransport",
24
+ "ClientResponse",
25
+ "ODataClientError",
26
+ ]
@@ -0,0 +1,295 @@
1
+ """The OData client (Olingo ODataClient / EdmEnabledODataClient)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from odatapy.client import serialization
8
+ from odatapy.client.responses import ClientResponse, ODataClientError
9
+ from odatapy.client.transport import Transport
10
+ from odatapy.client.uri_builder import URIBuilder
11
+ from odatapy.data import Entity, EntityCollection
12
+
13
+ _JSON = "application/json"
14
+ _DEFAULT_HEADERS = {
15
+ "Accept": _JSON,
16
+ "OData-Version": "4.01",
17
+ "OData-MaxVersion": "4.01",
18
+ }
19
+
20
+
21
+ class ODataClient:
22
+ """A blocking OData v4 client.
23
+
24
+ ``transport`` decides how requests are sent (httpx over the network, or an
25
+ in-process :class:`~odatapy.client.transport.HandlerTransport`).
26
+ """
27
+
28
+ def __init__(self, service_root: str, transport: Transport) -> None:
29
+ self.service_root = service_root.rstrip("/")
30
+ self.transport = transport
31
+
32
+ # --- URI building ----------------------------------------------------
33
+ def uri(self) -> URIBuilder:
34
+ return URIBuilder(self.service_root)
35
+
36
+ def entity_set_uri(self, name: str) -> URIBuilder:
37
+ return self.uri().append_entity_set_segment(name)
38
+
39
+ def entity_uri(self, name: str, key: Any) -> URIBuilder:
40
+ return self.uri().append_entity_set_segment(name).append_key_segment(key)
41
+
42
+ # --- retrieve --------------------------------------------------------
43
+ def get_entity_set(
44
+ self, uri: str | URIBuilder, accept: str | None = None
45
+ ) -> EntityCollection:
46
+ response = self._request("GET", _url(uri), accept=accept)
47
+ _raise_for_status(response)
48
+ return serialization.read_entity_collection(response.content)
49
+
50
+ def get_entity(self, uri: str | URIBuilder, accept: str | None = None) -> Entity:
51
+ response = self._request("GET", _url(uri), accept=accept)
52
+ _raise_for_status(response)
53
+ entity = serialization.read_entity(response.content)
54
+ if not entity.etag:
55
+ entity.etag = response.get_header("ETag")
56
+ return entity
57
+
58
+ def get_property_value(self, uri: str | URIBuilder) -> Any:
59
+ """Read a primitive/complex property.
60
+
61
+ Handles both the JSON ``{"value": ...}`` envelope and raw ``.../$value``
62
+ responses (returned as text, or ``None`` for 204 No Content).
63
+ """
64
+ response = self._request("GET", _url(uri))
65
+ _raise_for_status(response)
66
+ if response.status_code == 204 or not response.content:
67
+ return None
68
+ content_type = (response.get_header("Content-Type") or "").lower()
69
+ if "json" in content_type or "xml" in content_type:
70
+ return serialization.read_property(response.content)
71
+ if content_type.startswith("text/") or _url(uri).rstrip("/").endswith("$value"):
72
+ return response.text()
73
+ return serialization.read_property(response.content)
74
+
75
+ def get_service_document(self) -> dict[str, Any]:
76
+ response = self._request("GET", self.service_root + "/")
77
+ _raise_for_status(response)
78
+ return serialization.read_json(response.content)
79
+
80
+ def get_count(self, entity_set_uri: str | URIBuilder) -> int:
81
+ """``GET .../$count`` as an integer."""
82
+ url = _url(entity_set_uri).rstrip("/")
83
+ if not url.endswith("$count"):
84
+ url += "/$count"
85
+ response = self._request("GET", url, accept="text/plain")
86
+ _raise_for_status(response)
87
+ return int(response.text().strip())
88
+
89
+ def get_raw(self, uri: str | URIBuilder) -> ClientResponse:
90
+ return self._request("GET", _url(uri))
91
+
92
+ def get_metadata_document(self) -> str:
93
+ response = self._request(
94
+ "GET", self.service_root + "/$metadata", accept="application/xml"
95
+ )
96
+ _raise_for_status(response)
97
+ return response.text()
98
+
99
+ # --- create / update / delete ---------------------------------------
100
+ def create_entity(self, entity_set: str, entity: Entity | dict[str, Any]) -> Entity:
101
+ url = self.entity_set_uri(entity_set).build()
102
+ body = serialization.write_entity(entity)
103
+ response = self._request("POST", url, body=body, content_type=_JSON)
104
+ _raise_for_status(response, expected=(200, 201, 204))
105
+ if response.content:
106
+ return serialization.read_entity(response.content)
107
+ result = entity if isinstance(entity, Entity) else _dict_to_entity(entity)
108
+ location = response.get_header("Location")
109
+ if location:
110
+ result.id = location
111
+ return result
112
+
113
+ def update_entity(
114
+ self,
115
+ uri: str | URIBuilder,
116
+ entity: Entity | dict[str, Any],
117
+ method: str = "PATCH",
118
+ etag: str | None = None,
119
+ ) -> ClientResponse:
120
+ # Olingo's UpdateType.REPLACE is a PUT on the wire.
121
+ http_method = method.upper()
122
+ if http_method == "REPLACE":
123
+ http_method = "PUT"
124
+ if http_method not in ("PATCH", "PUT"):
125
+ raise ValueError("update_entity method must be 'PATCH', 'PUT' or 'REPLACE'.")
126
+ body = serialization.write_entity(entity)
127
+ headers = {}
128
+ if etag:
129
+ headers["If-Match"] = etag
130
+ response = self._request(
131
+ http_method, _url(uri), body=body, content_type=_JSON, extra=headers
132
+ )
133
+ _raise_for_status(response, expected=(200, 204))
134
+ return response
135
+
136
+ def update_property(
137
+ self,
138
+ uri: str | URIBuilder,
139
+ value: Any,
140
+ etag: str | None = None,
141
+ ) -> ClientResponse:
142
+ """PUT a single primitive/complex property (``{"value": ...}`` payload)."""
143
+ body = serialization.write_property(value)
144
+ headers = {"If-Match": etag} if etag else {}
145
+ response = self._request("PUT", _url(uri), body=body, content_type=_JSON, extra=headers)
146
+ _raise_for_status(response, expected=(200, 204))
147
+ return response
148
+
149
+ def delete_entity(self, uri: str | URIBuilder, etag: str | None = None) -> ClientResponse:
150
+ headers = {"If-Match": etag} if etag else {}
151
+ response = self._request("DELETE", _url(uri), extra=headers)
152
+ _raise_for_status(response, expected=(200, 204))
153
+ return response
154
+
155
+ # --- async protocol ---------------------------------------------------
156
+ def execute_async(
157
+ self,
158
+ method: str,
159
+ uri: str | URIBuilder,
160
+ body: bytes | None = None,
161
+ content_type: str | None = None,
162
+ ) -> "AsyncOperation":
163
+ """Send a request with ``Prefer: respond-async``.
164
+
165
+ Returns an :class:`AsyncOperation`; if the server answered 202 the
166
+ operation polls the status-monitor URL, otherwise the final response is
167
+ available immediately.
168
+ """
169
+ response = self._request(
170
+ method, _url(uri), body=body, content_type=content_type,
171
+ extra={"Prefer": "respond-async"},
172
+ )
173
+ operation = AsyncOperation(self)
174
+ if response.status_code == 202 and response.get_header("Location"):
175
+ operation.monitor_url = response.get_header("Location")
176
+ else:
177
+ operation._result = response
178
+ return operation
179
+
180
+ # --- operations ------------------------------------------------------
181
+ def invoke_function(self, uri: str | URIBuilder) -> ClientResponse:
182
+ return self._request("GET", _url(uri))
183
+
184
+ def invoke_action(
185
+ self, uri: str | URIBuilder, parameters: dict[str, Any] | None = None
186
+ ) -> ClientResponse:
187
+ body = serialization.write_parameters(parameters or {})
188
+ return self._request("POST", _url(uri), body=body, content_type=_JSON)
189
+
190
+ # --- internals -------------------------------------------------------
191
+ def _request(
192
+ self,
193
+ method: str,
194
+ url: str,
195
+ body: bytes | None = None,
196
+ content_type: str | None = None,
197
+ accept: str | None = None,
198
+ extra: dict[str, str] | None = None,
199
+ ) -> ClientResponse:
200
+ headers = dict(_DEFAULT_HEADERS)
201
+ if accept:
202
+ headers["Accept"] = accept
203
+ if content_type:
204
+ headers["Content-Type"] = content_type
205
+ if extra:
206
+ headers.update(extra)
207
+ return self.transport.send(method, url, headers, body or b"")
208
+
209
+
210
+ class EdmEnabledODataClient(ODataClient):
211
+ """A client that fetches and caches the service ``$metadata`` as an :class:`Edm`."""
212
+
213
+ def __init__(self, service_root: str, transport: Transport) -> None:
214
+ super().__init__(service_root, transport)
215
+ self._edm = None
216
+ self._metadata_etag: str | None = None
217
+
218
+ def get_edm(self, refresh: bool = False):
219
+ from odatapy.edm.model import Edm
220
+ from odatapy.edm.xml_io import XmlEdmProvider
221
+
222
+ if self._edm is not None and not refresh:
223
+ return self._edm
224
+ response = self._request(
225
+ "GET", self.service_root + "/$metadata", accept="application/xml"
226
+ )
227
+ _raise_for_status(response)
228
+ self._metadata_etag = response.get_header("ETag")
229
+ self._edm = Edm.from_provider(XmlEdmProvider(response.content))
230
+ return self._edm
231
+
232
+ @property
233
+ def cached_edm(self):
234
+ return self._edm
235
+
236
+
237
+ class AsyncOperation:
238
+ """Tracks an OData async request (202 Accepted + status monitor)."""
239
+
240
+ def __init__(self, client: "ODataClient") -> None:
241
+ self._client = client
242
+ self.monitor_url: str | None = None
243
+ self._result: ClientResponse | None = None
244
+
245
+ @property
246
+ def is_done(self) -> bool:
247
+ return self._result is not None
248
+
249
+ def poll(self) -> ClientResponse | None:
250
+ """One poll of the monitor URL; returns the final response or None if pending."""
251
+ if self._result is not None:
252
+ return self._result
253
+ if not self.monitor_url:
254
+ return None
255
+ response = self._client._request("GET", self.monitor_url)
256
+ if response.status_code == 202:
257
+ return None
258
+ self._result = response
259
+ return response
260
+
261
+ def result(self, retries: int = 60, interval: float = 0.5) -> ClientResponse:
262
+ """Poll until the result is available."""
263
+ import time
264
+
265
+ for _ in range(retries):
266
+ response = self.poll()
267
+ if response is not None:
268
+ return response
269
+ time.sleep(interval)
270
+ raise TimeoutError("The asynchronous operation did not complete in time.")
271
+
272
+ def cancel(self) -> None:
273
+ if self.monitor_url and self._result is None:
274
+ self._client._request("DELETE", self.monitor_url)
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ def _url(uri: str | URIBuilder) -> str:
279
+ return uri.build() if isinstance(uri, URIBuilder) else uri
280
+
281
+
282
+ def _raise_for_status(response: ClientResponse, expected: tuple[int, ...] | None = None) -> None:
283
+ if expected is not None:
284
+ if response.status_code not in expected:
285
+ raise ODataClientError(response)
286
+ return
287
+ if not response.is_success:
288
+ raise ODataClientError(response)
289
+
290
+
291
+ def _dict_to_entity(data: dict[str, Any]) -> Entity:
292
+ entity = Entity()
293
+ for key, value in data.items():
294
+ entity.set_property(key, value)
295
+ return entity
@@ -0,0 +1,118 @@
1
+ """Programmatic ``$filter`` builder (Olingo FilterFactory).
2
+
3
+ A small fluent DSL that produces OData filter strings::
4
+
5
+ from odatapy.client.filters import Field, and_
6
+ and_(Field("Price").gt(10), Field("Name").startswith("A"))
7
+ # -> "(Price gt 10) and (startswith(Name,'A'))"
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as _dt
13
+ import uuid
14
+ from decimal import Decimal
15
+ from typing import Any
16
+
17
+
18
+ def literal(value: Any) -> str:
19
+ """Render a Python value as an OData literal."""
20
+ if value is None:
21
+ return "null"
22
+ if isinstance(value, bool):
23
+ return "true" if value else "false"
24
+ if isinstance(value, (int, float, Decimal)):
25
+ return str(value)
26
+ if isinstance(value, uuid.UUID):
27
+ return str(value)
28
+ if isinstance(value, _dt.datetime):
29
+ return value.isoformat()
30
+ if isinstance(value, _dt.date):
31
+ return value.isoformat()
32
+ if isinstance(value, _dt.time):
33
+ return value.isoformat()
34
+ if isinstance(value, Filter):
35
+ return value.text
36
+ text = str(value).replace("'", "''")
37
+ return f"'{text}'"
38
+
39
+
40
+ class Filter:
41
+ """A rendered filter fragment."""
42
+
43
+ def __init__(self, text: str) -> None:
44
+ self.text = text
45
+
46
+ def __str__(self) -> str:
47
+ return self.text
48
+
49
+ def __and__(self, other: "Filter") -> "Filter":
50
+ return and_(self, other)
51
+
52
+ def __or__(self, other: "Filter") -> "Filter":
53
+ return or_(self, other)
54
+
55
+ def __invert__(self) -> "Filter":
56
+ return not_(self)
57
+
58
+
59
+ class Field:
60
+ """A property reference in a filter expression."""
61
+
62
+ def __init__(self, path: str) -> None:
63
+ self.path = path
64
+
65
+ def _binary(self, op: str, value: Any) -> Filter:
66
+ return Filter(f"{self.path} {op} {literal(value)}")
67
+
68
+ def eq(self, value: Any) -> Filter:
69
+ return self._binary("eq", value)
70
+
71
+ def ne(self, value: Any) -> Filter:
72
+ return self._binary("ne", value)
73
+
74
+ def gt(self, value: Any) -> Filter:
75
+ return self._binary("gt", value)
76
+
77
+ def ge(self, value: Any) -> Filter:
78
+ return self._binary("ge", value)
79
+
80
+ def lt(self, value: Any) -> Filter:
81
+ return self._binary("lt", value)
82
+
83
+ def le(self, value: Any) -> Filter:
84
+ return self._binary("le", value)
85
+
86
+ def contains(self, value: Any) -> Filter:
87
+ return Filter(f"contains({self.path},{literal(value)})")
88
+
89
+ def startswith(self, value: Any) -> Filter:
90
+ return Filter(f"startswith({self.path},{literal(value)})")
91
+
92
+ def endswith(self, value: Any) -> Filter:
93
+ return Filter(f"endswith({self.path},{literal(value)})")
94
+
95
+
96
+ def and_(*filters: Filter) -> Filter:
97
+ return Filter(" and ".join(f"({f.text})" for f in filters))
98
+
99
+
100
+ def or_(*filters: Filter) -> Filter:
101
+ return Filter(" or ".join(f"({f.text})" for f in filters))
102
+
103
+
104
+ def not_(inner: Filter) -> Filter:
105
+ return Filter(f"not ({inner.text})")
106
+
107
+
108
+ class FilterFactory:
109
+ """Convenience factory mirroring Olingo's FilterFactory."""
110
+
111
+ @staticmethod
112
+ def field(path: str) -> Field:
113
+ return Field(path)
114
+
115
+ and_ = staticmethod(and_)
116
+ or_ = staticmethod(or_)
117
+ not_ = staticmethod(not_)
118
+ literal = staticmethod(literal)
@@ -0,0 +1,99 @@
1
+ """Client response wrappers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class ClientResponse:
12
+ """A raw HTTP response returned by a :class:`Transport`."""
13
+
14
+ status_code: int
15
+ headers: dict[str, str] = field(default_factory=dict)
16
+ content: bytes = b""
17
+
18
+ def get_header(self, name: str) -> str | None:
19
+ for key, value in self.headers.items():
20
+ if key.lower() == name.lower():
21
+ return value
22
+ return None
23
+
24
+ def text(self, encoding: str = "utf-8") -> str:
25
+ return self.content.decode(encoding)
26
+
27
+ @property
28
+ def is_success(self) -> bool:
29
+ return 200 <= self.status_code < 300
30
+
31
+
32
+ class ODataClientError(Exception):
33
+ """Raised when the server returns a non-2xx response.
34
+
35
+ ``error_code`` / ``error_message`` / ``error_target`` / ``error_details`` are
36
+ populated from an OData JSON or XML error body when one is present.
37
+ """
38
+
39
+ def __init__(self, response: ClientResponse, message: str | None = None) -> None:
40
+ self.response = response
41
+ self.status_code = response.status_code
42
+ self.error_code: str | None = None
43
+ self.error_target: str | None = None
44
+ self.error_details: list[dict[str, Any]] = []
45
+ parsed = self._extract(response)
46
+ self.error_message = message or parsed
47
+ super().__init__(f"HTTP {response.status_code}: {self.error_message}")
48
+
49
+ def _extract(self, response: ClientResponse) -> str:
50
+ content = response.content or b""
51
+ if not content:
52
+ return ""
53
+ try:
54
+ text = content.decode("utf-8")
55
+ except UnicodeDecodeError:
56
+ return content.decode("utf-8", "replace")
57
+ stripped = text.lstrip()
58
+ if stripped.startswith("<"):
59
+ return self._extract_xml(stripped) or text
60
+ try:
61
+ body = json.loads(text)
62
+ except ValueError:
63
+ return text
64
+ error = body.get("error") if isinstance(body, dict) else None
65
+ if not isinstance(error, dict):
66
+ return text
67
+ code = error.get("code")
68
+ self.error_code = str(code) if code is not None else None
69
+ target = error.get("target")
70
+ self.error_target = str(target) if target is not None else None
71
+ details = error.get("details")
72
+ if isinstance(details, list):
73
+ self.error_details = [d for d in details if isinstance(d, dict)]
74
+ msg = error.get("message")
75
+ if isinstance(msg, dict): # OData 4.0 "message": {"lang": ..., "value": ...}
76
+ msg = msg.get("value")
77
+ return str(msg) if msg is not None else text
78
+
79
+ def _extract_xml(self, text: str) -> str | None:
80
+ from xml.etree import ElementTree as ET
81
+
82
+ try:
83
+ root = ET.fromstring(text)
84
+ except ET.ParseError:
85
+ return None
86
+ message = None
87
+ for el in root.iter():
88
+ local = el.tag.rsplit("}", 1)[-1]
89
+ if local == "code" and self.error_code is None:
90
+ self.error_code = (el.text or "").strip() or None
91
+ elif local == "message" and message is None:
92
+ message = (el.text or "").strip()
93
+ elif local == "target" and self.error_target is None:
94
+ self.error_target = (el.text or "").strip() or None
95
+ return message
96
+
97
+
98
+ class ODataServerError(ODataClientError):
99
+ """Raised for 5xx responses (subclass of :class:`ODataClientError`)."""