ondewo-client-utils 4.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.
- ondewo/__init__.py +7 -0
- ondewo/utils/__init__.py +15 -0
- ondewo/utils/async_base_client.py +164 -0
- ondewo/utils/async_base_services_interface.py +283 -0
- ondewo/utils/base_client.py +160 -0
- ondewo/utils/base_client_config.py +450 -0
- ondewo/utils/base_service_container.py +33 -0
- ondewo/utils/base_services_interface.py +291 -0
- ondewo/utils/grpc_retry_policy.py +269 -0
- ondewo/utils/helpers.py +99 -0
- ondewo/utils/text.py +49 -0
- ondewo/version.py +3 -0
- ondewo_client_utils-4.0.0.dist-info/METADATA +174 -0
- ondewo_client_utils-4.0.0.dist-info/RECORD +17 -0
- ondewo_client_utils-4.0.0.dist-info/WHEEL +5 -0
- ondewo_client_utils-4.0.0.dist-info/licenses/LICENSE +191 -0
- ondewo_client_utils-4.0.0.dist-info/top_level.txt +1 -0
ondewo/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Namespace package root for the ``ondewo`` distribution family.
|
|
2
|
+
|
|
3
|
+
Extends ``__path__`` via :func:`pkgutil.extend_path` so that sibling ``ondewo.*``
|
|
4
|
+
distributions installed in separate locations share the single ``ondewo`` namespace.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore
|
ondewo/utils/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2020-2026 ONDEWO GmbH
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Utilities and abstract base classes shared by ONDEWO gRPC Python clients."""
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Copyright 2017-2026 ONDEWO GmbH
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Abstract base class for asynchronous ONDEWO gRPC Python clients.
|
|
17
|
+
|
|
18
|
+
Provides the async counterpart of ``BaseClient`` with awaitable ``connect`` and
|
|
19
|
+
``disconnect`` methods that manage the lifecycle of the underlying gRPC channels.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import dataclasses
|
|
23
|
+
from abc import (
|
|
24
|
+
ABC,
|
|
25
|
+
abstractmethod,
|
|
26
|
+
)
|
|
27
|
+
from typing import (
|
|
28
|
+
Any,
|
|
29
|
+
Optional,
|
|
30
|
+
Set,
|
|
31
|
+
Tuple,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from ondewo.utils.base_client_config import BaseClientConfig
|
|
35
|
+
from ondewo.utils.base_service_container import BaseServicesContainer
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncBaseClient(ABC):
|
|
39
|
+
"""
|
|
40
|
+
Abstract base class for async ONDEWO clients.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
services: A container for the service clients initialized by the client.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
config: BaseClientConfig,
|
|
49
|
+
use_secure_channel: bool = True,
|
|
50
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Initialize the async client and its service clients.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
config (BaseClientConfig):
|
|
57
|
+
Configuration for the client.
|
|
58
|
+
use_secure_channel (bool):
|
|
59
|
+
Whether to use a secure gRPC channel. Defaults to ``True``.
|
|
60
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
61
|
+
Additional options for the gRPC channel. Defaults to ``None``.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError:
|
|
65
|
+
If the ``services`` attribute is not defined after initialization.
|
|
66
|
+
"""
|
|
67
|
+
self.services: Optional[BaseServicesContainer] = None
|
|
68
|
+
self._initialize_services(
|
|
69
|
+
config=config,
|
|
70
|
+
use_secure_channel=use_secure_channel,
|
|
71
|
+
options=options,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if not self.services:
|
|
75
|
+
raise ValueError(f"The attribute `services` must be defined in class {self.__class__.__name__}.")
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def _initialize_services(
|
|
79
|
+
self,
|
|
80
|
+
config: BaseClientConfig,
|
|
81
|
+
use_secure_channel: bool,
|
|
82
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Initialize the service clients.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
config (BaseClientConfig):
|
|
89
|
+
Configuration for the client.
|
|
90
|
+
use_secure_channel (bool):
|
|
91
|
+
Whether to use a secure gRPC channel.
|
|
92
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
93
|
+
Additional options for the gRPC channel. Defaults to ``None``.
|
|
94
|
+
"""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
async def connect(
|
|
98
|
+
self,
|
|
99
|
+
config: BaseClientConfig,
|
|
100
|
+
use_secure_channel: bool,
|
|
101
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Establish a connection to the services.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
config (BaseClientConfig):
|
|
108
|
+
Configuration for the client.
|
|
109
|
+
use_secure_channel (bool):
|
|
110
|
+
Whether to use a secure gRPC channel.
|
|
111
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
112
|
+
Additional options for the gRPC channel. Defaults to ``None``.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ConnectionError:
|
|
116
|
+
If a connection is already established.
|
|
117
|
+
"""
|
|
118
|
+
if self.services:
|
|
119
|
+
raise ConnectionError("The current client already has an open connection.")
|
|
120
|
+
|
|
121
|
+
self._initialize_services(
|
|
122
|
+
config=config,
|
|
123
|
+
use_secure_channel=use_secure_channel,
|
|
124
|
+
options=options,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def disconnect(self) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Asynchronously close every service's gRPC channel and clear the services.
|
|
130
|
+
|
|
131
|
+
Every field of the services dataclass is visited, inherited ones included, and a channel
|
|
132
|
+
shared by several services (see ``build_shared_channel``) is closed exactly once. A
|
|
133
|
+
``close()`` that raises does not leave the remaining channels open: every channel is
|
|
134
|
+
attempted, ``services`` is cleared regardless, and the first error is re-raised.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
AttributeError:
|
|
138
|
+
If the ``services`` attribute is not defined.
|
|
139
|
+
Exception:
|
|
140
|
+
The first exception raised by a channel's ``close()``, after all channels were
|
|
141
|
+
attempted.
|
|
142
|
+
"""
|
|
143
|
+
if not self.services:
|
|
144
|
+
raise AttributeError("The attribute `services` is not defined.")
|
|
145
|
+
|
|
146
|
+
first_error: Optional[BaseException] = None
|
|
147
|
+
closed: Set[int] = set()
|
|
148
|
+
try:
|
|
149
|
+
# dataclasses.fields() and not __annotations__: on Python 3.14 an instance has no
|
|
150
|
+
# __annotations__ (PEP 649), and on every version __annotations__ omits the fields a
|
|
151
|
+
# parent container declares, so their channels leaked.
|
|
152
|
+
for service_field in dataclasses.fields(self.services):
|
|
153
|
+
channel: Any = getattr(self.services, service_field.name).grpc_channel
|
|
154
|
+
if id(channel) in closed:
|
|
155
|
+
continue
|
|
156
|
+
closed.add(id(channel))
|
|
157
|
+
try:
|
|
158
|
+
await channel.close(grace=None)
|
|
159
|
+
except Exception as error:
|
|
160
|
+
first_error = first_error or error
|
|
161
|
+
finally:
|
|
162
|
+
self.services = None
|
|
163
|
+
if first_error is not None:
|
|
164
|
+
raise first_error
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Copyright 2020-2026 ONDEWO GmbH
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
"""Async gRPC service interface base classes and channel factory helpers.
|
|
15
|
+
|
|
16
|
+
Async counterpart of ``base_services_interface`` built on ``grpc.aio``: it
|
|
17
|
+
provides channel factory helpers and the abstract ``AsyncBaseServicesInterface``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import struct
|
|
22
|
+
from abc import (
|
|
23
|
+
ABC,
|
|
24
|
+
abstractmethod,
|
|
25
|
+
)
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
from typing import (
|
|
28
|
+
Any,
|
|
29
|
+
Dict,
|
|
30
|
+
List,
|
|
31
|
+
Optional,
|
|
32
|
+
Set,
|
|
33
|
+
Tuple,
|
|
34
|
+
Union,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
import grpc
|
|
38
|
+
|
|
39
|
+
from ondewo.utils.base_client_config import BaseClientConfig
|
|
40
|
+
from ondewo.utils.grpc_retry_policy import (
|
|
41
|
+
build_service_config_json,
|
|
42
|
+
service_config_json_for,
|
|
43
|
+
service_config_json_for_classes,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# A module logger, never the root one: logging.warning() at module level runs basicConfig() and
|
|
47
|
+
# installs a stderr handler on the HOST application's root logger.
|
|
48
|
+
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
MAX_MESSAGE_LENGTH: int = 2 ** (struct.Struct("i").size * 8 - 1) - 1
|
|
51
|
+
|
|
52
|
+
# The default channel options are constant and assembled once at import time so that
|
|
53
|
+
# building a client with many services stays cheap (ultra low latency). The retry policy is
|
|
54
|
+
# per service: only idempotent methods are retried (see ``ondewo.utils.grpc_retry_policy``).
|
|
55
|
+
# ``_SERVICE_CONFIG_JSON`` is the config for a class whose services cannot be found, i.e. no
|
|
56
|
+
# method retried beyond gRPC's transparent retries; ``_grpc_options_items_for`` swaps in the
|
|
57
|
+
# per-class config, built once per class and cached.
|
|
58
|
+
_SERVICE_CONFIG_JSON: str = build_service_config_json([])
|
|
59
|
+
|
|
60
|
+
_DEFAULT_GRPC_OPTIONS: Dict[str, Any] = {
|
|
61
|
+
"grpc.max_send_message_length": MAX_MESSAGE_LENGTH,
|
|
62
|
+
"grpc.max_receive_message_length": MAX_MESSAGE_LENGTH,
|
|
63
|
+
# Keepalive keeps long-lived streaming RPCs warm and detects half-open connections while
|
|
64
|
+
# data flows. Pings fire only during active calls (permit_without_calls stays False), and
|
|
65
|
+
# stop after 2 pings without data (gRPC's default): a default grpc-core server
|
|
66
|
+
# (min_recv_ping_interval_without_data 5 min, max_ping_strikes 2) answers a client that
|
|
67
|
+
# keeps pinging a silent stream with GOAWAY ENHANCE_YOUR_CALM "too_many_pings", which
|
|
68
|
+
# tears down the shared connection (measured: UNAVAILABLE after ~50 s at a 10 s keepalive
|
|
69
|
+
# with 0 = unlimited; the same silent stream completed with 2).
|
|
70
|
+
"grpc.keepalive_time_ms": 30000,
|
|
71
|
+
"grpc.keepalive_timeout_ms": 60000,
|
|
72
|
+
"grpc.keepalive_permit_without_calls": False,
|
|
73
|
+
"grpc.http2.max_pings_without_data": 2,
|
|
74
|
+
# "grpc.dns_enable_srv_queries" is deliberately NOT set: it only discovers deprecated grpclb
|
|
75
|
+
# balancers, which no ONDEWO deployment uses, and cost ~14 ms per channel (median channel
|
|
76
|
+
# creation + first unary call to 127.0.0.1: 15.9 ms with it, 1.4 ms without). A caller
|
|
77
|
+
# that runs grpclb can still pass it in ``options``.
|
|
78
|
+
"grpc.enable_retries": 1,
|
|
79
|
+
"grpc.service_config": _SERVICE_CONFIG_JSON,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@lru_cache(maxsize=None)
|
|
84
|
+
def _grpc_options_items_for(service_class: type) -> List[Tuple[str, Any]]:
|
|
85
|
+
"""
|
|
86
|
+
Return the default channel options for a service-interface class, built once per class.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
service_class (type):
|
|
90
|
+
The concrete service-interface class being instantiated.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List[Tuple[str, Any]]:
|
|
94
|
+
The default options with ``grpc.service_config`` set to the class's retry policy.
|
|
95
|
+
"""
|
|
96
|
+
options: Dict[str, Any] = dict(_DEFAULT_GRPC_OPTIONS)
|
|
97
|
+
options["grpc.service_config"] = service_config_json_for(service_class)
|
|
98
|
+
return list(options.items())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_secure_channel(
|
|
102
|
+
host: str,
|
|
103
|
+
cert: Union[str, bytes],
|
|
104
|
+
options: Optional[List[Tuple[str, Any]]] = None,
|
|
105
|
+
) -> grpc.aio.Channel:
|
|
106
|
+
"""
|
|
107
|
+
Create a secure asynchronous gRPC channel to the given host.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
host (str):
|
|
111
|
+
Target address in the form "host:port" to connect to.
|
|
112
|
+
cert (Union[str, bytes]):
|
|
113
|
+
Root certificate used to establish the TLS connection. A ``str`` is encoded to
|
|
114
|
+
``bytes`` before being handed to gRPC; ``BaseClientConfig.__post_init__`` already
|
|
115
|
+
supplies ``bytes``.
|
|
116
|
+
options (Optional[List[Tuple[str, Any]]]):
|
|
117
|
+
Optional list of gRPC channel options as (key, value) tuples.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
grpc.aio.Channel:
|
|
121
|
+
A secure asynchronous gRPC channel connected to the target host.
|
|
122
|
+
"""
|
|
123
|
+
root_certificates: bytes = cert.encode() if isinstance(cert, str) else cert
|
|
124
|
+
credentials: grpc.ChannelCredentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
|
125
|
+
return grpc.aio.secure_channel(
|
|
126
|
+
target=host,
|
|
127
|
+
credentials=credentials,
|
|
128
|
+
options=options,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_grpc_channel(
|
|
133
|
+
config: BaseClientConfig,
|
|
134
|
+
use_secure_channel: bool,
|
|
135
|
+
options: Optional[List[Tuple[str, Any]]] = None,
|
|
136
|
+
) -> grpc.aio.Channel:
|
|
137
|
+
"""
|
|
138
|
+
Build an asynchronous gRPC channel, secure or insecure, from a client config.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
config (BaseClientConfig):
|
|
142
|
+
Client configuration providing the host, port and optional certificate.
|
|
143
|
+
use_secure_channel (bool):
|
|
144
|
+
Whether to create a secure (TLS) channel. If False an insecure channel
|
|
145
|
+
is created instead.
|
|
146
|
+
options (Optional[List[Tuple[str, Any]]]):
|
|
147
|
+
Optional list of gRPC channel options as (key, value) tuples.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
grpc.aio.Channel:
|
|
151
|
+
A secure or insecure asynchronous gRPC channel.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ValueError:
|
|
155
|
+
If a secure channel is requested but the config has no gRPC certificate.
|
|
156
|
+
"""
|
|
157
|
+
if not use_secure_channel:
|
|
158
|
+
_LOGGER.warning("Using an INSECURE (plaintext) gRPC channel to %s.", config.host_and_port)
|
|
159
|
+
return grpc.aio.insecure_channel(target=config.host_and_port, options=options)
|
|
160
|
+
|
|
161
|
+
if not config.grpc_cert:
|
|
162
|
+
# Never interpolate the config itself: a downstream subclass may carry a password field.
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"No grpc certificate found on {type(config).__name__} for {config.host_and_port}; "
|
|
165
|
+
"pass grpc_cert or use_secure_channel=False."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return get_secure_channel(
|
|
169
|
+
host=config.host_and_port,
|
|
170
|
+
cert=config.grpc_cert,
|
|
171
|
+
options=options,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_shared_channel(
|
|
176
|
+
config: BaseClientConfig,
|
|
177
|
+
use_secure_channel: bool,
|
|
178
|
+
service_classes: Tuple[type, ...],
|
|
179
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
180
|
+
) -> grpc.aio.Channel:
|
|
181
|
+
"""
|
|
182
|
+
Open ONE channel for several service interfaces: one connection, one TLS handshake, one resolution.
|
|
183
|
+
|
|
184
|
+
By default every ``AsyncBaseServicesInterface`` opens its own channel, so a client with N services
|
|
185
|
+
opens N connections and pays N name resolutions and N TLS handshakes. Build one channel here
|
|
186
|
+
and hand it to each service with ``grpc_channel=``. The channel's retry policy is the union of
|
|
187
|
+
the per-class policies (:func:`ondewo.utils.grpc_retry_policy.service_config_json_for_classes`),
|
|
188
|
+
which gives every method exactly the policy its own channel would have had.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
config (BaseClientConfig):
|
|
192
|
+
Client configuration providing the host, port and optional gRPC certificate.
|
|
193
|
+
use_secure_channel (bool):
|
|
194
|
+
If ``True`` open a secure (TLS) channel; if ``False`` open an insecure channel.
|
|
195
|
+
service_classes (Tuple[type, ...]):
|
|
196
|
+
The service-interface classes that will share the channel.
|
|
197
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
198
|
+
Optional channel option overrides merged on top of the defaults, exactly as
|
|
199
|
+
``AsyncBaseServicesInterface.__init__`` merges them; a caller ``grpc.service_config`` wins.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
grpc.aio.Channel:
|
|
203
|
+
The shared channel. Closing it (e.g. through ``disconnect``) closes it for every service.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ValueError:
|
|
207
|
+
If a secure channel is requested but the config has no gRPC certificate.
|
|
208
|
+
"""
|
|
209
|
+
merged_options: Dict[str, Any] = dict(_DEFAULT_GRPC_OPTIONS)
|
|
210
|
+
merged_options["grpc.service_config"] = service_config_json_for_classes(tuple(service_classes))
|
|
211
|
+
if options:
|
|
212
|
+
merged_options.update(dict(options))
|
|
213
|
+
return _get_grpc_channel(
|
|
214
|
+
config=config,
|
|
215
|
+
use_secure_channel=use_secure_channel,
|
|
216
|
+
options=list(merged_options.items()),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class AsyncBaseServicesInterface(ABC):
|
|
221
|
+
"""
|
|
222
|
+
Abstract base class for async ONDEWO gRPC service interfaces.
|
|
223
|
+
|
|
224
|
+
Attributes:
|
|
225
|
+
grpc_channel (grpc.aio.Channel):
|
|
226
|
+
The asynchronous gRPC channel used to communicate with the service.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
config: BaseClientConfig,
|
|
232
|
+
use_secure_channel: bool,
|
|
233
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
234
|
+
*,
|
|
235
|
+
grpc_channel: Optional[grpc.aio.Channel] = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""
|
|
238
|
+
Initialize the async service interface and open its gRPC channel.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
config (BaseClientConfig):
|
|
242
|
+
Client configuration providing the host, port and optional certificate.
|
|
243
|
+
use_secure_channel (bool):
|
|
244
|
+
Whether to create a secure (TLS) channel.
|
|
245
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
246
|
+
Optional set of gRPC channel options as (key, value) tuples that
|
|
247
|
+
override the default options. Passing ``("grpc.service_config", <json>)``
|
|
248
|
+
replaces the default retry policy, which retries idempotent methods only (see
|
|
249
|
+
``ondewo.utils.grpc_retry_policy``).
|
|
250
|
+
grpc_channel (Optional[grpc.aio.Channel]):
|
|
251
|
+
An already open channel to use instead of opening one, e.g. from
|
|
252
|
+
:func:`build_shared_channel`. When given, ``config``, ``use_secure_channel`` and
|
|
253
|
+
``options`` are ignored and nothing is built. Defaults to ``None``.
|
|
254
|
+
"""
|
|
255
|
+
if grpc_channel is not None:
|
|
256
|
+
self.grpc_channel: grpc.aio.Channel = grpc_channel
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
default_options: List[Tuple[str, Any]] = _grpc_options_items_for(type(self))
|
|
260
|
+
if options:
|
|
261
|
+
merged_options: Dict[str, Any] = dict(default_options)
|
|
262
|
+
merged_options.update(dict(options))
|
|
263
|
+
updated_options: List[Tuple[str, Any]] = list(merged_options.items())
|
|
264
|
+
else:
|
|
265
|
+
updated_options = default_options
|
|
266
|
+
|
|
267
|
+
self.grpc_channel = _get_grpc_channel(
|
|
268
|
+
config=config,
|
|
269
|
+
use_secure_channel=use_secure_channel,
|
|
270
|
+
options=updated_options,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
@abstractmethod
|
|
275
|
+
def stub(self) -> Any:
|
|
276
|
+
"""
|
|
277
|
+
Return the concrete gRPC stub used to issue RPC calls.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Any:
|
|
281
|
+
The gRPC service stub implemented by the concrete subclass.
|
|
282
|
+
"""
|
|
283
|
+
pass
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright 2017-2026 ONDEWO GmbH
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Abstract base class providing the synchronous scaffolding for ONDEWO gRPC clients."""
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
from abc import (
|
|
19
|
+
ABC,
|
|
20
|
+
abstractmethod,
|
|
21
|
+
)
|
|
22
|
+
from typing import (
|
|
23
|
+
Any,
|
|
24
|
+
Optional,
|
|
25
|
+
Set,
|
|
26
|
+
Tuple,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ondewo.utils.base_client_config import BaseClientConfig
|
|
30
|
+
from ondewo.utils.base_service_container import BaseServicesContainer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseClient(ABC):
|
|
34
|
+
"""
|
|
35
|
+
Abstract base class for ONDEWO clients.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
services (Optional[BaseServicesContainer]):
|
|
39
|
+
A container for the service clients initialized by the client, or ``None`` when the
|
|
40
|
+
client is not connected.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
config: BaseClientConfig,
|
|
46
|
+
use_secure_channel: bool = True,
|
|
47
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Initialize the client and its service clients.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
config (BaseClientConfig):
|
|
54
|
+
Configuration for the client.
|
|
55
|
+
use_secure_channel (bool):
|
|
56
|
+
Whether to use a secure gRPC channel. Defaults to ``True``.
|
|
57
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
58
|
+
Additional options for the gRPC channel. Defaults to ``None``.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError:
|
|
62
|
+
If ``_initialize_services`` does not populate the ``services`` attribute.
|
|
63
|
+
"""
|
|
64
|
+
self.services: Optional[BaseServicesContainer] = None
|
|
65
|
+
self._initialize_services(
|
|
66
|
+
config=config,
|
|
67
|
+
use_secure_channel=use_secure_channel,
|
|
68
|
+
options=options,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not self.services:
|
|
72
|
+
raise ValueError(f"The attribute `services` must be defined in class {self.__class__.__name__}.")
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def _initialize_services(
|
|
76
|
+
self,
|
|
77
|
+
config: BaseClientConfig,
|
|
78
|
+
use_secure_channel: bool,
|
|
79
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Initialize the service clients.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config (BaseClientConfig):
|
|
86
|
+
Configuration for the client.
|
|
87
|
+
use_secure_channel (bool):
|
|
88
|
+
Whether to use a secure gRPC channel.
|
|
89
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
90
|
+
Additional options for the gRPC channel.
|
|
91
|
+
"""
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
def connect(
|
|
95
|
+
self,
|
|
96
|
+
config: BaseClientConfig,
|
|
97
|
+
use_secure_channel: bool,
|
|
98
|
+
options: Optional[Set[Tuple[str, Any]]] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Establish a connection to the services.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
config (BaseClientConfig):
|
|
105
|
+
Configuration for the client.
|
|
106
|
+
use_secure_channel (bool):
|
|
107
|
+
Whether to use a secure gRPC channel.
|
|
108
|
+
options (Optional[Set[Tuple[str, Any]]]):
|
|
109
|
+
Additional options for the gRPC channel.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ConnectionError: If a connection is already established.
|
|
113
|
+
"""
|
|
114
|
+
if self.services:
|
|
115
|
+
raise ConnectionError("The current client already has an open connection.")
|
|
116
|
+
|
|
117
|
+
self._initialize_services(
|
|
118
|
+
config=config,
|
|
119
|
+
use_secure_channel=use_secure_channel,
|
|
120
|
+
options=options,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def disconnect(self) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Close every service's gRPC channel and clear the services.
|
|
126
|
+
|
|
127
|
+
Every field of the services dataclass is visited, inherited ones included, and a channel
|
|
128
|
+
shared by several services (see ``build_shared_channel``) is closed exactly once. A
|
|
129
|
+
``close()`` that raises does not leave the remaining channels open: every channel is
|
|
130
|
+
attempted, ``services`` is cleared regardless, and the first error is re-raised.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
AttributeError:
|
|
134
|
+
If the ``services`` attribute is not defined.
|
|
135
|
+
Exception:
|
|
136
|
+
The first exception raised by a channel's ``close()``, after all channels were
|
|
137
|
+
attempted.
|
|
138
|
+
"""
|
|
139
|
+
if not self.services:
|
|
140
|
+
raise AttributeError("The attribute `services` is not defined.")
|
|
141
|
+
|
|
142
|
+
first_error: Optional[BaseException] = None
|
|
143
|
+
closed: Set[int] = set()
|
|
144
|
+
try:
|
|
145
|
+
# dataclasses.fields() and not __annotations__: on Python 3.14 an instance has no
|
|
146
|
+
# __annotations__ (PEP 649), and on every version __annotations__ omits the fields a
|
|
147
|
+
# parent container declares, so their channels leaked.
|
|
148
|
+
for service_field in dataclasses.fields(self.services):
|
|
149
|
+
channel: Any = getattr(self.services, service_field.name).grpc_channel
|
|
150
|
+
if id(channel) in closed:
|
|
151
|
+
continue
|
|
152
|
+
closed.add(id(channel))
|
|
153
|
+
try:
|
|
154
|
+
channel.close()
|
|
155
|
+
except Exception as error:
|
|
156
|
+
first_error = first_error or error
|
|
157
|
+
finally:
|
|
158
|
+
self.services = None
|
|
159
|
+
if first_error is not None:
|
|
160
|
+
raise first_error
|