odatapy 5.0.0__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 (71) hide show
  1. odatapy/__init__.py +30 -0
  2. odatapy/client/__init__.py +132 -0
  3. odatapy/client/auth.py +159 -0
  4. odatapy/client/batch.py +628 -0
  5. odatapy/client/client.py +1829 -0
  6. odatapy/client/filters.py +972 -0
  7. odatapy/client/responses.py +304 -0
  8. odatapy/client/serialization.py +1257 -0
  9. odatapy/client/transport.py +505 -0
  10. odatapy/client/uri_builder.py +328 -0
  11. odatapy/data/__init__.py +426 -0
  12. odatapy/edm/__init__.py +91 -0
  13. odatapy/edm/annotations.py +742 -0
  14. odatapy/edm/csdl.py +430 -0
  15. odatapy/edm/geo.py +385 -0
  16. odatapy/edm/json_csdl.py +462 -0
  17. odatapy/edm/json_value.py +377 -0
  18. odatapy/edm/model.py +1276 -0
  19. odatapy/edm/names.py +27 -0
  20. odatapy/edm/primitives.py +1247 -0
  21. odatapy/edm/xml_io.py +529 -0
  22. odatapy/errors.py +196 -0
  23. odatapy/ext/__init__.py +26 -0
  24. odatapy/ext/sql.py +726 -0
  25. odatapy/ext/sqlalchemy.py +298 -0
  26. odatapy/http/__init__.py +23 -0
  27. odatapy/http/content_type.py +309 -0
  28. odatapy/http/header.py +41 -0
  29. odatapy/http/method.py +18 -0
  30. odatapy/http/prefer.py +292 -0
  31. odatapy/http/status.py +84 -0
  32. odatapy/py.typed +0 -0
  33. odatapy/server/__init__.py +20 -0
  34. odatapy/server/adapters/__init__.py +11 -0
  35. odatapy/server/adapters/asgi.py +147 -0
  36. odatapy/server/adapters/common.py +212 -0
  37. odatapy/server/adapters/wsgi.py +187 -0
  38. odatapy/server/apply.py +740 -0
  39. odatapy/server/batch.py +818 -0
  40. odatapy/server/default_processor.py +131 -0
  41. odatapy/server/deserializer/__init__.py +6 -0
  42. odatapy/server/deserializer/json_deserializer.py +648 -0
  43. odatapy/server/deserializer/xml_deserializer.py +591 -0
  44. odatapy/server/dispatcher.py +734 -0
  45. odatapy/server/eval.py +994 -0
  46. odatapy/server/handler.py +658 -0
  47. odatapy/server/inmemory.py +3164 -0
  48. odatapy/server/negotiator.py +280 -0
  49. odatapy/server/odata.py +77 -0
  50. odatapy/server/processor.py +492 -0
  51. odatapy/server/request.py +220 -0
  52. odatapy/server/serializer/__init__.py +24 -0
  53. odatapy/server/serializer/factory.py +45 -0
  54. odatapy/server/serializer/json_serializer.py +1222 -0
  55. odatapy/server/serializer/metadata.py +713 -0
  56. odatapy/server/serializer/xml_serializer.py +859 -0
  57. odatapy/server/support.py +319 -0
  58. odatapy/server/uri/__init__.py +106 -0
  59. odatapy/server/uri/apply_parser.py +552 -0
  60. odatapy/server/uri/expression.py +440 -0
  61. odatapy/server/uri/expression_parser.py +1302 -0
  62. odatapy/server/uri/parser.py +1525 -0
  63. odatapy/server/uri/query_options.py +351 -0
  64. odatapy/server/uri/resource.py +317 -0
  65. odatapy/server/uri/search_parser.py +162 -0
  66. odatapy/server/uri/validator.py +387 -0
  67. odatapy-5.0.0.dist-info/METADATA +313 -0
  68. odatapy-5.0.0.dist-info/RECORD +71 -0
  69. odatapy-5.0.0.dist-info/WHEEL +4 -0
  70. odatapy-5.0.0.dist-info/licenses/LICENSE +202 -0
  71. odatapy-5.0.0.dist-info/licenses/NOTICE +7 -0
odatapy/__init__.py ADDED
@@ -0,0 +1,30 @@
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 docs/OLINGO_COMPARISON.md for what is implemented, what is deliberately not
25
+ ported from Apache Olingo, and the known limitations.
26
+ """
27
+
28
+ __version__ = "5.0.0"
29
+
30
+ __all__ = ["__version__"]
@@ -0,0 +1,132 @@
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
+ and :class:`AsyncODataClient` its asyncio twin; the EDM-enabled variants read
7
+ ``$metadata`` so payloads come back as canonical Python values.
8
+ """
9
+
10
+ from odatapy.client.auth import (
11
+ Auth,
12
+ BasicAuth,
13
+ BearerAuth,
14
+ NoAuth,
15
+ OAuth2ClientCredentialsAuth,
16
+ )
17
+ from odatapy.client.batch import (
18
+ BatchItem,
19
+ BatchPartResponse,
20
+ BatchRequest,
21
+ BatchResponse,
22
+ ChangeSetBuilder,
23
+ )
24
+ from odatapy.client.client import (
25
+ AsyncEdmEnabledODataClient,
26
+ AsyncODataClient,
27
+ AsyncOperation,
28
+ AwaitableOperation,
29
+ EdmEnabledODataClient,
30
+ ODataClient,
31
+ RequestSpec,
32
+ build_prefer,
33
+ )
34
+ from odatapy.client.filters import (
35
+ Field,
36
+ Filter,
37
+ FilterArgFactory,
38
+ FilterFactory,
39
+ OrderBy,
40
+ Search,
41
+ SearchFactory,
42
+ and_,
43
+ it,
44
+ literal,
45
+ not_,
46
+ or_,
47
+ root,
48
+ )
49
+ from odatapy.client.responses import (
50
+ ClientResponse,
51
+ MediaResponse,
52
+ NotModified,
53
+ ODataClientError,
54
+ ODataInvokeResult,
55
+ ODataServerError,
56
+ ODataUpdateResult,
57
+ RawValue,
58
+ StreamedResponse,
59
+ )
60
+ from odatapy.client.serialization import ServiceDocument, ServiceDocumentEntry
61
+ from odatapy.client.transport import (
62
+ AsyncHandlerTransport,
63
+ AsyncHttpxTransport,
64
+ AsyncPolicyTransport,
65
+ AsyncTransport,
66
+ HandlerTransport,
67
+ HttpxTransport,
68
+ PolicyTransport,
69
+ RetryPolicy,
70
+ Transport,
71
+ )
72
+ from odatapy.client.uri_builder import URIBuilder
73
+
74
+ __all__ = [
75
+ # clients
76
+ "ODataClient",
77
+ "AsyncODataClient",
78
+ "EdmEnabledODataClient",
79
+ "AsyncEdmEnabledODataClient",
80
+ "AsyncOperation",
81
+ "AwaitableOperation",
82
+ "RequestSpec",
83
+ "build_prefer",
84
+ # URIs and expressions
85
+ "URIBuilder",
86
+ "Field",
87
+ "Filter",
88
+ "FilterFactory",
89
+ "FilterArgFactory",
90
+ "OrderBy",
91
+ "Search",
92
+ "SearchFactory",
93
+ "and_",
94
+ "or_",
95
+ "not_",
96
+ "it",
97
+ "root",
98
+ "literal",
99
+ # transports and auth
100
+ "Transport",
101
+ "AsyncTransport",
102
+ "HandlerTransport",
103
+ "AsyncHandlerTransport",
104
+ "HttpxTransport",
105
+ "AsyncHttpxTransport",
106
+ "PolicyTransport",
107
+ "AsyncPolicyTransport",
108
+ "RetryPolicy",
109
+ "Auth",
110
+ "NoAuth",
111
+ "BasicAuth",
112
+ "BearerAuth",
113
+ "OAuth2ClientCredentialsAuth",
114
+ # batch
115
+ "BatchRequest",
116
+ "BatchResponse",
117
+ "BatchItem",
118
+ "BatchPartResponse",
119
+ "ChangeSetBuilder",
120
+ # responses
121
+ "ClientResponse",
122
+ "MediaResponse",
123
+ "NotModified",
124
+ "RawValue",
125
+ "StreamedResponse",
126
+ "ODataInvokeResult",
127
+ "ODataUpdateResult",
128
+ "ODataClientError",
129
+ "ODataServerError",
130
+ "ServiceDocument",
131
+ "ServiceDocumentEntry",
132
+ ]
odatapy/client/auth.py ADDED
@@ -0,0 +1,159 @@
1
+ """Authentication helpers for the client transports.
2
+
3
+ An :class:`Auth` adds credentials to outgoing request headers and may react to a
4
+ ``401 Unauthorized`` by refreshing them (the transport then retries once)::
5
+
6
+ transport = HttpxTransport(auth=BasicAuth("alice", "secret"))
7
+ transport = HttpxTransport(auth=BearerAuth("eyJ..."))
8
+ transport = HttpxTransport(
9
+ auth=OAuth2ClientCredentialsAuth("https://idp/token", "client-id", "client-secret")
10
+ )
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import time
17
+ from typing import Any, Callable, Protocol
18
+
19
+ from odatapy.client.responses import ClientResponse
20
+
21
+
22
+ class Auth(Protocol):
23
+ def apply(self, headers: dict[str, str]) -> None:
24
+ """Add credentials to the request headers (in place)."""
25
+
26
+ def on_unauthorized(self, response: ClientResponse) -> bool:
27
+ """Called on a 401; return True to refresh credentials and retry once."""
28
+
29
+
30
+ class NoAuth:
31
+ def apply(self, headers: dict[str, str]) -> None:
32
+ return None
33
+
34
+ def on_unauthorized(self, response: ClientResponse) -> bool:
35
+ return False
36
+
37
+
38
+ class BasicAuth:
39
+ """HTTP Basic authentication."""
40
+
41
+ def __init__(self, username: str, password: str) -> None:
42
+ token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
43
+ self._header = f"Basic {token}"
44
+
45
+ def apply(self, headers: dict[str, str]) -> None:
46
+ headers["Authorization"] = self._header
47
+
48
+ def on_unauthorized(self, response: ClientResponse) -> bool:
49
+ return False
50
+
51
+
52
+ class BearerAuth:
53
+ """``Authorization: Bearer <token>``; ``token`` may be a string or a callable
54
+ returning the current token (called on every request and after a 401)."""
55
+
56
+ def __init__(self, token: str | Callable[[], str]) -> None:
57
+ self._token = token
58
+
59
+ @property
60
+ def token(self) -> str:
61
+ return self._token() if callable(self._token) else self._token
62
+
63
+ def apply(self, headers: dict[str, str]) -> None:
64
+ headers["Authorization"] = f"Bearer {self.token}"
65
+
66
+ def on_unauthorized(self, response: ClientResponse) -> bool:
67
+ # A callable token source can produce a fresh token; a static one cannot.
68
+ return callable(self._token)
69
+
70
+
71
+ class OAuth2ClientCredentialsAuth:
72
+ """OAuth 2.0 client-credentials grant.
73
+
74
+ Fetches an access token from ``token_url`` (form-encoded POST) on first use,
75
+ refreshes it when ``expires_in`` has elapsed, and once more after a 401.
76
+ ``token_fetcher`` can replace the HTTP call (it must return the token
77
+ response dict) - handy for tests or non-standard identity providers.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ token_url: str,
83
+ client_id: str,
84
+ client_secret: str,
85
+ scope: str | None = None,
86
+ *,
87
+ token_fetcher: Callable[[], dict[str, Any]] | None = None,
88
+ extra_params: dict[str, str] | None = None,
89
+ leeway: float = 30.0,
90
+ httpx_kwargs: dict[str, Any] | None = None,
91
+ ) -> None:
92
+ self.token_url = token_url
93
+ self.client_id = client_id
94
+ self.client_secret = client_secret
95
+ self.scope = scope
96
+ self.extra_params = dict(extra_params or {})
97
+ self.leeway = leeway
98
+ self._fetcher = token_fetcher
99
+ self._httpx_kwargs = httpx_kwargs or {}
100
+ self._access_token: str | None = None
101
+ self._expires_at: float | None = None
102
+ self._token_type = "Bearer"
103
+
104
+ # --- token management ------------------------------------------------
105
+ @property
106
+ def access_token(self) -> str | None:
107
+ return self._access_token
108
+
109
+ def is_expired(self) -> bool:
110
+ if self._access_token is None:
111
+ return True
112
+ if self._expires_at is None:
113
+ return False
114
+ return time.monotonic() >= self._expires_at - self.leeway
115
+
116
+ def fetch_token(self) -> str:
117
+ data = self._fetcher() if self._fetcher is not None else self._request_token()
118
+ token = data.get("access_token")
119
+ if not token:
120
+ raise ValueError("The token endpoint did not return an access_token.")
121
+ self._access_token = str(token)
122
+ self._token_type = str(data.get("token_type") or "Bearer")
123
+ expires_in = data.get("expires_in")
124
+ self._expires_at = (
125
+ time.monotonic() + float(expires_in) if expires_in is not None else None
126
+ )
127
+ return self._access_token
128
+
129
+ def _request_token(self) -> dict[str, Any]:
130
+ import httpx
131
+
132
+ form = {
133
+ "grant_type": "client_credentials",
134
+ "client_id": self.client_id,
135
+ "client_secret": self.client_secret,
136
+ **self.extra_params,
137
+ }
138
+ if self.scope:
139
+ form["scope"] = self.scope
140
+ response = httpx.post(self.token_url, data=form, **self._httpx_kwargs)
141
+ response.raise_for_status()
142
+ return response.json()
143
+
144
+ def invalidate(self) -> None:
145
+ self._access_token = None
146
+ self._expires_at = None
147
+
148
+ # --- Auth protocol ---------------------------------------------------
149
+ def apply(self, headers: dict[str, str]) -> None:
150
+ if self.is_expired():
151
+ self.fetch_token()
152
+ headers["Authorization"] = f"{self._token_type} {self._access_token}"
153
+
154
+ def on_unauthorized(self, response: ClientResponse) -> bool:
155
+ self.invalidate()
156
+ return True
157
+
158
+
159
+ __all__ = ["Auth", "BasicAuth", "BearerAuth", "NoAuth", "OAuth2ClientCredentialsAuth"]