diff --git a/docs/source/upcoming_release_notes/385-auto_close_after_timeout b/docs/source/upcoming_release_notes/385-auto_close_after_timeout new file mode 100644 index 00000000..b4f3d6ca --- /dev/null +++ b/docs/source/upcoming_release_notes/385-auto_close_after_timeout @@ -0,0 +1,22 @@ +385 ECS-5104 Hutch-python feature: auto-close after timeout +################# + +API Changes +----------- +- N/A + +Features +-------- +- Automatically timeout and closes hutch-python sessions after the user has been idle for a certain number of hours. The number of hours can be set in conf.yml for each hutch. If no value is set the default timeout duration is 48 hours. + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- N/A + +Contributors +------------ +- janeliu-slac diff --git a/hutch_python/cli.py b/hutch_python/cli.py index 08f6d9d5..c36f51b6 100644 --- a/hutch_python/cli.py +++ b/hutch_python/cli.py @@ -121,6 +121,7 @@ def configure_ipython_session(args: HutchPythonArgs): # Important Utilities ipy_config.InteractiveShellApp.extensions = [ "hutch_python.ipython_log", + "hutch_python.ipython_session_timer", "hutch_python.bug", "hutch_python.pt_app_config" ] diff --git a/hutch_python/constants.py b/hutch_python/constants.py index bba04bad..7e3ab7a9 100644 --- a/hutch_python/constants.py +++ b/hutch_python/constants.py @@ -38,6 +38,7 @@ 'daq_type', 'daq_host', 'obj_config', + 'session_timer', ) NO_LOG_EXCEPTIONS = (KeyboardInterrupt, SystemExit) LOG_DOMAINS = {".pcdsn", ".slac.stanford.edu"} diff --git a/hutch_python/ipython_session_timer.py b/hutch_python/ipython_session_timer.py new file mode 100644 index 00000000..ef6322c8 --- /dev/null +++ b/hutch_python/ipython_session_timer.py @@ -0,0 +1,107 @@ +""" +This module modifies an ``ipython`` shell to automatically close if it has been +idle for a certain number of hours. Each hutch can configure their conf.yml +file to set a timeout duration. The default duration is 48 hours. +""" + +import time +from threading import Thread + +max_idle_time = 172800.0 # number of seconds in 48 hours + + +def configure_timeout(session_timer): + global max_idle_time + if isinstance(session_timer, int) and session_timer > 0: + max_idle_time = session_timer + + +class IPythonSessionTimer: + ''' + Class tracks the amount of time the current `InteractiveShell` instance (henceforth + called 'user session') has been idle and closes the session if more than 48 + hours have passed. + + Time is in seconds (floating point) since the epoch began. (In UNIX the + epoch started on January 1, 1970, 00:00:00 UTC) + + Parameters + ---------- + ipython : ``IPython.terminal.interactiveshell.TerminalInteractiveShell`` + The active ``ipython`` ``Shell``, perhaps the one returned by + ``IPython.get_ipython()``. + + Attributes + ---------- + curr_time: float + The current time in seconds. + + max_idle_time: float + The maximum number of seconds a user session can be idle (default is + 172800.0 seconds or 48 hours). + + last_active_time: float + The time of the last user activity in this session. + + idle_time: float + The amount of time the user session has been idle. + ''' + + def __init__(self, ipython): + self.curr_time = 0.0 + self.max_idle_time = max_idle_time + self.last_active_time = 0.0 + self.idle_time = 0.0 + self.user_active = False + self.ip = ipython + + ipython.events.register('pre_run_cell', self._set_user_active) + ipython.events.register('post_run_cell', self._set_user_inactive) + + def _set_user_active(self): + self.user_active = True + self.last_active_time = time.monotonic() + + def _set_user_inactive(self): + self.user_active = False + self.last_active_time = time.monotonic() + + def _set_idle_time(self): + self.curr_time = time.monotonic() + self.idle_time = self.curr_time - self.last_active_time + + def _start_session(self): + # Check if idle_time has exceeded max_idle_time or if user is currently active + while (self.idle_time < self.max_idle_time) or self.user_active: + + # Check if user is active once every minute + while (self.user_active): + time.sleep(60) + self.idle_time = 0 + + time.sleep(self.max_idle_time - self.idle_time) + self._set_idle_time() + + # End the IPython session + print("This hutch-python session has timed out. Please start a new session.") + + self.ip.ask_exit() + self.ip.pt_app.app.exit() + + +def load_ipython_extension(ipython): + """ + Initialize the `IPythonSessionTimer`. + + This starts a timer that checks if the user session has been + idle for 48 hours or longer. If so, close the user session. + + Parameters + ---------- + ipython: IPython.terminal.interactiveshell.TerminalInteractiveShell + The active ``ipython`` ``Shell``, the one returned by + ``IPython.get_ipython()``. + """ + user_session_timer = IPythonSessionTimer(ipython) + t1 = Thread(target=user_session_timer._start_session, daemon=True) + t1.start() diff --git a/hutch_python/load_conf.py b/hutch_python/load_conf.py index 946c223b..dd7db55a 100644 --- a/hutch_python/load_conf.py +++ b/hutch_python/load_conf.py @@ -21,6 +21,7 @@ from pcdsdaq.sim import set_sim_mode as set_daq_sim from pcdsdevices.interface import setup_preset_paths +import hutch_python.ipython_session_timer from . import calc_defaults, plan_defaults, sim, log_setup from .cache import LoadCache @@ -202,7 +203,8 @@ def load_conf(conf, hutch_dir=None, args=None): db = None except KeyError: db = None - logger.info('Missing db from conf. Will skip loading from shared database.') + logger.info( + 'Missing db from conf. Will skip loading from shared database.') try: load_level_setting = conf['load_level'] @@ -295,6 +297,15 @@ def load_conf(conf, hutch_dir=None, args=None): daq_platform = 0 logger.info('Selected default hutch-python daq platform: 0') + # Set the session timeout duration + try: + hutch_python.ipython_session_timer.configure_timeout( + conf['session_timer']) + except KeyError: + hutch_python.ipython_session_timer.configure_timeout(172800) + logger.info( + 'Missing session_timer value from conf. Set default value to 172800 seconds (48 hours).') + # Make cache namespace cache = LoadCache((hutch or 'hutch') + '.db', hutch_dir=hutch_dir) @@ -366,8 +377,8 @@ def load_conf(conf, hutch_dir=None, args=None): logger.warning('Sim mode not implemented for lcls2 DAQ!') logger.warning('Instantiating live DAQ!') # Optional dependency - from psdaq.control.DaqControl import DaqControl # NOQA - from psdaq.control.BlueskyScan import BlueskyScan # NOQA + from psdaq.control.DaqControl import DaqControl # NOQA + from psdaq.control.BlueskyScan import BlueskyScan # NOQA daq_control = DaqControl( host=daq_host, platform=daq_platform, diff --git a/hutch_python/tests/test_ipython_log.py b/hutch_python/tests/test_ipython_log.py index 8dc35752..33a3f9b5 100644 --- a/hutch_python/tests/test_ipython_log.py +++ b/hutch_python/tests/test_ipython_log.py @@ -2,7 +2,9 @@ import logging import sys from queue import Empty, Queue +from types import SimpleNamespace from typing import Any, Optional +from unittest.mock import MagicMock import pytest @@ -53,6 +55,10 @@ class FakeExecutionResult: error_before_exec: bool = False +class FakePtApp: + app = SimpleNamespace(exit=MagicMock()) + + class FakeIPython: """A fake replacement of IPython's ``TerminalInteractiveShell``.""" user_ns: dict[str, Any] @@ -61,6 +67,8 @@ class FakeIPython: def __init__(self): self.user_ns = dict(In=[""]) self.events = FakeIPythonEvents() + self.pt_app = FakePtApp() + self.ask_exit = MagicMock() def add_line(self, in_line, out_line=None, is_error=False): line_number = len(self.user_ns["In"]) diff --git a/hutch_python/tests/test_ipython_session_timer.py b/hutch_python/tests/test_ipython_session_timer.py new file mode 100644 index 00000000..dd282d31 --- /dev/null +++ b/hutch_python/tests/test_ipython_session_timer.py @@ -0,0 +1,54 @@ +import time +import unittest + +import pytest +from test_ipython_log import FakeIPython + +from hutch_python.ipython_session_timer import IPythonSessionTimer + + +@pytest.fixture(scope='function') +def fake_ipython(): + fake_ipython = FakeIPython() + return fake_ipython + + +@pytest.fixture(scope='function') +def session_timer(fake_ipython): + session_timer = IPythonSessionTimer(fake_ipython) + session_timer.max_idle_time = 5.0 + session_timer.user_active = True + session_timer.curr_time = time.monotonic() + session_timer.last_active_time = session_timer.curr_time - 2.0 + session_timer.idle_time = session_timer.curr_time - session_timer.last_active_time + return session_timer + + +def test_set_user_active(session_timer): + session_timer._set_user_active() + assert session_timer.user_active + + +def test_set_user_inactive(session_timer): + session_timer._set_user_inactive() + assert not session_timer.user_active + + +def test_set_idle_time(session_timer): + session_timer._set_idle_time() + assert session_timer.idle_time == pytest.approx(2.0, 0.01) + + +# Skipping tests for _start_session() where self.user_active==True because this enters an +# infinite while loop. + +@unittest.mock.patch('time.sleep', lambda seconds: None) +def test_start_session(session_timer, fake_ipython, capsys): + session_timer.user_active = False + + session_timer._start_session() + + captured = capsys.readouterr() + assert "timed out" in captured.out + assert session_timer.ip.ask_exit.called + assert session_timer.ip.pt_app.app.exit.called