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.

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