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.
- odatapy/__init__.py +29 -0
- odatapy/client/__init__.py +26 -0
- odatapy/client/client.py +295 -0
- odatapy/client/filters.py +118 -0
- odatapy/client/responses.py +99 -0
- odatapy/client/serialization.py +210 -0
- odatapy/client/transport.py +109 -0
- odatapy/client/uri_builder.py +214 -0
- odatapy/data/__init__.py +301 -0
- odatapy/edm/__init__.py +75 -0
- odatapy/edm/csdl.py +294 -0
- odatapy/edm/geo.py +343 -0
- odatapy/edm/json_value.py +129 -0
- odatapy/edm/model.py +513 -0
- odatapy/edm/names.py +27 -0
- odatapy/edm/primitives.py +967 -0
- odatapy/edm/xml_io.py +303 -0
- odatapy/errors.py +122 -0
- odatapy/ext/__init__.py +5 -0
- odatapy/ext/sql.py +133 -0
- odatapy/http/__init__.py +22 -0
- odatapy/http/content_type.py +252 -0
- odatapy/http/header.py +27 -0
- odatapy/http/method.py +18 -0
- odatapy/http/prefer.py +82 -0
- odatapy/http/status.py +54 -0
- odatapy/py.typed +0 -0
- odatapy/server/__init__.py +20 -0
- odatapy/server/adapters/__init__.py +11 -0
- odatapy/server/adapters/asgi.py +86 -0
- odatapy/server/adapters/common.py +90 -0
- odatapy/server/adapters/wsgi.py +82 -0
- odatapy/server/apply.py +177 -0
- odatapy/server/batch.py +261 -0
- odatapy/server/default_processor.py +77 -0
- odatapy/server/deserializer/__init__.py +5 -0
- odatapy/server/deserializer/json_deserializer.py +179 -0
- odatapy/server/deserializer/xml_deserializer.py +81 -0
- odatapy/server/dispatcher.py +270 -0
- odatapy/server/eval.py +221 -0
- odatapy/server/handler.py +164 -0
- odatapy/server/inmemory.py +767 -0
- odatapy/server/negotiator.py +81 -0
- odatapy/server/odata.py +60 -0
- odatapy/server/processor.py +239 -0
- odatapy/server/request.py +116 -0
- odatapy/server/serializer/__init__.py +24 -0
- odatapy/server/serializer/factory.py +23 -0
- odatapy/server/serializer/json_serializer.py +304 -0
- odatapy/server/serializer/metadata.py +339 -0
- odatapy/server/serializer/xml_serializer.py +215 -0
- odatapy/server/uri/__init__.py +81 -0
- odatapy/server/uri/expression.py +214 -0
- odatapy/server/uri/expression_parser.py +279 -0
- odatapy/server/uri/parser.py +420 -0
- odatapy/server/uri/query_options.py +112 -0
- odatapy/server/uri/resource.py +177 -0
- odatapy/server/uri/validator.py +77 -0
- odatapy-5.0.0a1.dist-info/METADATA +240 -0
- odatapy-5.0.0a1.dist-info/RECORD +63 -0
- odatapy-5.0.0a1.dist-info/WHEEL +4 -0
- odatapy-5.0.0a1.dist-info/licenses/LICENSE +202 -0
- 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
|
+
]
|
odatapy/client/client.py
ADDED
|
@@ -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`)."""
|