langfun 0.1.2.dev202510310805__py3-none-any.whl → 0.1.2.dev202511020804__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,473 @@
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
+ import time
15
+ import unittest
16
+
17
+ from langfun.env import interface
18
+ from langfun.env import test_utils
19
+
20
+ TestingEnvironment = test_utils.TestingEnvironment
21
+ TestingSandbox = test_utils.TestingSandbox
22
+ TestingFeature = test_utils.TestingFeature
23
+ TestingEventHandler = test_utils.TestingEventHandler
24
+
25
+
26
+ class BaseEnvironmentTests(unittest.TestCase):
27
+
28
+ def test_basics(self):
29
+ env = TestingEnvironment(
30
+ image_ids=['test_image'],
31
+ root_dir='/tmp',
32
+ pool_size=0,
33
+ features={'test_feature': TestingFeature()},
34
+ outage_grace_period=1,
35
+ outage_retry_interval=0,
36
+ )
37
+ self.assertIsNone(interface.Environment.current())
38
+ self.assertEqual(env.image_ids, ['test_image'])
39
+ self.assertFalse(env.supports_dynamic_image_loading)
40
+ self.assertEqual(env.status, interface.Environment.Status.CREATED)
41
+ self.assertFalse(env.is_online)
42
+ self.assertEqual(env.min_pool_size('test_image'), 0)
43
+ self.assertEqual(env.max_pool_size('test_image'), 0)
44
+ self.assertEqual(env.sandbox_pool, {})
45
+ self.assertEqual(env.id, interface.Environment.Id('testing-env'))
46
+ self.assertEqual(env.outage_grace_period, 1)
47
+ self.assertEqual(env.features['test_feature'].name, 'test_feature')
48
+
49
+ self.assertIsNone(env.start_time)
50
+
51
+ with env:
52
+ self.assertEqual(env.status, interface.Environment.Status.ONLINE)
53
+ self.assertIs(interface.Environment.current(), env)
54
+ self.assertTrue(env.is_online)
55
+ self.assertIsNotNone(env.start_time)
56
+ self.assertEqual(env.offline_duration, 0.0)
57
+ self.assertEqual(env.sandbox_pool, {})
58
+ self.assertEqual(env.working_dir, '/tmp/testing-env')
59
+
60
+ with env.sandbox('session1') as sb:
61
+ self.assertEqual(
62
+ sb.id, interface.Sandbox.Id(
63
+ environment_id=env.id,
64
+ image_id=sb.image_id,
65
+ sandbox_id='0')
66
+ )
67
+ self.assertEqual(sb.session_id, 'session1')
68
+ self.assertEqual(sb.working_dir, '/tmp/testing-env/test_image/0')
69
+ self.assertTrue(sb.is_online)
70
+ self.assertIs(sb.test_feature, sb.features['test_feature'])
71
+ self.assertEqual(
72
+ sb.test_feature.working_dir,
73
+ '/tmp/testing-env/test_image/0/test_feature'
74
+ )
75
+ with self.assertRaises(AttributeError):
76
+ _ = sb.test_feature2
77
+ self.assertFalse(sb.is_online)
78
+
79
+ with self.assertRaisesRegex(
80
+ ValueError, 'Environment .* does not serve image ID .*'
81
+ ):
82
+ env.sandbox(image_id='test_image2')
83
+
84
+ with env.test_feature() as feature:
85
+ self.assertIsInstance(feature, TestingFeature)
86
+ self.assertEqual(
87
+ feature.sandbox.status, interface.Sandbox.Status.IN_SESSION
88
+ )
89
+ self.assertTrue(
90
+ feature.sandbox.session_id.startswith('test_feature-session')
91
+ )
92
+
93
+ with self.assertRaises(AttributeError):
94
+ _ = env.test_feature2
95
+
96
+ def test_dynamic_image_loading(self):
97
+ env = TestingEnvironment(
98
+ image_ids=[],
99
+ supports_dynamic_image_loading=True,
100
+ pool_size=0,
101
+ features={'test_feature': TestingFeature()},
102
+ outage_grace_period=1,
103
+ outage_retry_interval=0,
104
+ )
105
+ with env:
106
+ with env.sandbox(image_id='test_image2') as sb:
107
+ self.assertEqual(sb.image_id, 'test_image2')
108
+
109
+ with self.assertRaisesRegex(
110
+ ValueError, 'Environment .* does not have a default image ID.'
111
+ ):
112
+ env.sandbox()
113
+
114
+ def test_dynamic_image_loading_with_pooling(self):
115
+ env = TestingEnvironment(
116
+ image_ids=[],
117
+ supports_dynamic_image_loading=True,
118
+ pool_size=2,
119
+ features={'test_feature': TestingFeature()},
120
+ outage_grace_period=1,
121
+ outage_retry_interval=0,
122
+ )
123
+ with env:
124
+ with env.sandbox(image_id='test_image'):
125
+ self.assertEqual(len(env.sandbox_pool['test_image']), 1)
126
+
127
+ with env.sandbox(image_id='test_image'):
128
+ self.assertEqual(len(env.sandbox_pool['test_image']), 2)
129
+
130
+ with self.assertRaises(interface.EnvironmentOverloadError):
131
+ with env.sandbox(image_id='test_image'):
132
+ pass
133
+ self.assertEqual(len(env.sandbox_pool['test_image']), 2)
134
+
135
+ with env.sandbox(image_id='test_image'):
136
+ self.assertEqual(len(env.sandbox_pool['test_image']), 2)
137
+
138
+ def test_image_feature_mappings(self):
139
+ env = TestingEnvironment(
140
+ image_ids=[
141
+ 'test_image1',
142
+ 'test_image2',
143
+ ],
144
+ features={
145
+ 'test_feature': TestingFeature(
146
+ applicable_images=['test_image1.*']
147
+ ),
148
+ 'test_feature2': TestingFeature(
149
+ applicable_images=['test_image2.*']
150
+ ),
151
+ 'test_feature3': TestingFeature(
152
+ applicable_images=['test_image.*']
153
+ ),
154
+ },
155
+ pool_size=0,
156
+ outage_grace_period=1,
157
+ outage_retry_interval=0,
158
+ sandbox_keepalive_interval=0,
159
+ )
160
+ with env:
161
+ with env.sandbox(image_id='test_image1') as sb:
162
+ self.assertIn('test_feature', sb.features)
163
+ self.assertNotIn('test_feature2', sb.features)
164
+ self.assertIn('test_feature3', sb.features)
165
+
166
+ with env.sandbox(image_id='test_image2') as sb:
167
+ self.assertNotIn('test_feature', sb.features)
168
+ self.assertIn('test_feature2', sb.features)
169
+ self.assertIn('test_feature3', sb.features)
170
+
171
+ with env.test_feature() as feature:
172
+ self.assertEqual(feature.sandbox.image_id, 'test_image1')
173
+
174
+ with self.assertRaisesRegex(
175
+ ValueError, 'Feature .* is not applicable to .*'
176
+ ):
177
+ with env.test_feature(image_id='test_image2'):
178
+ pass
179
+
180
+ with env.test_feature2() as feature:
181
+ self.assertEqual(feature.sandbox.image_id, 'test_image2')
182
+
183
+ with env.test_feature3() as feature:
184
+ self.assertEqual(feature.sandbox.image_id, 'test_image1')
185
+
186
+ with env.test_feature3(image_id='test_image2') as feature:
187
+ self.assertEqual(feature.sandbox.image_id, 'test_image2')
188
+
189
+ def test_feature_applicability_check(self):
190
+ with self.assertRaisesRegex(
191
+ ValueError, 'Feature .* is not applicable to .*'
192
+ ):
193
+ TestingEnvironment(
194
+ image_ids=[
195
+ 'test_image1',
196
+ ],
197
+ features={
198
+ 'test_feature2': TestingFeature(
199
+ applicable_images=['test_image2.*']
200
+ ),
201
+ },
202
+ )
203
+ env = TestingEnvironment(
204
+ image_ids=[],
205
+ supports_dynamic_image_loading=True,
206
+ features={
207
+ 'test_feature2': TestingFeature(
208
+ applicable_images=['test_image2.*']
209
+ ),
210
+ },
211
+ pool_size=0
212
+ )
213
+ with env:
214
+ with self.assertRaisesRegex(
215
+ ValueError, 'No image ID found for feature .*'
216
+ ):
217
+ with env.test_feature2():
218
+ pass
219
+
220
+ # Dynamically loaded IDs.
221
+ with env.test_feature2(image_id='test_image2') as feature:
222
+ self.assertEqual(feature.sandbox.image_id, 'test_image2')
223
+
224
+ def test_pool_size(self):
225
+ env = TestingEnvironment(
226
+ image_ids=['test_image'],
227
+ pool_size=1,
228
+ outage_grace_period=1,
229
+ outage_retry_interval=0,
230
+ )
231
+ self.assertEqual(env.min_pool_size('test_image'), 1)
232
+ self.assertEqual(env.max_pool_size('test_image'), 1)
233
+
234
+ env = TestingEnvironment(
235
+ image_ids=['test_image'],
236
+ pool_size=(0, 256),
237
+ outage_grace_period=1,
238
+ outage_retry_interval=0,
239
+ )
240
+ self.assertEqual(env.min_pool_size('test_image'), 0)
241
+ self.assertEqual(env.max_pool_size('test_image'), 256)
242
+
243
+ env = TestingEnvironment(
244
+ image_ids=['test_image'],
245
+ pool_size={
246
+ 'test_.*': (0, 128),
247
+ 'my.*': (5, 64),
248
+ 'exact_image_name': 10,
249
+ },
250
+ outage_grace_period=1,
251
+ outage_retry_interval=0,
252
+ )
253
+ self.assertEqual(env.min_pool_size('test_image'), 0)
254
+ self.assertEqual(env.max_pool_size('test_image'), 128)
255
+ self.assertEqual(env.min_pool_size('my_image'), 5)
256
+ self.assertEqual(env.max_pool_size('my_image'), 64)
257
+ self.assertEqual(env.min_pool_size('exact_image_name'), 10)
258
+ self.assertEqual(env.max_pool_size('exact_image_name'), 10)
259
+ self.assertEqual(env.min_pool_size('some_image'), 0) # default
260
+ self.assertEqual(env.max_pool_size('some_image'), 256) # default
261
+
262
+ def test_del(self):
263
+ env = TestingEnvironment(
264
+ features={'test_feature': TestingFeature()},
265
+ pool_size=0,
266
+ outage_grace_period=1,
267
+ outage_retry_interval=0,
268
+ sandbox_keepalive_interval=0,
269
+ )
270
+ env.start()
271
+ sb = env.acquire()
272
+ del sb
273
+ del env
274
+
275
+ def test_acquire_env_offline(self):
276
+ env = TestingEnvironment(
277
+ features={'test_feature': TestingFeature()},
278
+ pool_size=0,
279
+ outage_grace_period=1,
280
+ outage_retry_interval=0,
281
+ sandbox_keepalive_interval=0,
282
+ )
283
+ with self.assertRaises(interface.EnvironmentOutageError):
284
+ env.acquire()
285
+
286
+ def test_acquire_no_pooling(self):
287
+ env = TestingEnvironment(
288
+ features={'test_feature': TestingFeature()},
289
+ pool_size=0,
290
+ outage_grace_period=1,
291
+ outage_retry_interval=0,
292
+ sandbox_keepalive_interval=0,
293
+ )
294
+ with env:
295
+ sb = env.acquire()
296
+ self.assertEqual(sb.status, interface.Sandbox.Status.ACQUIRED)
297
+ self.assertIsNone(env.working_dir)
298
+ self.assertIsNone(sb.working_dir)
299
+ self.assertIsNone(sb.test_feature.working_dir)
300
+
301
+ def test_acquire_no_pooling_with_error(self):
302
+ env = TestingEnvironment(
303
+ features={
304
+ 'test_feature': TestingFeature(
305
+ simulate_setup_error=interface.SandboxStateError
306
+ )
307
+ },
308
+ pool_size=0,
309
+ outage_grace_period=1,
310
+ outage_retry_interval=0,
311
+ sandbox_keepalive_interval=0,
312
+ )
313
+ with env:
314
+ with self.assertRaises(interface.EnvironmentOutageError):
315
+ env.acquire()
316
+
317
+ def test_acquire_with_pooling(self):
318
+ env = TestingEnvironment(
319
+ features={'test_feature': TestingFeature()},
320
+ pool_size=1,
321
+ outage_grace_period=1,
322
+ outage_retry_interval=0,
323
+ sandbox_keepalive_interval=0,
324
+ )
325
+ with env:
326
+ sb = env.acquire()
327
+ self.assertEqual(sb.status, interface.Sandbox.Status.ACQUIRED)
328
+
329
+ def test_acquire_with_pooling_overload(self):
330
+ env = TestingEnvironment(
331
+ features={'test_feature': TestingFeature()},
332
+ pool_size=1,
333
+ outage_grace_period=1,
334
+ outage_retry_interval=0,
335
+ sandbox_keepalive_interval=0,
336
+ )
337
+ with env:
338
+ sb = env.acquire()
339
+ self.assertEqual(sb.status, interface.Sandbox.Status.ACQUIRED)
340
+ with self.assertRaises(interface.EnvironmentOverloadError):
341
+ env.acquire()
342
+
343
+ def test_acquire_with_growing_pool(self):
344
+ env = TestingEnvironment(
345
+ features={'test_feature': TestingFeature()},
346
+ pool_size=(1, 3),
347
+ outage_grace_period=1,
348
+ outage_retry_interval=0,
349
+ sandbox_keepalive_interval=0,
350
+ )
351
+ with env:
352
+ self.assertEqual(len(env.sandbox_pool['test_image']), 1)
353
+ self.assertEqual(
354
+ env.stats(),
355
+ {
356
+ 'sandbox': {
357
+ 'test_image': {
358
+ 'created': 0,
359
+ 'setting_up': 0,
360
+ 'ready': 1,
361
+ 'acquired': 0,
362
+ 'in_session': 0,
363
+ 'exiting_session': 0,
364
+ 'shutting_down': 0,
365
+ 'offline': 0,
366
+ }
367
+ }
368
+ }
369
+ )
370
+ sb = env.acquire()
371
+ self.assertEqual(sb.status, interface.Sandbox.Status.ACQUIRED)
372
+ self.assertEqual(
373
+ env.stats(),
374
+ {
375
+ 'sandbox': {
376
+ 'test_image': {
377
+ 'created': 0,
378
+ 'setting_up': 0,
379
+ 'ready': 0,
380
+ 'acquired': 1,
381
+ 'in_session': 0,
382
+ 'exiting_session': 0,
383
+ 'shutting_down': 0,
384
+ 'offline': 0,
385
+ }
386
+ }
387
+ }
388
+ )
389
+ self.assertEqual(len(env.sandbox_pool['test_image']), 1)
390
+ sb2 = env.acquire()
391
+ self.assertEqual(sb2.status, interface.Sandbox.Status.ACQUIRED)
392
+ self.assertEqual(len(env.sandbox_pool['test_image']), 2)
393
+ self.assertEqual(
394
+ env.stats(),
395
+ {
396
+ 'sandbox': {
397
+ 'test_image': {
398
+ 'created': 0,
399
+ 'setting_up': 0,
400
+ 'ready': 0,
401
+ 'acquired': 2,
402
+ 'in_session': 0,
403
+ 'exiting_session': 0,
404
+ 'shutting_down': 0,
405
+ 'offline': 0,
406
+ }
407
+ }
408
+ }
409
+ )
410
+ self.assertEqual(
411
+ env.stats(),
412
+ {
413
+ 'sandbox': {}
414
+ }
415
+ )
416
+
417
+ def test_acquire_with_growing_pool_failure(self):
418
+ env = TestingEnvironment(
419
+ features={'test_feature': TestingFeature()},
420
+ pool_size=(1, 3),
421
+ outage_grace_period=1,
422
+ outage_retry_interval=0,
423
+ sandbox_keepalive_interval=0,
424
+ )
425
+ with env:
426
+ self.assertEqual(len(env.sandbox_pool), 1)
427
+ sb = env.acquire()
428
+ self.assertEqual(sb.status, interface.Sandbox.Status.ACQUIRED)
429
+
430
+ # Make future sandbox setup to fail.
431
+ env.features.test_feature.rebind(
432
+ simulate_setup_error=interface.SandboxStateError,
433
+ skip_notification=True
434
+ )
435
+ with self.assertRaises(interface.EnvironmentOutageError):
436
+ env.acquire()
437
+
438
+ def test_housekeep_error(self):
439
+ env = TestingEnvironment(
440
+ features={'test_feature': TestingFeature()},
441
+ pool_size=1,
442
+ proactive_session_setup=True,
443
+ outage_grace_period=1,
444
+ outage_retry_interval=0,
445
+ sandbox_keepalive_interval=0,
446
+ )
447
+ with env:
448
+ self.assertEqual(len(env.sandbox_pool), 1)
449
+ self.assertIn('test_image', env.sandbox_pool)
450
+ self.assertEqual(
451
+ env.sandbox_pool['test_image'][0].status,
452
+ interface.Sandbox.Status.READY
453
+ )
454
+ # Make future sandbox setup to fail.
455
+ env.features.test_feature.rebind(
456
+ simulate_setup_error=interface.SandboxStateError,
457
+ skip_notification=True
458
+ )
459
+ with self.assertRaises(interface.SandboxStateError):
460
+ with env.sandbox() as sb:
461
+ sb.shell('bad command', raise_error=interface.SandboxStateError)
462
+ self.assertEqual(sb.status, interface.Sandbox.Status.OFFLINE)
463
+ self.assertEqual(len(sb.state_errors), 1)
464
+ sb_offline_time = time.time()
465
+ while time.time() - sb_offline_time < 10:
466
+ if not env.is_online:
467
+ break
468
+ time.sleep(0.5)
469
+ self.assertFalse(env.is_online)
470
+
471
+
472
+ if __name__ == '__main__':
473
+ unittest.main()
@@ -11,9 +11,9 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
- """Common base class for sandbox-based features.
14
+ """Common base class for environment features.
15
15
 
16
- This module provides an base class `BaseFeature` for sandbox-based features,
16
+ This module provides an base class `BaseFeature` for environment features,
17
17
  which provides event handlers for the feature lifecycle events, which can be
18
18
  overridden by subclasses to provide custom behaviors. Please note that this base
19
19
  class is intended to provide a convenient way to implement features, and not
@@ -22,18 +22,24 @@ coupled with `BaseEnvironment` and `BaseSandbox`, and is expected to work with
22
22
  the `Environment` and `Sandbox` interfaces directly.
23
23
  """
24
24
 
25
+ import contextlib
25
26
  import functools
26
27
  import os
27
28
  import re
28
29
  import time
29
- from typing import Annotated, Callable
30
+ from typing import Annotated, Any, Callable, Iterator
30
31
 
31
32
  from langfun.env import interface
32
33
  import pyglove as pg
33
34
 
34
35
 
35
36
  class BaseFeature(interface.Feature):
36
- """Common base class for sandbox-based features."""
37
+ """Common base class for environment features."""
38
+
39
+ is_sandbox_based: Annotated[
40
+ bool,
41
+ 'Whether the feature is sandbox-based.'
42
+ ] = True
37
43
 
38
44
  applicable_images: Annotated[
39
45
  list[str],
@@ -110,10 +116,21 @@ class BaseFeature(interface.Feature):
110
116
  super()._on_parent_change(old_parent, new_parent)
111
117
  self.__dict__.pop('name', None)
112
118
 
119
+ @functools.cached_property
120
+ def environment(self) -> interface.Environment:
121
+ """Returns the environment that the feature is running in."""
122
+ if self._sandbox is not None:
123
+ return self._sandbox.environment
124
+ env = self.sym_ancestor(lambda v: isinstance(v, interface.Environment))
125
+ assert env is not None, 'Feature is not put into an environment.'
126
+ return env
127
+
113
128
  @property
114
- def sandbox(self) -> interface.Sandbox:
129
+ def sandbox(self) -> interface.Sandbox | None:
115
130
  """Returns the sandbox that the feature is running in."""
116
- assert self._sandbox is not None, 'Feature has not been set up yet.'
131
+ assert self._sandbox is not None or not self.is_sandbox_based, (
132
+ 'Feature has not been set up yet.'
133
+ )
117
134
  return self._sandbox
118
135
 
119
136
  @property
@@ -150,7 +167,7 @@ class BaseFeature(interface.Feature):
150
167
  finally:
151
168
  event_handler(duration=time.time() - start_time, error=error)
152
169
 
153
- def setup(self, sandbox: interface.Sandbox) -> None:
170
+ def setup(self, sandbox: interface.Sandbox | None = None) -> None:
154
171
  """Sets up the feature."""
155
172
  self._sandbox = sandbox
156
173
  self._do(self._setup, self.on_setup)
@@ -188,7 +205,11 @@ class BaseFeature(interface.Feature):
188
205
  error: BaseException | None = None
189
206
  ) -> None:
190
207
  """Called when the feature is setup."""
191
- self.sandbox.on_feature_setup(self, duration, error)
208
+ self.environment.event_handler.on_feature_setup(
209
+ feature=self,
210
+ duration=duration,
211
+ error=error
212
+ )
192
213
 
193
214
  def on_teardown(
194
215
  self,
@@ -196,7 +217,11 @@ class BaseFeature(interface.Feature):
196
217
  error: BaseException | None = None
197
218
  ) -> None:
198
219
  """Called when the feature is teardown."""
199
- self.sandbox.on_feature_teardown(self, duration, error)
220
+ self.environment.event_handler.on_feature_teardown(
221
+ feature=self,
222
+ duration=duration,
223
+ error=error
224
+ )
200
225
 
201
226
  def on_housekeep(
202
227
  self,
@@ -205,8 +230,12 @@ class BaseFeature(interface.Feature):
205
230
  **kwargs
206
231
  ) -> None:
207
232
  """Called when the feature has done housekeeping."""
208
- self.sandbox.on_feature_housekeep(
209
- self, self._housekeep_counter, duration, error, **kwargs
233
+ self.environment.event_handler.on_feature_housekeep(
234
+ feature=self,
235
+ counter=self._housekeep_counter,
236
+ duration=duration,
237
+ error=error,
238
+ **kwargs
210
239
  )
211
240
 
212
241
  def on_setup_session(
@@ -215,7 +244,12 @@ class BaseFeature(interface.Feature):
215
244
  error: BaseException | None = None,
216
245
  ) -> None:
217
246
  """Called when the feature is setup for a user session."""
218
- self.sandbox.on_feature_setup_session(self, duration, error)
247
+ self.environment.event_handler.on_feature_setup_session(
248
+ feature=self,
249
+ session_id=self.session_id,
250
+ duration=duration,
251
+ error=error
252
+ )
219
253
 
220
254
  def on_teardown_session(
221
255
  self,
@@ -223,7 +257,12 @@ class BaseFeature(interface.Feature):
223
257
  error: BaseException | None = None,
224
258
  ) -> None:
225
259
  """Called when the feature is teardown for a user session."""
226
- self.sandbox.on_feature_teardown_session(self, duration, error)
260
+ self.environment.event_handler.on_feature_teardown_session(
261
+ feature=self,
262
+ session_id=self.session_id,
263
+ duration=duration,
264
+ error=error
265
+ )
227
266
 
228
267
  def on_activity(
229
268
  self,
@@ -233,10 +272,33 @@ class BaseFeature(interface.Feature):
233
272
  **kwargs
234
273
  ) -> None:
235
274
  """Called when a sandbox activity is performed."""
236
- self.sandbox.on_activity(
275
+ self.environment.event_handler.on_feature_activity(
237
276
  name=f'{self.name}.{name}',
238
277
  feature=self,
239
- error=error,
278
+ session_id=self.session_id,
240
279
  duration=duration,
280
+ error=error,
241
281
  **kwargs
242
282
  )
283
+
284
+ @contextlib.contextmanager
285
+ def track_activity(
286
+ self,
287
+ name: str,
288
+ **kwargs: Any
289
+ ) -> Iterator[None]:
290
+ """Context manager that tracks a feature activity."""
291
+ start_time = time.time()
292
+ error = None
293
+ try:
294
+ yield None
295
+ except BaseException as e: # pylint: disable=broad-except
296
+ error = e
297
+ raise
298
+ finally:
299
+ self.on_activity(
300
+ name=name,
301
+ duration=time.time() - start_time,
302
+ error=error,
303
+ **kwargs
304
+ )