2525from __future__ import annotations
2626
2727import time
28- from typing import Any
28+ from typing import Any , Mapping
2929import weakref
30+ from types import MappingProxyType
3031
3132import grpc
3233from typing_extensions import Self
3334
3435from .interface import LAUNCHER_CONFIG_T , LauncherProtocol , ServerType
3536
36- __all__ = ["ProductInstance" ]
37+ __all__ = ["ProductInstance" , "ProductInstanceError" ]
3738
3839_GRPC_MAX_MESSAGE_LENGTH = 256 * 1024 ** 2 # 256 MB
3940
4041
42+ class ProductInstanceError (RuntimeError ):
43+ """Custom exception for ProductInstance lifecycle errors."""
44+
45+
4146class ProductInstance :
4247 """Provides a wrapper for interacting with the launched product instance.
4348
4449 This class allows stopping and starting of the product instance. It also
45- provides access to its server URLs/ channels.
50+ provides access to its server URLs and gRPC channels.
4651
4752 The :class:`ProductInstance` class can be used as a context manager, stopping
4853 the instance when exiting the context.
4954 """
5055
5156 def __init__ (self , * , launcher : LauncherProtocol [LAUNCHER_CONFIG_T ]):
5257 self ._launcher = launcher
53- self ._finalizer : weakref .finalize [Any , Self ]
54- self ._urls : dict [str , str ]
55- self ._channels : dict [str , grpc .Channel ]
58+ self ._finalizer : weakref .finalize [Any , Self ] | None = None
59+ self ._urls : Mapping [str , str ] | None = None
60+ self ._channels : Mapping [str , grpc .Channel ] | None = None
5661 self .start ()
5762
5863 def __enter__ (self ) -> ProductInstance :
5964 """Enter the context manager defined by the product instance."""
6065 if self .stopped :
61- raise RuntimeError ("The product instance is stopped. Cannot enter context." )
66+ raise ProductInstanceError ("The product instance is stopped. Cannot enter context." )
6267 return self
6368
6469 def __exit__ (self , * exc : Any ) -> None :
@@ -70,28 +75,29 @@ def start(self: Self) -> None:
7075
7176 Raises
7277 ------
73- RuntimeError
74- If the instance is already in the started state.
75- RuntimeError
76- If the URLs exposed by the started instance do not match
77- the expected ones defined in the launcher's
78- :attr:`.LauncherProtocol.SERVER_SPEC` attribute.
78+ ProductInstanceError
79+ If the instance is already started or the URLs do not match
80+ the launcher's SERVER_SPEC.
7981 """
8082 if not self .stopped :
81- raise RuntimeError ("Cannot start the server. It has already been started." )
83+ raise ProductInstanceError ("Cannot start the server. It has already been started." )
84+
8285 self ._finalizer = weakref .finalize (self , self ._launcher .stop , timeout = None )
8386 self ._launcher .start ()
84- self ._channels = dict ()
85- urls = self .urls
86- if urls .keys () != self ._launcher .SERVER_SPEC .keys ():
87- raise RuntimeError (
88- f"The URL keys '{ urls .keys ()} ' provided by the launcher "
89- f"do not match the SERVER_SPEC keys '{ self ._launcher .SERVER_SPEC .keys ()} '."
87+
88+ self ._channels = {}
89+ self ._urls = MappingProxyType (self ._launcher .urls )
90+
91+ if self ._urls .keys () != self ._launcher .SERVER_SPEC .keys ():
92+ raise ProductInstanceError (
93+ f"The URL keys '{ self ._urls .keys ()} ' provided by the launcher "
94+ f"do not match the SERVER_SPEC keys '{ self ._launcher .SERVER_SPEC .keys ()} '"
9095 )
96+
9197 for key , server_type in self ._launcher .SERVER_SPEC .items ():
9298 if server_type == ServerType .GRPC :
9399 self ._channels [key ] = grpc .insecure_channel (
94- urls [key ],
100+ self . _urls [key ],
95101 options = [("grpc.max_receive_message_length" , _GRPC_MAX_MESSAGE_LENGTH )],
96102 )
97103
@@ -102,18 +108,19 @@ def stop(self, *, timeout: float | None = None) -> None:
102108 ----------
103109 timeout :
104110 Time in seconds after which the instance is forcefully stopped.
105- Not all launch methods implement this parameter. If the parameter
106- is not implemented, it is ignored.
111+ Not all launch methods implement this parameter.
107112
108113 Raises
109114 ------
110- RuntimeError
111- If the instance is already in the stopped state .
115+ ProductInstanceError
116+ If the instance is already stopped.
112117 """
113118 if self .stopped :
114- raise RuntimeError ("Cannot stop the server. It has already been stopped." )
119+ raise ProductInstanceError ("Cannot stop the server. It has already been stopped." )
120+
115121 self ._launcher .stop (timeout = timeout )
116- self ._finalizer .detach ()
122+ if self ._finalizer is not None :
123+ self ._finalizer .detach ()
117124
118125 def restart (self , stop_timeout : float | None = None ) -> None :
119126 """Stop and then start the product instance.
@@ -122,17 +129,11 @@ def restart(self, stop_timeout: float | None = None) -> None:
122129 ----------
123130 stop_timeout :
124131 Time in seconds after which the instance is forcefully stopped.
125- Not all launch methods implement this parameter. If the parameter
126- is not implemented, it is ignored.
127132
128133 Raises
129134 ------
130- RuntimeError
131- If the instance is already in the stopped state.
132- RuntimeError
133- If the URLs exposed by the started instance do not match
134- the expected ones defined in the launcher's
135- :attr:`.LauncherProtocol.SERVER_SPEC` attribute.
135+ ProductInstanceError
136+ If the instance is already stopped or URL keys mismatch.
136137 """
137138 self .stop (timeout = stop_timeout )
138139 self .start ()
@@ -143,9 +144,8 @@ def check(self, timeout: float | None = None) -> bool:
143144 Parameters
144145 ----------
145146 timeout :
146- Time in seconds to wait for the servers to respond. There
147- is no guarantee that the ``check()`` method returns within this time.
148- Instead, this parameter is used as a hint to the launcher implementation.
147+ Time in seconds to wait for the servers to respond. This is a hint
148+ to the launcher; the method may return earlier or later.
149149 """
150150 return self ._launcher .check (timeout = timeout )
151151
@@ -162,36 +162,32 @@ def wait(self, timeout: float) -> None:
162162
163163 Raises
164164 ------
165- RuntimeError
166- If the server still has not responded after `` timeout` ` seconds.
165+ ProductInstanceError
166+ If the server still has not responded after `timeout` seconds.
167167 """
168168 start_time = time .time ()
169169 while time .time () - start_time <= timeout :
170170 if self .check (timeout = timeout / 3 ):
171171 break
172- else :
173- # Try again until the timeout is reached. We add a small
174- # delay s.t. the server isn't bombarded with requests.
175- time .sleep (timeout / 100 )
172+ time .sleep (max (timeout / 100 , 0.01 )) # minimum sleep to avoid busy waiting
176173 else :
177- raise RuntimeError (f"The product is not running after { timeout } s." )
174+ raise ProductInstanceError (f"The product is not running after { timeout } s." )
178175
179176 @property
180- def urls (self ) -> dict [str , str ]:
181- """URL and port for the servers of the product instance."""
182- return self ._launcher .urls
177+ def urls (self ) -> Mapping [str , str ]:
178+ """Read-only mapping of server keys to their URLs."""
179+ if self ._urls is None :
180+ return MappingProxyType ({})
181+ return self ._urls
183182
184183 @property
185184 def stopped (self ) -> bool :
186185 """Flag indicating if the product instance is currently stopped."""
187- try :
188- return not self ._finalizer .alive
189- # If the server has never been started, the '_finalizer' attribute
190- # may not be defined.
191- except AttributeError :
192- return True
186+ return self ._finalizer is None or not self ._finalizer .alive
193187
194188 @property
195- def channels (self ) -> dict [str , grpc .Channel ]:
196- """Channels to the gRPC servers of the product instance."""
197- return self ._channels
189+ def channels (self ) -> Mapping [str , grpc .Channel ]:
190+ """Read-only mapping of server keys to gRPC channels."""
191+ if self ._channels is None :
192+ return MappingProxyType ({})
193+ return MappingProxyType (self ._channels )
0 commit comments