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 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
@@ -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