langfun 0.1.2.dev202509150805__py3-none-any.whl → 0.1.2.dev202509170804__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,843 @@
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
+ """Interfaces for environments, sandboxes and features."""
15
+
16
+ import abc
17
+ import contextlib
18
+ import dataclasses
19
+ import os
20
+ from typing import Annotated, Any, Callable, ContextManager, ClassVar, Iterator, Optional
21
+ import uuid
22
+
23
+ import pyglove as pg
24
+
25
+ #
26
+ # Environemnt identifiers.
27
+ #
28
+
29
+
30
+ @dataclasses.dataclass(frozen=True)
31
+ class EnvironmentId:
32
+ """Identifier for an environment."""
33
+ environment_id: str
34
+
35
+ def __str__(self) -> str:
36
+ return self.environment_id
37
+
38
+ def working_dir(self, root_dir: str | None) -> str | None:
39
+ """Returns the download directory for the service."""
40
+ if root_dir is None:
41
+ return None
42
+ return os.path.join(root_dir, _make_path_compatible(self.environment_id))
43
+
44
+ # Enable automatic conversion from str to EnvironmentId.
45
+ pg.typing.register_converter(str, EnvironmentId, EnvironmentId)
46
+
47
+
48
+ @dataclasses.dataclass(frozen=True)
49
+ class SandboxId:
50
+ """Identifier for a sandbox."""
51
+ environment_id: EnvironmentId
52
+ sandbox_id: str
53
+
54
+ def __str__(self) -> str:
55
+ return f'{self.environment_id}/{self.sandbox_id}'
56
+
57
+ def working_dir(self, root_dir: str | None) -> str | None:
58
+ """Returns the download directory for the sandbox."""
59
+ if root_dir is None:
60
+ return None
61
+ return os.path.join(
62
+ self.environment_id.working_dir(root_dir),
63
+ _make_path_compatible(self.sandbox_id)
64
+ )
65
+
66
+
67
+ def _make_path_compatible(id_str: str) -> str:
68
+ """Makes a path compatible with CNS."""
69
+ return id_str.translate(
70
+ str.maketrans({
71
+ '@': '_',
72
+ ':': '_',
73
+ '#': '_',
74
+ ' ': '',
75
+ })
76
+ )
77
+
78
+
79
+ #
80
+ # Environment errors.
81
+ #
82
+
83
+
84
+ class EnvironmentError(RuntimeError): # pylint: disable=redefined-builtin
85
+ """Base class for environment errors."""
86
+
87
+ def __init__(
88
+ self,
89
+ message: str,
90
+ *args,
91
+ environment: 'Environment',
92
+ **kwargs
93
+ ) -> None:
94
+ self.environment = environment
95
+ super().__init__(f'[{environment.id}] {message}.', *args, **kwargs)
96
+
97
+
98
+ class EnvironmentOutageError(EnvironmentError):
99
+ """Error that indicates environment is offline."""
100
+
101
+ def __init__(
102
+ self,
103
+ message: str | None = None,
104
+ *args,
105
+ offline_duration: float,
106
+ **kwargs
107
+ ):
108
+ self.offline_duration = offline_duration
109
+ super().__init__(
110
+ message or f'Environment is offline for {offline_duration} seconds.',
111
+ *args,
112
+ **kwargs
113
+ )
114
+
115
+
116
+ class EnvironmentOverloadError(EnvironmentError):
117
+ """Error that indicates environment is overloaded."""
118
+
119
+ def __init__(
120
+ self,
121
+ message: str | None = None,
122
+ *args,
123
+ **kwargs
124
+ ):
125
+ super().__init__(
126
+ message or 'All sandboxes in the pool are either busy or dead.',
127
+ *args, **kwargs
128
+ )
129
+
130
+
131
+ class SandboxError(RuntimeError):
132
+ """Base class for sandbox errors."""
133
+
134
+ def __init__(
135
+ self,
136
+ message: str,
137
+ *args,
138
+ sandbox: 'Sandbox',
139
+ **kwargs
140
+ ) -> None:
141
+ self.sandbox = sandbox
142
+ super().__init__(f'[{sandbox.id}] {message}.', *args, **kwargs)
143
+
144
+
145
+ class SandboxStateError(SandboxError):
146
+ """Error that indicates sandbox is in an unexpected state.
147
+
148
+ This error is raised when the sandbox is in an unexpected state and cannot
149
+ be recovered. As a result, the sandbox will be shutdown and user session
150
+ will be terminated.
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ message: str | None = None,
156
+ *args,
157
+ code: str | None = None,
158
+ **kwargs
159
+ ):
160
+ default_message = 'Sandbox is in an unexpected state'
161
+ if code is not None:
162
+ default_message = (
163
+ f'Sandbox is in an unexpected state after executing code: {code!r}'
164
+ )
165
+ super().__init__(message or default_message, *args, **kwargs)
166
+
167
+
168
+ #
169
+ # Event handler.
170
+ #
171
+
172
+
173
+ class EnvironmentEventHandler:
174
+ """Base class for environment event handlers."""
175
+
176
+ def on_environment_start(
177
+ self,
178
+ environment: 'Environment',
179
+ error: Exception | None
180
+ ) -> None:
181
+ """Called when the environment is started."""
182
+
183
+ def on_environment_shutdown(
184
+ self,
185
+ environment: 'Environment',
186
+ error: Exception | None
187
+ ) -> None:
188
+ """Called when the environment is shutdown."""
189
+
190
+ def on_sandbox_start(
191
+ self,
192
+ environment: 'Environment',
193
+ sandbox: 'Sandbox',
194
+ error: Exception | None
195
+ ) -> None:
196
+ """Called when a sandbox is started."""
197
+
198
+ def on_sandbox_shutdown(
199
+ self,
200
+ environment: 'Environment',
201
+ sandbox: 'Sandbox',
202
+ error: Exception | None
203
+ ) -> None:
204
+ """Called when a sandbox is shutdown."""
205
+
206
+ def on_sandbox_feature_setup(
207
+ self,
208
+ environment: 'Environment',
209
+ sandbox: 'Sandbox',
210
+ feature: 'Feature',
211
+ error: Exception | None
212
+ ) -> None:
213
+ """Called when a sandbox feature is setup."""
214
+
215
+ def on_sandbox_feature_teardown(
216
+ self,
217
+ environment: 'Environment',
218
+ sandbox: 'Sandbox',
219
+ feature: 'Feature',
220
+ error: Exception | None
221
+ ) -> None:
222
+ """Called when a sandbox feature is teardown."""
223
+
224
+ def on_sandbox_feature_housekeep(
225
+ self,
226
+ environment: 'Environment',
227
+ sandbox: 'Sandbox',
228
+ feature: 'Feature',
229
+ error: Exception | None
230
+ ) -> None:
231
+ """Called when a sandbox feature is housekeeping."""
232
+
233
+ def on_sandbox_session_start(
234
+ self,
235
+ environment: 'Environment',
236
+ sandbox: 'Sandbox',
237
+ session_id: str,
238
+ error: Exception | None
239
+ ) -> None:
240
+ """Called when a sandbox session starts."""
241
+
242
+ def on_sandbox_session_activity(
243
+ self,
244
+ environment: 'Environment',
245
+ sandbox: 'Sandbox',
246
+ session_id: str,
247
+ feature: Optional['Feature'],
248
+ error: Exception | None,
249
+ **kwargs
250
+ ) -> None:
251
+ """Called when a sandbox activity is performed."""
252
+
253
+ def on_sandbox_session_end(
254
+ self,
255
+ environment: 'Environment',
256
+ sandbox: 'Sandbox',
257
+ session_id: str,
258
+ error: Exception | None
259
+ ) -> None:
260
+ """Called when a sandbox session ends."""
261
+
262
+ def on_sandbox_ping(
263
+ self,
264
+ environment: 'Environment',
265
+ sandbox: 'Sandbox',
266
+ error: Exception | None
267
+ ) -> None:
268
+ """Called when a sandbox is pinged."""
269
+
270
+
271
+ #
272
+ # Interface for sandbox-based environment.
273
+ #
274
+
275
+
276
+ class Environment(pg.Object):
277
+ """Base class for an environment."""
278
+
279
+ features: Annotated[
280
+ dict[str, 'Feature'],
281
+ 'Features to be exposed by the environment.'
282
+ ] = {}
283
+
284
+ event_handler: Annotated[
285
+ EnvironmentEventHandler | None,
286
+ (
287
+ 'User handler for the environment events.'
288
+ )
289
+ ] = None
290
+
291
+ _ENV_STACK: Annotated[
292
+ ClassVar[list['Environment']],
293
+ 'Recording the environments stacked through context managers.'
294
+ ] = []
295
+
296
+ #
297
+ # Subclasses must implement:
298
+ #
299
+
300
+ @property
301
+ @abc.abstractmethod
302
+ def id(self) -> EnvironmentId:
303
+ """Returns the identifier for the environment."""
304
+
305
+ @abc.abstractmethod
306
+ def stats(self) -> dict[str, Any]:
307
+ """Returns the stats of the environment."""
308
+
309
+ @property
310
+ @abc.abstractmethod
311
+ def is_alive(self) -> bool:
312
+ """Returns True if the environment is alive."""
313
+
314
+ @abc.abstractmethod
315
+ def start(self) -> None:
316
+ """Starts the environment.
317
+
318
+ Raises:
319
+ EnvironmentError: If the environment is not available.
320
+ """
321
+
322
+ @abc.abstractmethod
323
+ def shutdown(self) -> None:
324
+ """Shuts down the environment.
325
+
326
+ Raises:
327
+ EnvironmentError: If the environment is not available.
328
+ """
329
+
330
+ @abc.abstractmethod
331
+ def acquire(self) -> 'Sandbox':
332
+ """Acquires a free sandbox from the environment.
333
+
334
+ Returns:
335
+ A free sandbox from the environment.
336
+
337
+ Raises:
338
+ EnvironmentOutageError: If the environment is out of service.
339
+ EnvironmentOverloadError: If the environment is overloaded.
340
+ """
341
+
342
+ #
343
+ # Environment lifecycle.
344
+ #
345
+
346
+ def __enter__(self) -> 'Environment':
347
+ """Enters the environment and sets it as the current environment."""
348
+ self.start()
349
+ Environment._ENV_STACK.append(self)
350
+ return self
351
+
352
+ def __exit__(self, exc_type, exc_value, traceback):
353
+ """Exits the environment and reset the current environment."""
354
+ assert Environment._ENV_STACK
355
+ Environment._ENV_STACK.pop()
356
+ self.shutdown()
357
+
358
+ @classmethod
359
+ def current(cls) -> Optional['Environment']:
360
+ """Returns the current environment."""
361
+ if not Environment._ENV_STACK:
362
+ return None
363
+ return Environment._ENV_STACK[-1]
364
+
365
+ #
366
+ # Environment operations.
367
+ #
368
+
369
+ def sandbox(
370
+ self,
371
+ session_id: str | None = None,
372
+ ) -> ContextManager['Sandbox']:
373
+ """Gets a sandbox from the environment and starts a new user session."""
374
+ return self.acquire().new_session(session_id)
375
+
376
+ def __getattr__(self, name: str) -> Any:
377
+ """Gets a feature from a free sandbox from the environment.
378
+
379
+ Example:
380
+ ```
381
+ with XboxEnvironment(
382
+ features={'selenium': SeleniumFeature()}
383
+ ) as env:
384
+ driver = env.selenium.get_driver()
385
+ ```
386
+
387
+ Args:
388
+ name: The name of the feature.
389
+
390
+ Returns:
391
+ A feature from a free sandbox from the environment.
392
+ """
393
+ if name in self.features:
394
+ return self.acquire().features[name]
395
+ raise AttributeError(name)
396
+
397
+ #
398
+ # Event handlers subclasses can override.
399
+ #
400
+
401
+ def on_start(self, error: Exception | None = None) -> None:
402
+ """Called when the environment is started."""
403
+ if self.event_handler:
404
+ self.event_handler.on_environment_start(self, error)
405
+
406
+ def on_shutdown(self, error: Exception | None = None) -> None:
407
+ """Called when the environment is shutdown."""
408
+ if self.event_handler:
409
+ self.event_handler.on_environment_shutdown(self, error)
410
+
411
+ def on_sandbox_start(
412
+ self,
413
+ sandbox: 'Sandbox',
414
+ error: Exception | None = None
415
+ ) -> None:
416
+ """Called when a sandbox is started."""
417
+ if self.event_handler:
418
+ self.event_handler.on_sandbox_start(self, sandbox, error)
419
+
420
+ def on_sandbox_shutdown(
421
+ self,
422
+ sandbox: 'Sandbox',
423
+ error: Exception | None = None
424
+ ) -> None:
425
+ """Called when a sandbox is shutdown."""
426
+ if self.event_handler:
427
+ self.event_handler.on_sandbox_shutdown(self, sandbox, error)
428
+
429
+ def on_sandbox_feature_setup(
430
+ self,
431
+ sandbox: 'Sandbox',
432
+ feature: 'Feature',
433
+ error: Exception | None = None
434
+ ) -> None:
435
+ """Called when a sandbox feature is setup."""
436
+ if self.event_handler:
437
+ self.event_handler.on_sandbox_feature_setup(self, sandbox, feature, error)
438
+
439
+ def on_sandbox_feature_teardown(
440
+ self,
441
+ sandbox: 'Sandbox',
442
+ feature: 'Feature',
443
+ error: Exception | None = None
444
+ ) -> None:
445
+ """Called when a sandbox feature is teardown."""
446
+ if self.event_handler:
447
+ self.event_handler.on_sandbox_feature_teardown(
448
+ self, sandbox, feature, error
449
+ )
450
+
451
+ def on_sandbox_feature_housekeep(
452
+ self,
453
+ sandbox: 'Sandbox',
454
+ feature: 'Feature',
455
+ error: Exception | None = None
456
+ ) -> None:
457
+ """Called when a sandbox feature is housekeeping."""
458
+ if self.event_handler:
459
+ self.event_handler.on_sandbox_feature_housekeep(
460
+ self, sandbox, feature, error
461
+ )
462
+
463
+ def on_sandbox_session_start(
464
+ self,
465
+ sandbox: 'Sandbox',
466
+ session_id: str,
467
+ error: Exception | None = None
468
+ ) -> None:
469
+ """Called when a sandbox session starts."""
470
+ if self.event_handler:
471
+ self.event_handler.on_sandbox_session_start(
472
+ self, sandbox, session_id, error
473
+ )
474
+
475
+ def on_sandbox_session_activity(
476
+ self,
477
+ sandbox: 'Sandbox',
478
+ session_id: str,
479
+ feature: Optional['Feature'] = None,
480
+ error: Exception | None = None,
481
+ **kwargs
482
+ ) -> None:
483
+ """Called when a sandbox activity is performed."""
484
+ if self.event_handler:
485
+ self.event_handler.on_sandbox_session_activity(
486
+ self, sandbox, feature, session_id, error, **kwargs
487
+ )
488
+
489
+ def on_sandbox_session_end(
490
+ self,
491
+ sandbox: 'Sandbox',
492
+ session_id: str,
493
+ error: Exception | None = None
494
+ ) -> None:
495
+ """Called when a sandbox session ends."""
496
+ if self.event_handler:
497
+ self.event_handler.on_sandbox_session_end(
498
+ self, sandbox, session_id, error
499
+ )
500
+
501
+ def on_sandbox_ping(
502
+ self,
503
+ sandbox: 'Sandbox',
504
+ error: Exception | None = None
505
+ ) -> None:
506
+ """Called when a sandbox is pinged."""
507
+ if self.event_handler:
508
+ self.event_handler.on_sandbox_ping(self, sandbox, error)
509
+
510
+
511
+ class Sandbox(pg.Object):
512
+ """Interface for sandboxes."""
513
+
514
+ @property
515
+ @abc.abstractmethod
516
+ def id(self) -> SandboxId:
517
+ """Returns the identifier for the sandbox."""
518
+
519
+ @property
520
+ @abc.abstractmethod
521
+ def environment(self) -> Environment:
522
+ """Returns the environment for the sandbox."""
523
+
524
+ @property
525
+ @abc.abstractmethod
526
+ def features(self) -> dict[str, 'Feature']:
527
+ """Returns the features in the sandbox."""
528
+
529
+ @property
530
+ @abc.abstractmethod
531
+ def is_alive(self) -> bool:
532
+ """Returns True if the sandbox is alive."""
533
+
534
+ @property
535
+ @abc.abstractmethod
536
+ def is_busy(self) -> bool:
537
+ """Returns whether the sandbox is busy."""
538
+
539
+ @abc.abstractmethod
540
+ def set_pending(self) -> None:
541
+ """Marks the sandbox pending after acquisition but before ready for use."""
542
+
543
+ @property
544
+ @abc.abstractmethod
545
+ def is_pending(self) -> bool:
546
+ """Returns whether the sandbox is pending."""
547
+
548
+ @abc.abstractmethod
549
+ def start(self) -> None:
550
+ """Starts the sandbox.
551
+
552
+ Raises:
553
+ SandboxStateError: If the sandbox is in a bad state.
554
+ """
555
+
556
+ @abc.abstractmethod
557
+ def shutdown(self) -> None:
558
+ """Shuts down the sandbox.
559
+
560
+ Raises:
561
+ SandboxStateError: If the sandbox is in a bad state.
562
+ """
563
+
564
+ @abc.abstractmethod
565
+ def ping(self) -> None:
566
+ """Ping the sandbox to check if it is alive.
567
+
568
+ Raises:
569
+ SandboxStateError: If the sandbox is in a bad state.
570
+ """
571
+
572
+ @abc.abstractmethod
573
+ def start_session(self, session_id: str) -> None:
574
+ """Begins a user session with the sandbox.
575
+
576
+ Args:
577
+ session_id: The identifier for the user session.
578
+
579
+ Raises:
580
+ SandboxError: If the sandbox already has a user session
581
+ or the session cannot be started.
582
+ """
583
+
584
+ @abc.abstractmethod
585
+ def end_session(self) -> None:
586
+ """Ends the user session with the sandbox."""
587
+
588
+ @property
589
+ @abc.abstractmethod
590
+ def session_id(self) -> str | None:
591
+ """Returns the current user session identifier."""
592
+
593
+ #
594
+ # API related to a user session.
595
+ # A sandbox could be reused across different user sessions.
596
+ # A user session is a sequence of stateful interactions with the sandbox,
597
+ # Across different sessions the sandbox are considered stateless.
598
+ #
599
+
600
+ @contextlib.contextmanager
601
+ def new_session(self, session_id: str | None = None) -> Iterator['Sandbox']:
602
+ """Context manager for obtaining a sandbox for a user session."""
603
+ if session_id is None:
604
+ session_id = f'session-{uuid.uuid4().hex[:7]}'
605
+ self.start_session(session_id)
606
+ try:
607
+ yield self
608
+ finally:
609
+ self.end_session()
610
+
611
+ def __getattr__(self, name: str) -> Any:
612
+ """Gets a feature from current sandbox.
613
+
614
+ Example:
615
+ ```
616
+ with MyEnvironment(
617
+ features={'feature1': Feature1()}
618
+ ) as env:
619
+ with env.sandbox('session1') as sb:
620
+ driver = sb.feature1.feature_method()
621
+ ```
622
+
623
+ Args:
624
+ name: The name of the feature.
625
+
626
+ Returns:
627
+ A feature from current sandbox.
628
+ """
629
+ if name in self.features:
630
+ return self.features[name]
631
+ raise AttributeError(name)
632
+
633
+ #
634
+ # Event handlers subclasses can override.
635
+ #
636
+
637
+ def on_start(self, error: Exception | None = None) -> None:
638
+ """Called when the sandbox is started."""
639
+ self.environment.on_sandbox_start(self, error)
640
+
641
+ def on_shutdown(self, error: Exception | None = None) -> None:
642
+ """Called when the sandbox is shutdown."""
643
+ self.environment.on_sandbox_shutdown(self, error)
644
+
645
+ def on_ping(self, error: Exception | None = None) -> None:
646
+ """Called when the sandbox is pinged."""
647
+ self.environment.on_sandbox_ping(self, error)
648
+
649
+ def on_feature_setup(
650
+ self,
651
+ feature: 'Feature',
652
+ error: Exception | None = None
653
+ ) -> None:
654
+ """Called when a feature is setup."""
655
+ self.environment.on_sandbox_feature_setup(
656
+ self, feature, error
657
+ )
658
+
659
+ def on_feature_teardown(
660
+ self,
661
+ feature: 'Feature',
662
+ error: Exception | None = None
663
+ ) -> None:
664
+ """Called when a feature is teardown."""
665
+ self.environment.on_sandbox_feature_teardown(
666
+ self, feature, error
667
+ )
668
+
669
+ def on_feature_housekeep(
670
+ self,
671
+ feature: 'Feature',
672
+ error: Exception | None = None
673
+ ) -> None:
674
+ """Called when a feature is housekeeping."""
675
+ self.environment.on_sandbox_feature_housekeep(
676
+ self, feature, error
677
+ )
678
+
679
+ def on_session_start(
680
+ self,
681
+ session_id: str,
682
+ error: Exception | None = None
683
+ ) -> None:
684
+ """Called when the user session starts."""
685
+ self.environment.on_sandbox_session_start(self, session_id, error)
686
+
687
+ def on_session_activity(
688
+ self,
689
+ session_id: str,
690
+ feature: Optional['Feature'] = None,
691
+ error: Exception | None = None,
692
+ **kwargs
693
+ ) -> None:
694
+ """Called when a sandbox activity is performed."""
695
+ self.environment.on_sandbox_session_activity(
696
+ sandbox=self,
697
+ feature=feature,
698
+ session_id=session_id,
699
+ error=error,
700
+ **kwargs
701
+ )
702
+
703
+ def on_session_end(
704
+ self,
705
+ session_id: str,
706
+ error: Exception | None = None
707
+ ) -> None:
708
+ """Called when the user session ends."""
709
+ self.environment.on_sandbox_session_end(self, session_id, error)
710
+
711
+
712
+ class Feature(pg.Object):
713
+ """Interface for sandbox features."""
714
+
715
+ @property
716
+ @abc.abstractmethod
717
+ def name(self) -> str:
718
+ """Name of the feature, which will be used as key to access the feature."""
719
+
720
+ @property
721
+ @abc.abstractmethod
722
+ def sandbox(self) -> Sandbox | None:
723
+ """Returns the sandbox that the feature is running in."""
724
+
725
+ @abc.abstractmethod
726
+ def setup(self, sandbox: Sandbox) -> None:
727
+ """Sets up the feature, which is called once when the sandbox is up.
728
+
729
+ Args:
730
+ sandbox: The sandbox that the feature is running in.
731
+
732
+ Raises:
733
+ SandboxStateError: If the sandbox is in a bad state.
734
+ """
735
+
736
+ @abc.abstractmethod
737
+ def teardown(self) -> None:
738
+ """Teardowns the feature, which is called once when the sandbox is down.
739
+
740
+ Raises:
741
+ SandboxStateError: If the sandbox is in a bad state.
742
+ """
743
+
744
+ @abc.abstractmethod
745
+ def setup_session(self, session_id: str) -> None:
746
+ """Sets up the feature for a user session."""
747
+
748
+ @abc.abstractmethod
749
+ def teardown_session(self, session_id: str) -> None:
750
+ """Teardowns the feature for a user session."""
751
+
752
+ @abc.abstractmethod
753
+ def housekeep(self) -> None:
754
+ """Performs housekeeping for the feature.
755
+
756
+ Raises:
757
+ SandboxStateError: If the sandbox is in a bad state.
758
+ """
759
+
760
+ @property
761
+ @abc.abstractmethod
762
+ def housekeep_interval(self) -> int | None:
763
+ """Returns the interval in seconds for feature housekeeping.
764
+
765
+ Returns:
766
+ The interval in seconds for feature housekeeping. If None, the feature
767
+ will not be housekeeping.
768
+ """
769
+
770
+ @property
771
+ def session_id(self) -> str | None:
772
+ """Returns the current user session identifier."""
773
+ assert self.sandbox is not None
774
+ return self.sandbox.session_id
775
+
776
+ #
777
+ # Event handlers subclasses can override.
778
+ #
779
+
780
+ def on_setup(
781
+ self,
782
+ error: BaseException | None = None
783
+ ) -> None:
784
+ """Called when the feature is setup."""
785
+ self.sandbox.on_feature_setup(self, error)
786
+
787
+ def on_teardown(
788
+ self,
789
+ error: BaseException | None = None
790
+ ) -> None:
791
+ """Called when the feature is teardown."""
792
+ self.sandbox.on_feature_teardown(self, error)
793
+
794
+ def on_housekeep(
795
+ self,
796
+ error: BaseException | None = None
797
+ ) -> None:
798
+ """Called when the feature has done housekeeping."""
799
+ self.sandbox.on_feature_housekeep(self, error)
800
+
801
+ def on_session_setup(
802
+ self,
803
+ session_id: str,
804
+ error: BaseException | None = None,
805
+ ) -> None:
806
+ """Called when the feature is setup for a user session."""
807
+
808
+ def on_session_activity(
809
+ self,
810
+ session_id: str,
811
+ error: Exception | None,
812
+ **kwargs
813
+ ) -> None:
814
+ """Called when a sandbox activity is performed."""
815
+ self.sandbox.on_session_activity(
816
+ feature=self, session_id=session_id, error=error, **kwargs
817
+ )
818
+
819
+ def on_session_teardown(
820
+ self,
821
+ session_id: str,
822
+ error: BaseException | None = None,
823
+ ) -> None:
824
+ """Called when the feature is teardown for a user session."""
825
+
826
+
827
+ def call_with_event(
828
+ action: Callable[[], None],
829
+ event_handler: Callable[..., None],
830
+ action_kwargs: dict[str, Any] | None = None,
831
+ event_handler_kwargs: dict[str, Any] | None = None,
832
+ ) -> None:
833
+ """Triggers an event handler."""
834
+ action_kwargs = action_kwargs or {}
835
+ event_handler_kwargs = event_handler_kwargs or {}
836
+ error = None
837
+ try:
838
+ action(**action_kwargs)
839
+ except BaseException as e: # pylint: disable=broad-except
840
+ error = e
841
+ raise
842
+ finally:
843
+ event_handler(error=error, **event_handler_kwargs)