langfun 0.1.2.dev202509160805__py3-none-any.whl → 0.1.2.dev202509180804__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.
Potentially problematic release.
This version of langfun might be problematic. Click here for more details.
- langfun/env/__init__.py +39 -0
- langfun/env/base_environment.py +491 -0
- langfun/env/base_feature.py +165 -0
- langfun/env/base_sandbox.py +458 -0
- langfun/env/base_test.py +795 -0
- langfun/env/interface.py +843 -0
- langfun/env/interface_test.py +43 -0
- langfun/env/load_balancers.py +59 -0
- langfun/env/load_balancers_test.py +157 -0
- {langfun-0.1.2.dev202509160805.dist-info → langfun-0.1.2.dev202509180804.dist-info}/METADATA +1 -1
- {langfun-0.1.2.dev202509160805.dist-info → langfun-0.1.2.dev202509180804.dist-info}/RECORD +14 -5
- {langfun-0.1.2.dev202509160805.dist-info → langfun-0.1.2.dev202509180804.dist-info}/WHEEL +0 -0
- {langfun-0.1.2.dev202509160805.dist-info → langfun-0.1.2.dev202509180804.dist-info}/licenses/LICENSE +0 -0
- {langfun-0.1.2.dev202509160805.dist-info → langfun-0.1.2.dev202509180804.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# Copyright 2025 The Langfun Authors
|
|
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
|
+
"""Common base class for sandboxes.
|
|
15
|
+
|
|
16
|
+
This module provides an base class `BaseSandbox` for sandboxes, which takes care
|
|
17
|
+
of the sandbox lifecycle and housekeeping. It also provides a decorator for
|
|
18
|
+
sandbox service methods to handle errors and trigger shutdowns. Please note that
|
|
19
|
+
this base class is intended to provide a convenient way to implement sandboxes,
|
|
20
|
+
and not all sandbox implementations need to subclass it. Also `BaseSandbox`
|
|
21
|
+
is not coupled with `BaseEnvironment` and `BaseFeature`, and is expected to
|
|
22
|
+
work with the `Environment` and `Feature` interfaces directly.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import functools
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from typing import Annotated, Any, Callable, Iterator, Sequence, Type
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
from langfun.env import interface
|
|
33
|
+
import pyglove as pg
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BaseSandbox(interface.Sandbox):
|
|
37
|
+
"""Base class for a sandbox."""
|
|
38
|
+
|
|
39
|
+
id: Annotated[
|
|
40
|
+
interface.SandboxId,
|
|
41
|
+
'The identifier for the sandbox.'
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
environment: Annotated[
|
|
45
|
+
pg.Ref[interface.Environment],
|
|
46
|
+
'The parent environment.'
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
reusable: Annotated[
|
|
50
|
+
bool,
|
|
51
|
+
(
|
|
52
|
+
'If True, the sandbox can be reused for multiple user sessions at '
|
|
53
|
+
'different times.'
|
|
54
|
+
)
|
|
55
|
+
] = False
|
|
56
|
+
|
|
57
|
+
keepalive_interval: Annotated[
|
|
58
|
+
float | None,
|
|
59
|
+
'Interval to ping the sandbox for keeping it alive..'
|
|
60
|
+
] = 60.0
|
|
61
|
+
|
|
62
|
+
#
|
|
63
|
+
# There is no required methods that subclasses must implement.
|
|
64
|
+
# Subclasses can override the following methods:
|
|
65
|
+
#
|
|
66
|
+
|
|
67
|
+
def _start(self) -> None:
|
|
68
|
+
"""Implementation of start(). Subclasses can override.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
interface.SandboxStateError: If the sandbox is in a bad state.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def _shutdown(self) -> None:
|
|
75
|
+
"""Implementation of shutdown(). Subclasses can override.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
interface.SandboxStateError: If the sandbox is in a bad state.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def _setup_features(self) -> None:
|
|
82
|
+
"""Starts the features in the sandbox."""
|
|
83
|
+
for feature in self._features.values():
|
|
84
|
+
feature.setup(self)
|
|
85
|
+
|
|
86
|
+
def _teardown_features(self) -> None:
|
|
87
|
+
"""Tears down the features in the sandbox."""
|
|
88
|
+
for feature in self._features.values():
|
|
89
|
+
feature.teardown()
|
|
90
|
+
|
|
91
|
+
def _start_session(self, session_id: str) -> None:
|
|
92
|
+
"""Starts a user session."""
|
|
93
|
+
self._session_id = session_id
|
|
94
|
+
self._session_start_time = time.time()
|
|
95
|
+
|
|
96
|
+
for feature in self._features.values():
|
|
97
|
+
feature.setup_session(session_id)
|
|
98
|
+
|
|
99
|
+
def _end_session(self) -> None:
|
|
100
|
+
try:
|
|
101
|
+
for feature in self._features.values():
|
|
102
|
+
feature.teardown_session(self._session_id)
|
|
103
|
+
finally:
|
|
104
|
+
pg.logging.info(
|
|
105
|
+
'[%s]: User session %s ended. (lifetime: %.2f seconds).',
|
|
106
|
+
self.id,
|
|
107
|
+
self._session_id,
|
|
108
|
+
time.time() - self._session_start_time
|
|
109
|
+
)
|
|
110
|
+
self._session_id = None
|
|
111
|
+
self._session_start_time = None
|
|
112
|
+
|
|
113
|
+
def _ping(self) -> None:
|
|
114
|
+
"""Implementation of ping for health checking."""
|
|
115
|
+
|
|
116
|
+
#
|
|
117
|
+
# Sandbox basics.
|
|
118
|
+
#
|
|
119
|
+
|
|
120
|
+
def _on_bound(self) -> None:
|
|
121
|
+
"""Called when the sandbox is bound."""
|
|
122
|
+
super()._on_bound()
|
|
123
|
+
self._features = pg.Dict({
|
|
124
|
+
name: pg.clone(feature)
|
|
125
|
+
for name, feature in self.environment.features.items()
|
|
126
|
+
})
|
|
127
|
+
self._session_id = None
|
|
128
|
+
self._session_start_time = None
|
|
129
|
+
self._alive = False
|
|
130
|
+
self._start_time = None
|
|
131
|
+
|
|
132
|
+
self._needs_housekeep = (
|
|
133
|
+
self.keepalive_interval is not None
|
|
134
|
+
or any(
|
|
135
|
+
feature.housekeep_interval is not None
|
|
136
|
+
for feature in self._features.values()
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
self._housekeep_thread = None
|
|
140
|
+
self._housekeep_count = 0
|
|
141
|
+
|
|
142
|
+
@functools.cached_property
|
|
143
|
+
def working_dir(self) -> str | None:
|
|
144
|
+
"""Returns the working directory for the sandbox."""
|
|
145
|
+
return self.id.working_dir(self.environment.root_dir)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_alive(self) -> bool:
|
|
149
|
+
"""Returns whether the sandbox is alive."""
|
|
150
|
+
return self._alive
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_busy(self) -> bool:
|
|
154
|
+
"""Returns whether the sandbox is busy."""
|
|
155
|
+
return self._session_id not in (None, 'pending')
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def features(self) -> dict[str, interface.Feature]:
|
|
159
|
+
"""Returns the features in the sandbox."""
|
|
160
|
+
return self._features
|
|
161
|
+
|
|
162
|
+
#
|
|
163
|
+
# Sandbox start/shutdown.
|
|
164
|
+
#
|
|
165
|
+
|
|
166
|
+
def start(self) -> None:
|
|
167
|
+
"""Starts the sandbox.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
interface.SandboxStateError: If the sandbox fails to start.
|
|
171
|
+
"""
|
|
172
|
+
assert not self._alive, 'Sandbox is already alive.'
|
|
173
|
+
|
|
174
|
+
def start_impl():
|
|
175
|
+
t = time.time()
|
|
176
|
+
self._start()
|
|
177
|
+
self._setup_features()
|
|
178
|
+
|
|
179
|
+
# We mark the sandbox as alive after the setup before the maintenance
|
|
180
|
+
# thread is started. This is to avoid the maintenance thread from
|
|
181
|
+
# immediately shutting down the sandbox because it's not alive yet.
|
|
182
|
+
self._alive = True
|
|
183
|
+
self._start_time = time.time()
|
|
184
|
+
|
|
185
|
+
if self._needs_housekeep:
|
|
186
|
+
self._housekeep_thread = threading.Thread(
|
|
187
|
+
target=self._housekeep_loop, daemon=True
|
|
188
|
+
)
|
|
189
|
+
self._housekeep_thread.start()
|
|
190
|
+
|
|
191
|
+
pg.logging.info(
|
|
192
|
+
'[%s]: Sandbox started in %.2f seconds.',
|
|
193
|
+
self.id, time.time() - t
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
interface.call_with_event(
|
|
197
|
+
action=start_impl,
|
|
198
|
+
event_handler=self.on_start,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def shutdown(self) -> None:
|
|
202
|
+
"""Shuts down the sandbox.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
interface.SandboxStateError: If the sandbox is in a bad state.
|
|
206
|
+
"""
|
|
207
|
+
if not self._alive:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
self._alive = False
|
|
211
|
+
shutdown_start_time = time.time()
|
|
212
|
+
def shutdown_impl():
|
|
213
|
+
self._teardown_features()
|
|
214
|
+
self._shutdown()
|
|
215
|
+
if (self._housekeep_thread is not None
|
|
216
|
+
and threading.current_thread() is not self._housekeep_thread):
|
|
217
|
+
self._housekeep_thread.join()
|
|
218
|
+
self._housekeep_thread = None
|
|
219
|
+
pg.logging.info(
|
|
220
|
+
'[%s]: Sandbox shutdown in %.2f seconds. (lifetime: %.2f seconds)',
|
|
221
|
+
self.id,
|
|
222
|
+
time.time() - shutdown_start_time,
|
|
223
|
+
time.time() - self._start_time if self._start_time else 0
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
interface.call_with_event(
|
|
227
|
+
action=shutdown_impl,
|
|
228
|
+
event_handler=self.on_shutdown,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def ping(self) -> None:
|
|
232
|
+
"""Pings the sandbox to check if it is alive."""
|
|
233
|
+
interface.call_with_event(
|
|
234
|
+
action=self._ping,
|
|
235
|
+
event_handler=self.on_ping,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
#
|
|
239
|
+
# API related to a user session.
|
|
240
|
+
# A sandbox could be reused across different user sessions.
|
|
241
|
+
# A user session is a sequence of stateful interactions with the sandbox,
|
|
242
|
+
# Across different sessions the sandbox are considered stateless.
|
|
243
|
+
#
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def session_id(self) -> str | None:
|
|
247
|
+
"""Returns the current user session identifier.
|
|
248
|
+
|
|
249
|
+
session_id is set to None when the sandbox is free, and set to 'pending'
|
|
250
|
+
when the sandbox is acquired by a thread, before a user session is started.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The current user session identifier or None if the sandbox is not busy.
|
|
254
|
+
"""
|
|
255
|
+
return self._session_id
|
|
256
|
+
|
|
257
|
+
def set_pending(self) -> None:
|
|
258
|
+
"""Marks the sandbox as pending for new session."""
|
|
259
|
+
self._session_id = 'pending'
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def is_pending(self) -> bool:
|
|
263
|
+
"""Returns whether the sandbox is pending for new session."""
|
|
264
|
+
return self._session_id == 'pending'
|
|
265
|
+
|
|
266
|
+
def start_session(self, session_id: str) -> None:
|
|
267
|
+
"""Begins a user session with the sandbox.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
session_id: The identifier for the user session.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
interface.SandboxError: If the sandbox already has a user session
|
|
274
|
+
or the session cannot be started.
|
|
275
|
+
"""
|
|
276
|
+
assert self._session_id in (None, 'pending'), (
|
|
277
|
+
'A user session is already active for this sandbox.'
|
|
278
|
+
)
|
|
279
|
+
interface.call_with_event(
|
|
280
|
+
action=self._start_session,
|
|
281
|
+
event_handler=self.on_session_start,
|
|
282
|
+
action_kwargs={'session_id': session_id},
|
|
283
|
+
event_handler_kwargs={'session_id': session_id},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def end_session(self) -> None:
|
|
287
|
+
"""Ends the user session with the sandbox."""
|
|
288
|
+
assert self._session_id not in (None, 'pending'), (
|
|
289
|
+
'No user session is active for this sandbox'
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
interface.call_with_event(
|
|
293
|
+
action=self._end_session,
|
|
294
|
+
event_handler=self.on_session_end,
|
|
295
|
+
event_handler_kwargs={'session_id': self._session_id},
|
|
296
|
+
)
|
|
297
|
+
finally:
|
|
298
|
+
if not self.reusable:
|
|
299
|
+
self.shutdown()
|
|
300
|
+
|
|
301
|
+
#
|
|
302
|
+
# Housekeeping.
|
|
303
|
+
#
|
|
304
|
+
|
|
305
|
+
def _housekeep_loop(self) -> None:
|
|
306
|
+
"""Sandbox housekeeping loop."""
|
|
307
|
+
now = time.time()
|
|
308
|
+
last_ping = now
|
|
309
|
+
last_housekeep_time = {name: now for name in self._features.keys()}
|
|
310
|
+
|
|
311
|
+
while self._alive:
|
|
312
|
+
if self.keepalive_interval is not None:
|
|
313
|
+
if time.time() - last_ping > self.keepalive_interval:
|
|
314
|
+
try:
|
|
315
|
+
self.ping()
|
|
316
|
+
except interface.SandboxStateError as e:
|
|
317
|
+
pg.logging.error(
|
|
318
|
+
'[%s]: Shutting down sandbox because ping failed '
|
|
319
|
+
'with error: %s.',
|
|
320
|
+
self.id,
|
|
321
|
+
str(e)
|
|
322
|
+
)
|
|
323
|
+
self._housekeep_count += 1
|
|
324
|
+
self.shutdown()
|
|
325
|
+
break
|
|
326
|
+
last_ping = time.time()
|
|
327
|
+
|
|
328
|
+
for name, feature in self._features.items():
|
|
329
|
+
if feature.housekeep_interval is not None and (
|
|
330
|
+
time.time() - last_housekeep_time[name]
|
|
331
|
+
> feature.housekeep_interval
|
|
332
|
+
):
|
|
333
|
+
try:
|
|
334
|
+
feature.housekeep()
|
|
335
|
+
last_housekeep_time[name] = time.time()
|
|
336
|
+
except interface.SandboxStateError as e:
|
|
337
|
+
pg.logging.error(
|
|
338
|
+
'[%s/%s]: Feature housekeeping failed with error: %s. '
|
|
339
|
+
'Shutting down sandbox...',
|
|
340
|
+
self.id,
|
|
341
|
+
feature.name,
|
|
342
|
+
str(e)
|
|
343
|
+
)
|
|
344
|
+
self.shutdown()
|
|
345
|
+
break
|
|
346
|
+
self._housekeep_count += 1
|
|
347
|
+
time.sleep(1)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def sandbox_service(
|
|
351
|
+
critical_errors: Sequence[
|
|
352
|
+
Type[BaseException] | tuple[Type[BaseException], str]
|
|
353
|
+
] | None = None
|
|
354
|
+
) -> Callable[..., Any]:
|
|
355
|
+
"""Decorator for Sandbox/Feature methods exposed as sandbox services.
|
|
356
|
+
|
|
357
|
+
This decorator will catch errors and map to `SandboxStateError` if the
|
|
358
|
+
error matches any of the critical errors. Consequently, the sandbox will be
|
|
359
|
+
shutdown automatically when the error is raised.
|
|
360
|
+
|
|
361
|
+
if the decorated method returns a context manager, a wrapper context manager
|
|
362
|
+
will be returned, which will end the session when exiting the context.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
critical_errors: A sequence of exception types or tuples of exception type
|
|
366
|
+
and error messages (described in regular expression), when matched, treat
|
|
367
|
+
the sandbox as in a bad state, which will trigger a shutdown.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
The decorator function.
|
|
371
|
+
"""
|
|
372
|
+
critical_errors = critical_errors or []
|
|
373
|
+
|
|
374
|
+
def decorator(func):
|
|
375
|
+
signature = pg.typing.get_signature(func)
|
|
376
|
+
if 'session_id' in signature.arg_names:
|
|
377
|
+
raise ValueError(
|
|
378
|
+
'`session_id` should not be used as argument for sandbox '
|
|
379
|
+
'service method. Please use `self.session_id` instead.'
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
@functools.wraps(func)
|
|
383
|
+
def method_wrapper(self, *args, **kwargs) -> Any:
|
|
384
|
+
"""Helper function to safely execute logics in the sandbox."""
|
|
385
|
+
assert isinstance(self, (interface.Sandbox, interface.Feature)), self
|
|
386
|
+
sandbox = self.sandbox if isinstance(self, interface.Feature) else self
|
|
387
|
+
|
|
388
|
+
# When a capability is directly accessed from the environment,
|
|
389
|
+
# we scope the function call within a short-lived sandbox session. This
|
|
390
|
+
# prevents the sandbox from being reused for other feature calls.
|
|
391
|
+
if sandbox.is_pending:
|
|
392
|
+
new_session = True
|
|
393
|
+
session_id = kwargs.get('session_id', f'session-{uuid.uuid4().hex[:7]}')
|
|
394
|
+
else:
|
|
395
|
+
new_session = False
|
|
396
|
+
session_id = sandbox.session_id
|
|
397
|
+
|
|
398
|
+
kwargs.pop('session_id', None)
|
|
399
|
+
result = None
|
|
400
|
+
error = None
|
|
401
|
+
try:
|
|
402
|
+
# If it's a feature method called from the environment, start a new
|
|
403
|
+
# session for the feature call.
|
|
404
|
+
if new_session:
|
|
405
|
+
sandbox.start_session(session_id)
|
|
406
|
+
|
|
407
|
+
# Execute the service function.
|
|
408
|
+
result = func(self, *args, **kwargs)
|
|
409
|
+
|
|
410
|
+
# If the result is a context manager, use it and end the session
|
|
411
|
+
# afterwards.
|
|
412
|
+
if new_session and isinstance(
|
|
413
|
+
result, contextlib.AbstractContextManager
|
|
414
|
+
):
|
|
415
|
+
return _end_session_when_exit(result, sandbox)
|
|
416
|
+
|
|
417
|
+
# Otherwise, return the result and end the session in the finally block.
|
|
418
|
+
return result
|
|
419
|
+
except interface.SandboxStateError as e:
|
|
420
|
+
error = e
|
|
421
|
+
raise
|
|
422
|
+
except BaseException as e:
|
|
423
|
+
if pg.match_error(e, critical_errors):
|
|
424
|
+
error = e
|
|
425
|
+
raise interface.SandboxStateError(
|
|
426
|
+
'Sandbox encountered an unexpected error executing '
|
|
427
|
+
f'`{func.__name__}` (args={args!r}, kwargs={kwargs!r}): {e}',
|
|
428
|
+
sandbox=self
|
|
429
|
+
) from e
|
|
430
|
+
raise
|
|
431
|
+
finally:
|
|
432
|
+
if error is not None:
|
|
433
|
+
sandbox.shutdown()
|
|
434
|
+
|
|
435
|
+
# End the session if it's from a feature method and the result is not a
|
|
436
|
+
# context manager.
|
|
437
|
+
if (new_session
|
|
438
|
+
and not isinstance(result, contextlib.AbstractContextManager)):
|
|
439
|
+
sandbox.end_session()
|
|
440
|
+
|
|
441
|
+
self.on_session_activity(
|
|
442
|
+
session_id=session_id, error=error, args=args, **kwargs
|
|
443
|
+
)
|
|
444
|
+
return method_wrapper
|
|
445
|
+
return decorator
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@contextlib.contextmanager
|
|
449
|
+
def _end_session_when_exit(
|
|
450
|
+
service: contextlib.AbstractContextManager[Any],
|
|
451
|
+
sandbox: interface.Sandbox
|
|
452
|
+
) -> Iterator[Any]:
|
|
453
|
+
"""Context manager wrapper for ending a sandbox session when exiting."""
|
|
454
|
+
try:
|
|
455
|
+
with service as result:
|
|
456
|
+
yield result
|
|
457
|
+
finally:
|
|
458
|
+
sandbox.end_session()
|