diff --git a/buckaroo/server/app.py b/buckaroo/server/app.py index 340c7d27f..c0aec575d 100644 --- a/buckaroo/server/app.py +++ b/buckaroo/server/app.py @@ -3,7 +3,7 @@ import tornado.web -from buckaroo.server.handlers import HealthHandler, DiagnosticsHandler, LoadHandler, LoadExprHandler, LoadCompareHandler, SessionPageHandler +from buckaroo.server.handlers import HealthHandler, DiagnosticsHandler, LoadHandler, LoadExprHandler, LoadCompareHandler, ResetHandler, SessionPageHandler from buckaroo.server.websocket_handler import DataStreamHandler from buckaroo.server.session import SessionManager @@ -22,6 +22,7 @@ def make_app(sessions: SessionManager | None = None, port: int = 8888, open_brow (r"/load", LoadHandler), (r"/load_expr", LoadExprHandler), (r"/load_compare", LoadCompareHandler), + (r"/reset", ResetHandler), (r"/s/([^/]+)", SessionPageHandler), (r"/ws/([^/]+)", DataStreamHandler), ], sessions=sessions, port=port, open_browser=open_browser, static_path=os.path.abspath(static_path), server_start_time=SERVER_START_TIME) diff --git a/buckaroo/server/handlers.py b/buckaroo/server/handlers.py index e41671add..7171dccba 100644 --- a/buckaroo/server/handlers.py +++ b/buckaroo/server/handlers.py @@ -569,6 +569,52 @@ async def post(self): "rows": len(merged_df), "columns": [str(c) for c in merged_df.columns], "eqs": eqs}) +class ResetHandler(tornado.web.RequestHandler): + """POST /reset — clear a session's data attributes back to the + empty state without restarting the server (issue #812). + + Body: ``{"session": ""}``. Returns 200 with + ``{"session": ..., "cleared": bool}``. ``cleared=False`` when the + session id was never populated (no-op), so callers can blindly + reset on startup without first checking existence. + + The WebSocket connection is intentionally left open; the next + ``infinite_request`` over it will take the "No data loaded" + branch in the dispatcher. We do not push a state update — there + is no meaningful state to send, and the contract is "the session + becomes indistinguishable from a freshly-created empty one".""" + + async def post(self): + try: + body = json.loads(self.request.body) + except (json.JSONDecodeError, TypeError): + self.set_status(400) + self.write({"error": "Invalid JSON body"}) + return + if not isinstance(body, dict): + self.set_status(400) + self.write({"error": "Invalid JSON body"}) + return + + session_id = body.get("session") + if not session_id: + self.set_status(400) + self.write({"error": "Missing 'session'"}) + return + + sessions = self.application.settings["sessions"] + session = sessions.get(session_id) + if session is None: + log.info("reset session=%s cleared=False (unknown)", session_id) + self.write({"session": session_id, "cleared": False}) + return + + session.reset() + log.info("reset session=%s cleared=True ws_clients=%d", + session_id, len(session.ws_clients)) + self.write({"session": session_id, "cleared": True}) + + class SessionPageHandler(tornado.web.RequestHandler): def get(self, session_id): self.set_header("Content-Type", "text/html") diff --git a/buckaroo/server/session.py b/buckaroo/server/session.py index 9cbf3a50f..9e50e1918 100644 --- a/buckaroo/server/session.py +++ b/buckaroo/server/session.py @@ -45,6 +45,40 @@ def touch(self) -> None: """Update the last-accessed timestamp.""" self.last_accessed = time.time() + def reset(self) -> None: + """Wipe all data/display attributes back to their dataclass + defaults, leaving ``session_id``, ``ws_clients`` and + ``last_accessed`` (touched) intact. + + Driven by issue #812: callers (interactive demo / playground) + need a way to "start over" on a session id without restarting + the server or tearing down the WebSocket. Mirrors the dataclass + field defaults exactly — when a new backend field is added to + ``SessionState`` it must be reset here too. + """ + self.path = "" + self.df = None + self.metadata = {} + self.df_display_args = {} + self.df_data_dict = {} + self.df_meta = {} + self.ldf = None + self.orig_to_rw = {} + self.rw_to_orig = {} + self.mode = "viewer" + self.backend = "pandas" + self.dataflow = None + self.xorq_dataflow = None + self.expr = None + self.buckaroo_state = {} + self.buckaroo_options = {} + self.command_config = {} + self.operation_results = {} + self.operations = [] + self.prompt = "" + self.component_config = None + self.touch() + PROTOCOL_VERSION = 1 """Bumped when the WebSocket protocol changes incompatibly. Clients diff --git a/tests/unit/server/test_load_expr.py b/tests/unit/server/test_load_expr.py index e4d6cc9db..d0bc4080a 100644 --- a/tests/unit/server/test_load_expr.py +++ b/tests/unit/server/test_load_expr.py @@ -141,6 +141,81 @@ async def test_ws_search_pushdown(self): finally: shutil.rmtree(builds_root, ignore_errors=True) + @tornado.testing.gen_test + async def test_reset_clears_xorq_session(self): + """POST /reset after /load_expr must clear xorq_dataflow / expr / + backend so the dataclass defaults are restored — same contract as + the pandas reset test in test_server.py, exercised over the xorq + path. Regression for issue #812.""" + builds_root = tempfile.mkdtemp() + try: + build_path = _build_expr_dir(builds_root) + await _post(self.get_http_port(), "/load_expr", + {"session": "lx-reset", "build_dir": build_path}) + + sessions = self._app.settings["sessions"] + session = sessions.get("lx-reset") + self.assertEqual(session.backend, "xorq") + self.assertIsNotNone(session.xorq_dataflow) + + resp = await _post(self.get_http_port(), "/reset", + {"session": "lx-reset"}) + self.assertEqual(resp.code, 200) + + session = sessions.get("lx-reset") + self.assertIsNone(session.xorq_dataflow) + self.assertIsNone(session.expr) + self.assertIsNone(session.dataflow) + self.assertIsNone(session.df) + self.assertEqual(session.backend, "pandas") + self.assertEqual(session.mode, "viewer") + self.assertEqual(session.metadata, {}) + self.assertEqual(session.df_data_dict, {}) + finally: + shutil.rmtree(builds_root, ignore_errors=True) + + @tornado.testing.gen_test + async def test_reset_between_xorq_and_pandas_load(self): + """Engine swap via reset: /load_expr (xorq) -> /reset -> /load + (pandas) leaves no stale xorq state behind. WS infinite_request + returns pandas-shaped data.""" + builds_root = tempfile.mkdtemp() + csv_fd, csv_path = tempfile.mkstemp(suffix=".csv") + os.close(csv_fd) + try: + build_path = _build_expr_dir(builds_root) + pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]}).to_csv(csv_path, index=False) + + sid = "lx-reset-swap" + await _post(self.get_http_port(), "/load_expr", + {"session": sid, "build_dir": build_path}) + await _post(self.get_http_port(), "/reset", {"session": sid}) + await _post(self.get_http_port(), "/load", + {"session": sid, "path": csv_path, "mode": "buckaroo"}) + + sessions = self._app.settings["sessions"] + session = sessions.get(sid) + self.assertIsNone(session.xorq_dataflow) + self.assertIsNone(session.expr) + self.assertEqual(session.backend, "pandas") + self.assertIsNotNone(session.dataflow) + + ws = await tornado.websocket.websocket_connect( + f"ws://localhost:{self.get_http_port()}/ws/{sid}") + await ws.read_message() # initial_state + + ws.write_message(json.dumps({ + "type": "infinite_request", + "payload_args": {"start": 0, "end": 10, + "sourceName": "default", "origEnd": 10}})) + r = json.loads(await ws.read_message()) + self.assertEqual(r["type"], "infinite_resp") + self.assertEqual(r["length"], 3) + ws.close() + finally: + shutil.rmtree(builds_root, ignore_errors=True) + os.unlink(csv_path) + @tornado.testing.gen_test async def test_session_reuse_xorq_then_pandas(self): """A client that POSTs /load_expr and then POSTs /load with the diff --git a/tests/unit/server/test_server.py b/tests/unit/server/test_server.py index 5edd8ce1b..b10fc7650 100644 --- a/tests/unit/server/test_server.py +++ b/tests/unit/server/test_server.py @@ -243,6 +243,118 @@ async def test_ws_request_no_data_loaded(self): ws.close() +class TestReset(tornado.testing.AsyncHTTPTestCase): + """POST /reset clears a populated session back to the empty state + without restarting the server (issue #812).""" + + def get_app(self): + return make_app() + + def test_reset_missing_session(self): + resp = self.fetch("/reset", method="POST", body=json.dumps({}), + headers={"Content-Type": "application/json"}) + self.assertEqual(resp.code, 400) + + def test_reset_bad_json(self): + resp = self.fetch("/reset", method="POST", body="not json", + headers={"Content-Type": "application/json"}) + self.assertEqual(resp.code, 400) + + def test_reset_unknown_session_is_noop(self): + """Reset on a session that doesn't exist must not 500; it should + report cleared=False so callers can blindly invoke it on startup + without first checking whether the session has been populated.""" + resp = self.fetch("/reset", method="POST", + body=json.dumps({"session": "never-existed"}), + headers={"Content-Type": "application/json"}) + self.assertEqual(resp.code, 200) + body = json.loads(resp.body) + self.assertEqual(body["cleared"], False) + + def test_reset_clears_loaded_pandas_session(self): + """/load populates df / dataflow / metadata. /reset must wipe + those back to the dataclass defaults so a subsequent WS + infinite_request reports 'No data loaded'.""" + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f: + _write_test_csv(f.name) + try: + # Populate + resp = self.fetch("/load", method="POST", + body=json.dumps({"session": "reset-1", "path": f.name, + "mode": "buckaroo"}), + headers={"Content-Type": "application/json"}) + self.assertEqual(resp.code, 200) + + sessions = self._app.settings["sessions"] + session = sessions.get("reset-1") + self.assertIsNotNone(session.df) + self.assertIsNotNone(session.dataflow) + self.assertTrue(session.df_data_dict) + self.assertTrue(session.metadata) + + # Reset + resp = self.fetch("/reset", method="POST", + body=json.dumps({"session": "reset-1"}), + headers={"Content-Type": "application/json"}) + self.assertEqual(resp.code, 200) + body = json.loads(resp.body) + self.assertEqual(body["session"], "reset-1") + self.assertEqual(body["cleared"], True) + + # The session row still exists (WS clients survive) but the + # data attributes must all be cleared. + session = sessions.get("reset-1") + self.assertIsNotNone(session, "reset must not evict the session") + self.assertIsNone(session.df) + self.assertIsNone(session.ldf) + self.assertIsNone(session.dataflow) + self.assertIsNone(session.xorq_dataflow) + self.assertIsNone(session.expr) + self.assertEqual(session.metadata, {}) + self.assertEqual(session.df_display_args, {}) + self.assertEqual(session.df_data_dict, {}) + self.assertEqual(session.df_meta, {}) + self.assertEqual(session.buckaroo_state, {}) + self.assertEqual(session.operations, []) + self.assertEqual(session.mode, "viewer") + self.assertEqual(session.backend, "pandas") + finally: + os.unlink(f.name) + + @tornado.testing.gen_test + async def test_reset_then_ws_request_reports_no_data(self): + """After reset, an existing WS connection sending an + infinite_request must get the 'No data loaded' branch — proving + the dispatch can no longer reach any stale dataflow.""" + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f: + _write_test_csv(f.name) + try: + await _async_fetch(self.get_http_port(), "/load", + method="POST", + body=json.dumps({"session": "reset-ws", "path": f.name})) + + ws = await tornado.websocket.websocket_connect( + f"ws://localhost:{self.get_http_port()}/ws/reset-ws") + await ws.read_message() # discard initial_state + + resp = await _async_fetch(self.get_http_port(), "/reset", + method="POST", + body=json.dumps({"session": "reset-ws"})) + self.assertEqual(resp.code, 200) + + ws.write_message(json.dumps({ + "type": "infinite_request", + "payload_args": {"start": 0, "end": 5, + "sourceName": "default", "origEnd": 5}})) + r = json.loads(await ws.read_message()) + self.assertEqual(r["type"], "infinite_resp") + self.assertEqual(r["length"], 0) + self.assertIn("error_info", r) + ws.close() + finally: + os.unlink(f.name) + + class TestLoadPushesToWebSocket(tornado.testing.AsyncHTTPTestCase): def get_app(self): return make_app()