Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion buckaroo/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
46 changes: 46 additions & 0 deletions buckaroo/server/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<session_id>"}``. 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")
Expand Down
34 changes: 34 additions & 0 deletions buckaroo/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/server/test_load_expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/server/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Comment on lines +254 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Implement POST /reset route before landing reset tests

This commit adds multiple assertions that POST /reset returns 200 and clears session state, but no server handler/route is added in the same change, so these calls hit an unmapped endpoint (404) and the new tests fail immediately. I verified the current server route table only includes /load, /load_expr, /load_compare, and /ws/... (no /reset), so this feature commit is incomplete and will keep CI red until the endpoint is implemented.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the intentional TDD split — the failing tests in commit 43ce45fe were committed first so they could be observed failing on CI before the fix landed; the ResetHandler route + handler + SessionState.reset() are all in the next commit 32697a88 (this same review). The PR description's test plan notes the two CI runs (one red for tests-only, one green for fix).

Project convention (global CLAUDE.md): "NEVER bundle a test with the fix that makes it pass — the test must be seen failing on CI first." So a per-commit complaint about "tests reference behaviour the commit doesn't implement" is a false positive on the test-only commit; the contract is checked against the PR head, not each commit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale: this comment was made against the test-first commit (43ce45f), not the PR head. The PR head (32697a8) does add the route — (r"/reset", ResetHandler) in server/app.py plus class ResetHandler in handlers.py. The visible "CI red" on this PR is a CI-infra git push failure, not a 404 from the new tests.

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()
Expand Down
Loading