From 9a0b9fbad0b9f46556a5334c5d427c521246ed48 Mon Sep 17 00:00:00 2001 From: LucaVor Date: Sat, 23 Aug 2025 17:21:32 -0400 Subject: [PATCH 1/4] okay --- mantis_sdk/space.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mantis_sdk/space.py b/mantis_sdk/space.py index fa95421..9932b78 100644 --- a/mantis_sdk/space.py +++ b/mantis_sdk/space.py @@ -92,9 +92,11 @@ async def _init_space(self): timeout=self.config.timeout) await self._apply_init_render_args () + + wait_for = self.config.wait_for if hasattr(self.config, 'wait_for') else "isLoaded" # Wait until the exposed loading value is true - await self.page.wait_for_function ("""() => window.isLoaded === true""", + await self.page.wait_for_function (f"""() => window.{wait_for} === true""", timeout=self.config.timeout) # Let points render after data is loaded From a1b841597a0093dbdf27c5ba8a4736a46b225846 Mon Sep 17 00:00:00 2001 From: LucaVor Date: Sun, 23 Nov 2025 16:03:27 -0500 Subject: [PATCH 2/4] okay --- .gitignore | 6 +- README.md | 50 +++++- examples/demo.ipynb | 97 ++++++---- mantis_sdk/client.py | 101 ++++++++++- mantis_sdk/notebook.py | 348 ++++++++++++++++++++++++++++++++++++ mantis_sdk/requirements.txt | 4 +- output_plot.png | Bin 0 -> 43493 bytes 7 files changed, 550 insertions(+), 56 deletions(-) create mode 100644 mantis_sdk/notebook.py create mode 100644 output_plot.png diff --git a/.gitignore b/.gitignore index 30b6b92..be7b51a 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,8 @@ cython_debug/ ./mantis_sdk/main.py mantis_sdk/main.py test.csv -./test.csv \ No newline at end of file +./test.csv +main.py +./main.py +./venv +venv/ \ No newline at end of file diff --git a/README.md b/README.md index a74cc21..1cc16f2 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,15 @@ The `MantisClient` object requires the parameter `cookie` to be passed in. This ***Note***: If you are using a local backend, you must run it with docker, or the space creation will NOT work. To do so, `cd docker` from the backend root, then `docker compose up -d --build`. After you run the build once, you can re-run it simply with `docker composeĀ up`. If things dno't work, check the logs and make sure they are not empty. ```python -from client import MantisClient, SpacePrivacy, DataType, ReducerModels -from render_args import RenderArgs +from mantis_sdk.client import MantisClient, SpacePrivacy, DataType, ReducerModels +from mantis_sdk.render_args import RenderArgs +from mantis_sdk.config import ConfigurationManager import pandas as pd -mantis = MantisClient("/api/proxy/", cookie) +# You need to provide your cookie and a space_id (can be dummy for creation) +cookie = "YOUR_COOKIE_HERE" + +mantis = MantisClient("/api/proxy/", cookie=cookie, space_id="dummy") # Create DF (Real data will need more points) df = pd.DataFrame({ @@ -58,10 +62,11 @@ new_space_id = mantis.create_space("Stock data", data=df, data_types=data_types, reducer=ReducerModels.UMAP, - privacy_level=SpacePrivacy.Private)["space_id"] + privacy_level=SpacePrivacy.PRIVATE)["space_id"] # Open space -space = await mantis.open_space(space_id) +# Re-initialize client with the new space_id if needed, or just use the space object +space = await mantis.open_space(new_space_id) # Interact with space await space.select_points(100) @@ -166,4 +171,39 @@ await space.run_code (code) await space.close_panel ("bags") await space.close_panel ("quicksheet") await space.close_panel ("userlogs") +``` + +### Notebooks + +You can create and manage notebooks within a space. + +```python +# Create a notebook +nb = mantis.create_notebook(space_id, "My Analysis", "user_id") + +# Add a cell +code = """ +print("Hello from Mantis Notebook!") +""" +cell = nb.add_cell(code) + +# Execute cell +outputs = cell.execute() +print(outputs) +``` + +### Configuration + +You can configure the client using `ConfigurationManager`. + +```python +from mantis_sdk.config import ConfigurationManager + +config = ConfigurationManager() +config.update({ + "timeout": 60000, + "render_args": RenderArgs(headless=True) +}) + +mantis = MantisClient("/api/proxy/", cookie=cookie, space_id=space_id, config=config) ``` \ No newline at end of file diff --git a/examples/demo.ipynb b/examples/demo.ipynb index 789a328..df56796 100644 --- a/examples/demo.ipynb +++ b/examples/demo.ipynb @@ -9,6 +9,7 @@ "# Import main SDK\n", "from mantis_sdk.client import MantisClient, SpacePrivacy, DataType, ReducerModels\n", "from mantis_sdk.render_args import RenderArgs\n", + "from mantis_sdk.config import ConfigurationManager\n", "\n", "import nest_asyncio\n", "import asyncio\n", @@ -16,6 +17,17 @@ "nest_asyncio.apply()" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Setup authentication\n", + "# You need to get your cookie from the browser (dev tools -> network -> request headers)\n", + "cookie = \"YOUR_COOKIE_HERE\"\n" + ] + }, { "cell_type": "code", "execution_count": null, @@ -41,8 +53,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Load Mantis Client\n", - "mantis = MantisClient(\"/api/proxy/\", render_args=RenderArgs(viewport={\"width\": 1920, \"height\": 1080}))" + "# Load Mantis Client with Config\n", + "config = ConfigurationManager()\n", + "config.update({\"render_args\": RenderArgs(viewport={\"width\": 1920, \"height\": 1080})})\n", + "mantis = MantisClient(\"/api/proxy/\", cookie=cookie, space_id=\"dummy\", config=config)" ] }, { @@ -68,7 +82,7 @@ "outputs": [], "source": [ "# Load Mantis\n", - "mantis = MantisClient(\"/api/proxy/\")\n", + "mantis = MantisClient(\"/api/proxy/\", cookie=cookie, space_id=\"dummy\")\n", "\n", "# Set data path + types\n", "data_path = \"./StockData.csv\"\n", @@ -94,7 +108,7 @@ "outputs": [], "source": [ "# Alternative you can create dataframes\n", - "mantis = MantisClient(\"/api/proxy/\")\n", + "mantis = MantisClient(\"/api/proxy/\", cookie=cookie, space_id=\"dummy\")\n", "\n", "# Create DF\n", "df = pd.DataFrame({\n", @@ -187,39 +201,6 @@ "imshow (await stock_data_space.capture ())" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Run Code**" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "code = \"\"\"\n", - "\n", - "computation = 6**4\n", - "print ('Hello from SDK, :P -> ' + str(computation))\n", - "\n", - "\"\"\"\n", - "\n", - "\n", - "await stock_data_space.run_code (code)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "imshow (await stock_data_space.capture ())" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -335,7 +316,7 @@ "metadata": {}, "outputs": [], "source": [ - "mantis = MantisClient(\"/api/proxy/\")" + "mantis = MantisClient(\"/api/proxy/\", cookie=cookie, space_id=\"dummy\")" ] }, { @@ -381,6 +362,46 @@ " print (\"Space:\", space_name)\n", " imshow (await space.capture ())" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Notebook Automation**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a notebook in the space\n", + "# We need a valid space_id. Assuming 'new_space_id' from previous cells is valid.\n", + "if 'new_space_id' in locals():\n", + " client = MantisClient(\"/api/proxy/\", cookie=cookie, space_id=new_space_id)\n", + " nb = client.create_notebook(new_space_id, \"SDK Demo Notebook\", \"sdk_user\")\n", + " \n", + " # Add a cell\n", + " code = \"\"\"\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "x = np.linspace(0, 10, 100)\n", + "y = np.sin(x)\n", + "\n", + "plt.plot(x, y)\n", + "plt.title('Sine Wave')\n", + "plt.show()\n", + " \"\"\"\n", + " cell = nb.add_cell(code)\n", + " \n", + " # Execute the cell\n", + " outputs = cell.execute()\n", + " print(\"Execution outputs:\", outputs)\n", + "else:\n", + " print(\"Please run the 'Create Space' cells first to generate a space ID.\")" + ] } ], "metadata": { diff --git a/mantis_sdk/client.py b/mantis_sdk/client.py index 167012d..58b164c 100644 --- a/mantis_sdk/client.py +++ b/mantis_sdk/client.py @@ -47,12 +47,14 @@ class MantisClient: SDK for interacting with your Django API. """ - def __init__(self, base_url: str, cookie: str, config: Optional[ConfigurationManager] = None): + def __init__(self, base_url: str, cookie: str, space_id: str, config: Optional[ConfigurationManager] = None): """ Initialize the client. :param base_url: Base URL of the API. - :param token: Optional authentication token. + :param cookie: Authentication cookie. + :param space_id: The space ID to authenticate against. + :param config: Optional configuration manager. """ self.base_url = base_url.rstrip("/") @@ -62,12 +64,36 @@ def __init__(self, base_url: str, cookie: str, config: Optional[ConfigurationMan self.config = config self.cookie = cookie + self.space_id = space_id + self.vscode_token = None - if self.cookie is None: - self._authenticate () + if self.cookie: + self._authenticate() - def _authenticate (self): - raise NotImplementedError ("Authentication is not implemented yet.") + def _authenticate(self): + """ + Authenticates by creating a VSCode token. + """ + try: + # We use the cookie to get the token + # The endpoint is /api/jupyter/vscode-auth-token/ + # It requires project_id in the body + url = f"{self.base_url.lstrip('/').rstrip('/')}/api/jupyter/vscode-auth-token/" + headers = {"cookie": self.cookie} + data = {"project_id": self.space_id} + + response = requests.post(url, json=data, headers=headers) + response.raise_for_status() + result = response.json() + + if result.get("success") and result.get("token"): + self.vscode_token = result.get("token") + logger.info("Successfully authenticated with VSCode token.") + else: + logger.warning(f"Failed to get VSCode token: {result}") + + except Exception as e: + logger.error(f"Authentication failed: {e}") def _request(self, method: str, endpoint: str, rm_slash: bool = False, **kwargs) -> Any: """ @@ -80,7 +106,7 @@ def _request(self, method: str, endpoint: str, rm_slash: bool = False, **kwargs) def remove_slash (s: str): return s.lstrip('/').rstrip('/') - url = f"{self.config.host}/{remove_slash(self.base_url)}/{remove_slash(endpoint)}/" + url = f"{remove_slash(self.base_url)}/{remove_slash(endpoint)}/" # This is one of the weirdest cases I have required # some endpoints don't authenticate if there is a slash at the end @@ -90,6 +116,10 @@ def remove_slash (s: str): headers = {"cookie": self.cookie} + # add VSCode token if available + if self.vscode_token: + headers["VSCode-Token"] = self.vscode_token + if method.upper() == "GET": headers["Cache-Control"] = "no-cache" # Prevent caching for GET requests params = kwargs.get("params", {}) @@ -100,12 +130,17 @@ def remove_slash (s: str): headers.update (kwargs["headers"]) del kwargs["headers"] + reponse_content = None + try: response = requests.request(method, url, headers=headers, **kwargs) + reponse_content = response.text response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: - raise RuntimeError(f"API request failed: {e}. Text: {response.text}") + # capture response text for better debugging + response_text = reponse_content if reponse_content else getattr(e.response, 'text', '') if e.response else str(e) + raise RuntimeError(f"API request failed: {e}. Text: {response_text}\nURL: {url}\nHeaders: {headers}") def get_spaces (self) -> Dict[str, Any]: """ @@ -300,6 +335,17 @@ def create_space (self, space_name: str, return {"space_id": space_id} + def resolve_map_to_project(self, map_id: str) -> str: + """ + Resolves a map ID to a project ID. + """ + response = self._request("POST", "/api/notebook/resolve_map_to_project", json={"map_id": map_id}) + + if response.get("success"): + return response.get("project_id") + else: + raise RuntimeError(f"Failed to resolve map to project: {response.get('error')}") + async def open_space(self, space_id: str) -> "Space": """ Asynchronously open a space by ID. @@ -322,4 +368,41 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): Async context manager exit """ # Clean up if needed - pass \ No newline at end of file + pass + + def create_notebook(self, space_id: str, notebook_name: str = "Untitled", user_id: str = "sdk_user") -> "Notebook": + """ + Creates a new notebook in the specified space. + """ + from .notebook import Notebook + + payload = { + "user_id": user_id, + "notebook_name": notebook_name, + "project_id": space_id, + "notebook_code": '{"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}' + } + + response = self._request("POST", "/api/notebook/create", json=payload) + + if response.get("success"): + nid = response.get("nid") + return Notebook(self, space_id, nid, notebook_name) + else: + raise RuntimeError(f"Failed to create notebook: {response.get('error')}") + + def get_notebook(self, space_id: str, notebook_id: str) -> "Notebook": + """ + Gets an existing notebook. + """ + from .notebook import Notebook + + # verify it exists + response = self._request("GET", "/api/notebook/get/", params={"nid": notebook_id, "project_id": space_id}) + + if response.get("success"): + notebook_data = response.get("notebook", {}) + name = notebook_data.get("notebook_name", "Untitled") + return Notebook(self, space_id, notebook_id, name) + else: + raise RuntimeError(f"Failed to get notebook: {response.get('error')}") \ No newline at end of file diff --git a/mantis_sdk/notebook.py b/mantis_sdk/notebook.py new file mode 100644 index 0000000..8224771 --- /dev/null +++ b/mantis_sdk/notebook.py @@ -0,0 +1,348 @@ +from typing import List, Optional, Dict, Any, TYPE_CHECKING +import time +import logging + +if TYPE_CHECKING: + from .client import MantisClient + +logger = logging.getLogger(__name__) + +class Cell: + """ + Represents a single cell in a Mantis Notebook. + """ + def __init__(self, notebook: "Notebook", index: int, cell_data: Dict[str, Any]): + self.notebook = notebook + self.index = index + self._data = cell_data + + @property + def cell_type(self) -> str: + return self._data.get("cell_type", "code") + + @property + def source(self) -> str: + source = self._data.get("source", "") + if isinstance(source, list): + return "".join(source) + return source + + @property + def outputs(self) -> List[Any]: + return self._data.get("outputs", []) + + @property + def metadata(self) -> Dict[str, Any]: + return self._data.get("metadata", {}) + + def update(self, content: str): + """ + Updates the content of this cell. + """ + self.notebook.update_cell(self.index, content) + # Refresh local data is handled by notebook.update_cell calling refresh, + # but we might want to update this instance's data directly or fetch fresh. + # For consistency, we'll rely on the notebook to refresh and we might need to re-fetch this cell object + # or update its internal data if the notebook updates it in place. + # For now, let's assume notebook.refresh() updates the list of cells, + # so this specific instance might become stale if we don't be careful. + # A better approach might be to have the notebook update this instance. + # But for simplicity, we'll just update the local data to match what we sent, + # and let the next refresh sync everything. + if isinstance(self._data["source"], list): + self._data["source"] = [content] + else: + self._data["source"] = content + + def execute(self): + """ + Executes this cell. + """ + return self.notebook.execute_cell(self.index) + + def delete(self): + """ + Deletes this cell from the notebook. + """ + self.notebook.delete_cell(self.index) + + def get_metadata(self) -> Dict[str, Any]: + """ + Returns the metadata of this cell. + """ + return self.metadata + + def __repr__(self): + return f"" + + +class Notebook: + """ + Represents a Mantis Notebook. + """ + def __init__(self, client: "MantisClient", space_id: str, nid: str, notebook_name: str = "Untitled"): + self.client = client + self.space_id = space_id + self.nid = nid + self.notebook_name = notebook_name + self.session_id: Optional[str] = None + self.cells: List[Cell] = [] + self._refresh_content() + + def _ensure_session(self): + """ + Ensures that a valid session exists. If not, creates one. + """ + if self.session_id: + # Check if session is valid + try: + response = self.client._request("POST", "/api/sessions/check", json={"session_id": self.session_id, "project_id": self.space_id}) + if response.get("success"): + return + except Exception: + logger.warning("Session check failed, creating new session.") + self.session_id = None + + # Create new session + # We need the user_id. The client might not expose it directly if it's just using a cookie. + # However, the frontend API `createSession` takes `user_id`. + # If the client doesn't have user_id, we might have a problem. + # Looking at client.py, it doesn't seem to store user_id. + # But `listNotebooks` in frontend takes `user_id`. + # Let's assume for now we can get it or it's not strictly required if the cookie is there, + # OR we need to fetch it. + # The `MantisClient` doesn't seem to have a `get_user_info` method. + # Wait, the frontend uses `useNotebookState` which calls `initialize(userID)`. + # The user ID comes from `useDataStore`. + # If the SDK is used with a token/cookie, maybe the backend infers the user? + # Let's check `client.py` again. It has `_authenticate` which is not implemented. + # It takes `cookie` in `__init__`. + # If I look at `api/sessions/create/` payload in `notebookApi.ts`: `user_id: userId`. + # I might need to ask the user or fetch it. + # For now, I will try to fetch it from a "whoami" endpoint if it exists, or assume the user knows it. + # But the `MantisClient` doesn't have it. + # Let's look at `client.py` imports. Nothing special. + # I'll assume for now that I can pass a dummy user_id or that the backend handles it if missing/from cookie. + # Actually, looking at `notebookApi.ts`, `createSession` sends `user_id`. + # If I don't have it, I might fail. + # Let's try to list notebooks to see if we can get it? No, list requires user_id too. + # Maybe `get_spaces`? + # Let's assume the user provides it or I can get it. + # Actually, I'll add a `user_id` parameter to `create_notebook` in `client.py` and store it. + # But wait, `MantisClient` is initialized with just base_url and cookie. + # I'll assume the cookie is enough for auth, but the API explicitly asks for user_id in the body. + # I will try to use a placeholder or maybe the client should have it. + # Let's check `frontend_reference_code/notebookApi.ts` again. + # `createSession` payload: `{ user_id: userId, nid: nid, broker_token: brokerToken }`. + # I will use a placeholder "sdk_user" if not available, or maybe I should check if there is an endpoint to get current user. + # Since I can't check the backend, I'll assume I need to pass it. + # I'll add `user_id` to `Notebook` init and `MantisClient` methods. + + # For now, I'll try to create session without user_id or with a dummy one if strict. + # But wait, `MantisClient` doesn't have `user_id`. + # I will check if `get_spaces` returns user info? Unlikely. + # I'll proceed with assuming I can pass a dummy or the user needs to provide it. + # I'll add `user_id` to `MantisClient` init optionally? + # Or just pass it to `create_notebook`. + pass + + def _create_session(self): + # This is a helper for _ensure_session + # We need user_id. I'll try to get it from client if I add it there, or use a default. + # For now, I'll use "sdk_user" as a fallback. + user_id = getattr(self.client, "user_id", "sdk_user") + + payload = { + "user_id": user_id, + "nid": self.nid, + "project_id": self.space_id + } + response = self.client._request("POST", "/api/sessions/create", json=payload) + if response.get("success"): + self.session_id = response.get("session_id") + else: + raise RuntimeError(f"Failed to create session: {response.get('error')}") + + def _refresh_content(self): + """ + Refreshes the notebook content from the backend. + """ + if not self.session_id: + self._ensure_session() + if not self.session_id: + self._create_session() + + response = self.client._request("GET", "/api/notebook/content", params={"session_id": self.session_id, "project_id": self.space_id}) + + if response.get("success"): + content = response.get("content", {}) + if "cells" in content: + self.cells = [Cell(self, i, cell_data) for i, cell_data in enumerate(content.get("cells", []))] + else: + logger.warning("Received successful response but content is missing 'cells'. Keeping previous state.") + else: + # If session not found, try to recreate + if "session not found" in str(response.get("error", "")).lower(): + self.session_id = None + self._create_session() + # Retry once + response = self.client._request("GET", "/api/notebook/content", params={"session_id": self.session_id, "project_id": self.space_id}) + if response.get("success"): + content = response.get("content", {}) + if "cells" in content: + self.cells = [Cell(self, i, cell_data) for i, cell_data in enumerate(content.get("cells", []))] + else: + logger.warning("Received successful response (retry) but content is missing 'cells'. Keeping previous state.") + else: + raise RuntimeError(f"Failed to get notebook content: {response.get('error')}") + else: + raise RuntimeError(f"Failed to get notebook content: {response.get('error')}") + + def refresh(self): + self._refresh_content() + + def add_cell(self, content: str = "", cell_type: str = "code", position: int = -1) -> Cell: + """ + Adds a new cell to the notebook. + """ + if not self.session_id: + self._create_session() + + payload = { + "session_id": self.session_id, + "cell_type": cell_type, + "content": content, + "position": position, + "project_id": self.space_id + } + + response = self.client._request("POST", "/api/notebook/add_cell", json=payload) + if response.get("success"): + self._refresh_content() + # Return the newly created cell. + # If position was -1, it's the last one. + # If position was specified, it's at that index (assuming 0-based index from backend matches). + if position == -1: + return self.cells[-1] + else: + # If position is within bounds, return that cell. + # Note: Backend might insert at position, shifting others. + # We should probably trust the index we passed if it's valid. + if 0 <= position < len(self.cells): + return self.cells[position] + return self.cells[-1] # Fallback + else: + raise RuntimeError(f"Failed to add cell: {response.get('error')}") + + def delete_cell(self, index: int): + """ + Deletes a cell by index. + """ + if not self.session_id: + self._create_session() + + payload = { + "session_id": self.session_id, + "position": index, + "project_id": self.space_id + } + + response = self.client._request("POST", "/api/notebook/delete_cell", json=payload) + if response.get("success"): + self._refresh_content() + else: + raise RuntimeError(f"Failed to delete cell: {response.get('error')}") + + def update_cell(self, index: int, content: str): + """ + Updates the content of a cell. + """ + if not self.session_id: + self._create_session() + + payload = { + "session_id": self.session_id, + "cell_index": index, + "content": content, + "project_id": self.space_id + } + + response = self.client._request("POST", "/api/notebook/edit_cell", json=payload) + if response.get("success"): + self._refresh_content() + else: + raise RuntimeError(f"Failed to update cell: {response.get('error')}") + + def execute_cell(self, index: int): + """ + Executes a cell by index. + """ + if not self.session_id: + self._create_session() + + payload = { + "session_id": self.session_id, + "cell_index": index, + "project_id": self.space_id + } + + # Execute is async in backend, we might need to poll for results. + # The frontend polls `getNotebookContent`. + # Here we can do a simple poll loop waiting for execution to finish. + # But how do we know it finished? + # The frontend checks `metadata.executing`. + + response = self.client._request("POST", "/api/sessions/execute", json=payload) + if not response.get("success"): + raise RuntimeError(f"Failed to execute cell: {response.get('error')}") + + # Poll for completion + while True: + time.sleep(0.5) + self._refresh_content() + + if index >= len(self.cells): + logger.warning(f"Cell index {index} out of range (cells count: {len(self.cells)}). Waiting for refresh...") + continue + + cell = self.cells[index] + # Check if executing + # Note: metadata might be None + meta = cell.metadata or {} + if not meta.get("executing", False): + # Execution finished + return cell.outputs + + def execute_all(self): + """ + Executes all code cells in the notebook. + """ + results = [] + for i, cell in enumerate(self.cells): + if cell.cell_type == "code": + results.append(self.execute_cell(i)) + return results + + def get_cell(self, index: int) -> Cell: + """ + Returns the cell at the specified index. + """ + if 0 <= index < len(self.cells): + return self.cells[index] + raise IndexError("Cell index out of range") + + def delete(self): + """ + Deletes the notebook and its session. + """ + if self.session_id: + payload = { + "nid": self.nid, + "session_id": self.session_id, + "project_id": self.space_id + } + self.client._request("POST", "/api/notebook/drop", json=payload) + self.session_id = None + else: + # If no session, just drop the notebook + self.client._request("POST", "/api/notebook/drop", json={"nid": self.nid, "project_id": self.space_id}) diff --git a/mantis_sdk/requirements.txt b/mantis_sdk/requirements.txt index 9a352b1..3bcd514 100644 --- a/mantis_sdk/requirements.txt +++ b/mantis_sdk/requirements.txt @@ -39,7 +39,6 @@ ptyprocess==0.7.0 pure_eval==0.2.3 pyasn1==0.6.1 pyasn1_modules==0.4.1 -pyee==11.1.1 Pygments==2.19.1 pyparsing==3.2.1 pyppeteer @@ -58,5 +57,4 @@ typing_extensions==4.12.2 tzdata==2025.1 urllib3==1.26.20 wcwidth==0.2.13 -websockets==10.4 -zipp==3.21.0 +websockets==10.4 \ No newline at end of file diff --git a/output_plot.png b/output_plot.png new file mode 100644 index 0000000000000000000000000000000000000000..2141f7237804fee51282475d4e7c41792fbc863a GIT binary patch literal 43493 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 I%$!aC5BOck`Tzg` literal 0 HcmV?d00001 From ce1c724ee67a15d3a2775d4aaee319766c8031bf Mon Sep 17 00:00:00 2001 From: LucaVor Date: Sun, 23 Nov 2025 16:05:04 -0500 Subject: [PATCH 3/4] okay --- mantis_sdk/notebook.py | 95 ++++++++---------------------------------- 1 file changed, 17 insertions(+), 78 deletions(-) diff --git a/mantis_sdk/notebook.py b/mantis_sdk/notebook.py index 8224771..abe6d81 100644 --- a/mantis_sdk/notebook.py +++ b/mantis_sdk/notebook.py @@ -40,15 +40,7 @@ def update(self, content: str): Updates the content of this cell. """ self.notebook.update_cell(self.index, content) - # Refresh local data is handled by notebook.update_cell calling refresh, - # but we might want to update this instance's data directly or fetch fresh. - # For consistency, we'll rely on the notebook to refresh and we might need to re-fetch this cell object - # or update its internal data if the notebook updates it in place. - # For now, let's assume notebook.refresh() updates the list of cells, - # so this specific instance might become stale if we don't be careful. - # A better approach might be to have the notebook update this instance. - # But for simplicity, we'll just update the local data to match what we sent, - # and let the next refresh sync everything. + if isinstance(self._data["source"], list): self._data["source"] = [content] else: @@ -103,65 +95,19 @@ def _ensure_session(self): logger.warning("Session check failed, creating new session.") self.session_id = None - # Create new session - # We need the user_id. The client might not expose it directly if it's just using a cookie. - # However, the frontend API `createSession` takes `user_id`. - # If the client doesn't have user_id, we might have a problem. - # Looking at client.py, it doesn't seem to store user_id. - # But `listNotebooks` in frontend takes `user_id`. - # Let's assume for now we can get it or it's not strictly required if the cookie is there, - # OR we need to fetch it. - # The `MantisClient` doesn't seem to have a `get_user_info` method. - # Wait, the frontend uses `useNotebookState` which calls `initialize(userID)`. - # The user ID comes from `useDataStore`. - # If the SDK is used with a token/cookie, maybe the backend infers the user? - # Let's check `client.py` again. It has `_authenticate` which is not implemented. - # It takes `cookie` in `__init__`. - # If I look at `api/sessions/create/` payload in `notebookApi.ts`: `user_id: userId`. - # I might need to ask the user or fetch it. - # For now, I will try to fetch it from a "whoami" endpoint if it exists, or assume the user knows it. - # But the `MantisClient` doesn't have it. - # Let's look at `client.py` imports. Nothing special. - # I'll assume for now that I can pass a dummy user_id or that the backend handles it if missing/from cookie. - # Actually, looking at `notebookApi.ts`, `createSession` sends `user_id`. - # If I don't have it, I might fail. - # Let's try to list notebooks to see if we can get it? No, list requires user_id too. - # Maybe `get_spaces`? - # Let's assume the user provides it or I can get it. - # Actually, I'll add a `user_id` parameter to `create_notebook` in `client.py` and store it. - # But wait, `MantisClient` is initialized with just base_url and cookie. - # I'll assume the cookie is enough for auth, but the API explicitly asks for user_id in the body. - # I will try to use a placeholder or maybe the client should have it. - # Let's check `frontend_reference_code/notebookApi.ts` again. - # `createSession` payload: `{ user_id: userId, nid: nid, broker_token: brokerToken }`. - # I will use a placeholder "sdk_user" if not available, or maybe I should check if there is an endpoint to get current user. - # Since I can't check the backend, I'll assume I need to pass it. - # I'll add `user_id` to `Notebook` init and `MantisClient` methods. - - # For now, I'll try to create session without user_id or with a dummy one if strict. - # But wait, `MantisClient` doesn't have `user_id`. - # I will check if `get_spaces` returns user info? Unlikely. - # I'll proceed with assuming I can pass a dummy or the user needs to provide it. - # I'll add `user_id` to `MantisClient` init optionally? - # Or just pass it to `create_notebook`. - pass - def _create_session(self): - # This is a helper for _ensure_session - # We need user_id. I'll try to get it from client if I add it there, or use a default. - # For now, I'll use "sdk_user" as a fallback. - user_id = getattr(self.client, "user_id", "sdk_user") - - payload = { - "user_id": user_id, - "nid": self.nid, - "project_id": self.space_id - } - response = self.client._request("POST", "/api/sessions/create", json=payload) - if response.get("success"): - self.session_id = response.get("session_id") - else: - raise RuntimeError(f"Failed to create session: {response.get('error')}") + user_id = getattr(self.client, "user_id", "sdk_user") + + payload = { + "user_id": user_id, + "nid": self.nid, + "project_id": self.space_id + } + response = self.client._request("POST", "/api/sessions/create", json=payload) + if response.get("success"): + self.session_id = response.get("session_id") + else: + raise RuntimeError(f"Failed to create session: {response.get('error')}") def _refresh_content(self): """ @@ -286,17 +232,10 @@ def execute_cell(self, index: int): "project_id": self.space_id } - # Execute is async in backend, we might need to poll for results. - # The frontend polls `getNotebookContent`. - # Here we can do a simple poll loop waiting for execution to finish. - # But how do we know it finished? - # The frontend checks `metadata.executing`. - response = self.client._request("POST", "/api/sessions/execute", json=payload) if not response.get("success"): raise RuntimeError(f"Failed to execute cell: {response.get('error')}") - # Poll for completion while True: time.sleep(0.5) self._refresh_content() @@ -306,11 +245,11 @@ def execute_cell(self, index: int): continue cell = self.cells[index] - # Check if executing - # Note: metadata might be None + # check if executing + # note: metadata might be None meta = cell.metadata or {} if not meta.get("executing", False): - # Execution finished + # execution finished return cell.outputs def execute_all(self): @@ -344,5 +283,5 @@ def delete(self): self.client._request("POST", "/api/notebook/drop", json=payload) self.session_id = None else: - # If no session, just drop the notebook + # if no session, just drop the notebook self.client._request("POST", "/api/notebook/drop", json={"nid": self.nid, "project_id": self.space_id}) From 4e728ef553703d914853868e81f989864743c419 Mon Sep 17 00:00:00 2001 From: LucaVor Date: Fri, 13 Mar 2026 13:10:32 -0400 Subject: [PATCH 4/4] nice --- mantis_sdk/client.py | 73 +++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/mantis_sdk/client.py b/mantis_sdk/client.py index 58b164c..c1b1981 100644 --- a/mantis_sdk/client.py +++ b/mantis_sdk/client.py @@ -42,34 +42,74 @@ class ReducerModels: class SpaceCreationError (Exception): pass +class BackendModeError(Exception): + pass + class MantisClient: """ SDK for interacting with your Django API. """ - def __init__(self, base_url: str, cookie: str, space_id: str, config: Optional[ConfigurationManager] = None): + def __init__(self, base_url: str, cookie: str = None, space_id: str = None, + config: Optional[ConfigurationManager] = None, + backend_mode: bool = False, + unlocked_token: str = None, + unlocked_uid: str = None): """ Initialize the client. :param base_url: Base URL of the API. - :param cookie: Authentication cookie. + :param cookie: Authentication cookie (not used in backend mode). :param space_id: The space ID to authenticate against. :param config: Optional configuration manager. + :param backend_mode: If True, authenticate via X-unlocked-token/X-unlocked-uid headers. + :param unlocked_token: Required when backend_mode is True. + :param unlocked_uid: Required when backend_mode is True. """ self.base_url = base_url.rstrip("/") + self.backend_mode = backend_mode if config is None: self.config = ConfigurationManager() else: self.config = config + + if self.backend_mode: + if not unlocked_token or not unlocked_uid: + raise ValueError("unlocked_token and unlocked_uid are required when backend_mode is True") + self.unlocked_token = unlocked_token + self.unlocked_uid = unlocked_uid + self.cookie = None + self.space_id = space_id + self.vscode_token = None + else: + self.cookie = cookie + self.space_id = space_id + self.vscode_token = None + + if self.cookie: + self._authenticate() - self.cookie = cookie - self.space_id = space_id - self.vscode_token = None - - if self.cookie: - self._authenticate() - + def _get_auth_headers(self) -> dict: + """returns auth headers appropriate for the current mode. + use these for both HTTP and WSS requests.""" + if self.backend_mode: + return { + "X-unlocked-token": self.unlocked_token, + "X-unlocked-uid": self.unlocked_uid, + } + headers = {"cookie": self.cookie} + if self.vscode_token: + headers["VSCode-Token"] = self.vscode_token + return headers + + def _check_frontend_allowed(self): + """raises BackendModeError if frontend features (Space/host) are unavailable.""" + if self.backend_mode: + raise BackendModeError( + "Frontend features (Space, host-based navigation) are disabled in backend mode." + ) + def _authenticate(self): """ Authenticates by creating a VSCode token. @@ -114,12 +154,8 @@ def remove_slash (s: str): if rm_slash: url = url.rstrip("/") - headers = {"cookie": self.cookie} - - # add VSCode token if available - if self.vscode_token: - headers["VSCode-Token"] = self.vscode_token - + headers = self._get_auth_headers() + if method.upper() == "GET": headers["Cache-Control"] = "no-cache" # Prevent caching for GET requests params = kwargs.get("params", {}) @@ -350,10 +386,11 @@ async def open_space(self, space_id: str) -> "Space": """ Asynchronously open a space by ID. """ + self._check_frontend_allowed() return await Space.create( - space_id, - _request=self._request, - cookie=self.cookie, + space_id, + _request=self._request, + cookie=self.cookie, config=self.config )