mito-ai 0.1.37__py3-none-any.whl → 0.1.38__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 mito-ai might be problematic. Click here for more details.

Files changed (40) hide show
  1. mito_ai/__init__.py +9 -1
  2. mito_ai/_version.py +1 -1
  3. mito_ai/app_builder/handlers.py +30 -30
  4. mito_ai/app_builder/models.py +1 -1
  5. mito_ai/log/handlers.py +10 -3
  6. mito_ai/log/urls.py +3 -3
  7. mito_ai/streamlit_conversion/streamlit_agent_handler.py +22 -6
  8. mito_ai/streamlit_conversion/streamlit_system_prompt.py +11 -0
  9. mito_ai/streamlit_conversion/streamlit_utils.py +8 -6
  10. mito_ai/streamlit_conversion/validate_and_run_streamlit_code.py +1 -0
  11. mito_ai/streamlit_preview/__init__.py +7 -0
  12. mito_ai/streamlit_preview/handlers.py +161 -0
  13. mito_ai/streamlit_preview/manager.py +159 -0
  14. mito_ai/streamlit_preview/urls.py +22 -0
  15. mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +16 -15
  16. mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +4 -5
  17. mito_ai/tests/streamlit_preview/test_streamlit_preview_manager.py +302 -0
  18. mito_ai/utils/telemetry_utils.py +28 -1
  19. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
  20. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
  21. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +1 -1
  22. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +6 -1
  23. mito_ai-0.1.37.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.831f63b48760c7119b9b.js → mito_ai-0.1.38.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.5d1d7c234e2dc7c9d97b.js +542 -56
  24. mito_ai-0.1.38.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.5d1d7c234e2dc7c9d97b.js.map +1 -0
  25. mito_ai-0.1.37.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.93ecc9bc0edba61535cc.js → mito_ai-0.1.38.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.bcce4ea34631acf6dbbe.js +5 -5
  26. mito_ai-0.1.37.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.93ecc9bc0edba61535cc.js.map → mito_ai-0.1.38.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.bcce4ea34631acf6dbbe.js.map +1 -1
  27. {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/METADATA +1 -1
  28. {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/RECORD +39 -34
  29. mito_ai-0.1.37.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.831f63b48760c7119b9b.js.map +0 -1
  30. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
  31. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
  32. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +0 -0
  33. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +0 -0
  34. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js +0 -0
  35. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js.map +0 -0
  36. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
  37. {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +0 -0
  38. {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/WHEEL +0 -0
  39. {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/entry_points.txt +0 -0
  40. {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/licenses/LICENSE +0 -0
mito_ai/__init__.py CHANGED
@@ -6,12 +6,14 @@ from jupyter_server.utils import url_path_join
6
6
  from mito_ai.completions.handlers import CompletionHandler
7
7
  from mito_ai.completions.providers import OpenAIProvider
8
8
  from mito_ai.app_builder.handlers import AppBuilderHandler
9
+ from mito_ai.streamlit_preview.handlers import StreamlitPreviewHandler
9
10
  from mito_ai.log.urls import get_log_urls
10
11
  from mito_ai.version_check import VersionCheckHandler
11
12
  from mito_ai.db.urls import get_db_urls
12
13
  from mito_ai.settings.urls import get_settings_urls
13
14
  from mito_ai.rules.urls import get_rules_urls
14
15
  from mito_ai.auth.urls import get_auth_urls
16
+ from mito_ai.streamlit_preview.urls import get_streamlit_preview_urls
15
17
  try:
16
18
  from _version import __version__
17
19
  except ImportError:
@@ -58,6 +60,11 @@ def _load_jupyter_server_extension(server_app) -> None: # type: ignore
58
60
  AppBuilderHandler,
59
61
  {}
60
62
  ),
63
+ (
64
+ url_path_join(base_url, "mito-ai", "streamlit-preview"),
65
+ StreamlitPreviewHandler,
66
+ {}
67
+ ),
61
68
  (
62
69
  url_path_join(base_url, "mito-ai", "version-check"),
63
70
  VersionCheckHandler,
@@ -69,8 +76,9 @@ def _load_jupyter_server_extension(server_app) -> None: # type: ignore
69
76
  handlers.extend(get_db_urls(base_url)) # type: ignore
70
77
  handlers.extend(get_settings_urls(base_url)) # type: ignore
71
78
  handlers.extend(get_rules_urls(base_url)) # type: ignore
72
- handlers.extend(get_log_urls(base_url)) # type: ignore
79
+ handlers.extend(get_log_urls(base_url, open_ai_provider.key_type)) # type: ignore
73
80
  handlers.extend(get_auth_urls(base_url)) # type: ignore
81
+ handlers.extend(get_streamlit_preview_urls(base_url)) # type: ignore
74
82
 
75
83
  web_app.add_handlers(host_pattern, handlers)
76
84
  server_app.log.info("Loaded the mito_ai server extension")
mito_ai/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # This file is auto-generated by Hatchling. As such, do not:
2
2
  # - modify
3
3
  # - track in version control e.g. be sure to add to .gitignore
4
- __version__ = VERSION = '0.1.37'
4
+ __version__ = VERSION = '0.1.38'
@@ -13,6 +13,7 @@ from mito_ai.utils.websocket_base import BaseWebSocketHandler
13
13
  from mito_ai.app_builder.models import (
14
14
  BuildAppReply,
15
15
  AppBuilderError,
16
+ BuildAppRequest,
16
17
  ErrorMessage,
17
18
  MessageType
18
19
  )
@@ -74,7 +75,8 @@ class AppBuilderHandler(BaseWebSocketHandler):
74
75
 
75
76
  if message_type == MessageType.BUILD_APP.value:
76
77
  # Handle build app request
77
- await self._handle_build_app(parsed_message)
78
+ build_app_request = BuildAppRequest(**parsed_message)
79
+ await self._handle_build_app(build_app_request)
78
80
  else:
79
81
  self.log.error(f"Unknown message type: {message_type}")
80
82
  error = AppBuilderError(
@@ -98,25 +100,24 @@ class AppBuilderHandler(BaseWebSocketHandler):
98
100
  latency_ms = round((time.time() - start) * 1000)
99
101
  self.log.info(f"App builder handler processed in {latency_ms} ms.")
100
102
 
101
- async def _handle_build_app(self, message: dict) -> None:
103
+ async def _handle_build_app(self, message: BuildAppRequest) -> None:
102
104
  """Handle a build app request.
103
105
 
104
106
  Args:
105
107
  message: The parsed message.
106
108
  """
107
- message_id = message.get('message_id', '') # Default to empty string if not present
108
- notebook_path = message.get('notebook_path')
109
- app_path = message.get('app_path')
110
- jwt_token = message.get('jwt_token', '') # Extract JWT token from request, default to empty string
109
+ message_id = message.message_id
110
+ notebook_path = message.notebook_path
111
+ jwt_token = message.jwt_token
111
112
 
112
113
  if not message_id:
113
114
  self.log.error("Missing message_id in request")
114
115
  return
115
116
 
116
- if not app_path:
117
+ if not notebook_path:
117
118
  error = AppBuilderError(
118
119
  error_type="InvalidRequest",
119
- title="Missing 'path' parameter"
120
+ title="Missing 'notebook_path' parameter"
120
121
  )
121
122
  self.reply(BuildAppReply(
122
123
  parent_id=message_id,
@@ -126,31 +127,30 @@ class AppBuilderHandler(BaseWebSocketHandler):
126
127
  return
127
128
 
128
129
  # Validate JWT token if provided
129
- if jwt_token and jwt_token != 'placeholder-jwt-token':
130
- self.log.info(f"Validating JWT token: {jwt_token[:20]}...")
131
- is_valid = self._validate_jwt_token(jwt_token)
132
- if not is_valid:
133
- self.log.error("JWT token validation failed")
134
- error = AppBuilderError(
135
- error_type="Unauthorized",
136
- title="Invalid authentication token",
137
- hint="Please sign in again to deploy your app."
138
- )
139
- self.reply(BuildAppReply(
140
- parent_id=message_id,
141
- url="",
142
- error=error
143
- ))
144
- return
145
- else:
146
- self.log.info("JWT token validation successful")
130
+ token_preview = jwt_token[:20] if jwt_token else "No token provided"
131
+ self.log.info(f"Validating JWT token: {token_preview}...")
132
+ is_valid = self._validate_jwt_token(jwt_token) if jwt_token else False
133
+ if not is_valid or not jwt_token:
134
+ self.log.error("JWT token validation failed")
135
+ error = AppBuilderError(
136
+ error_type="Unauthorized",
137
+ title="Invalid authentication token",
138
+ hint="Please sign in again to deploy your app."
139
+ )
140
+ self.reply(BuildAppReply(
141
+ parent_id=message_id,
142
+ url="",
143
+ error=error
144
+ ))
145
+ return
147
146
  else:
148
- self.log.warning("No JWT token provided or using placeholder token")
149
-
147
+ self.log.info("JWT token validation successful")
148
+
150
149
  try:
151
150
 
152
- success_flag, result_message = await streamlit_handler(str(notebook_path) if notebook_path else "", app_path)
153
- if not success_flag:
151
+ notebook_path = str(notebook_path) if notebook_path else ""
152
+ success_flag, app_path, result_message = await streamlit_handler(notebook_path)
153
+ if not success_flag or app_path is None:
154
154
  raise Exception(result_message)
155
155
 
156
156
  deploy_url = await self._deploy_app(app_path, jwt_token)
@@ -65,7 +65,7 @@ class BuildAppRequest:
65
65
  message_id: str
66
66
 
67
67
  # Path to the app file.
68
- path: str
68
+ notebook_path: str
69
69
 
70
70
  # JWT token for authorization.
71
71
  jwt_token: Optional[str] = None
mito_ai/log/handlers.py CHANGED
@@ -3,16 +3,22 @@
3
3
 
4
4
  from dataclasses import dataclass
5
5
  import json
6
- from typing import Any, Final
6
+ from typing import Any, Final, Literal
7
7
  import tornado
8
8
  import os
9
9
  from jupyter_server.base.handlers import APIHandler
10
- from mito_ai.utils.telemetry_utils import log
10
+ from mito_ai.utils.telemetry_utils import MITO_SERVER_KEY, USER_KEY, log
11
11
 
12
12
 
13
13
  class LogHandler(APIHandler):
14
14
  """Handler for logging"""
15
15
 
16
+ def initialize(self, key_type: Literal['mito_server_key', 'user_key']) -> None:
17
+ """Initialize the log handler"""
18
+
19
+ # The key_type is required so that we know if we can log pro users
20
+ self.key_type = key_type
21
+
16
22
  @tornado.web.authenticated
17
23
  def put(self) -> None:
18
24
  """Log an event"""
@@ -26,6 +32,7 @@ class LogHandler(APIHandler):
26
32
  log_event = data['log_event']
27
33
  params = data.get('params', {})
28
34
 
29
- log(log_event, params)
35
+ key_type = MITO_SERVER_KEY if self.key_type == "mito_server_key" else USER_KEY
36
+ log(log_event, params, key_type=key_type)
30
37
 
31
38
 
mito_ai/log/urls.py CHANGED
@@ -5,7 +5,7 @@ from typing import Any, List, Tuple
5
5
  from jupyter_server.utils import url_path_join
6
6
  from mito_ai.log.handlers import LogHandler
7
7
 
8
- def get_log_urls(base_url: str) -> List[Tuple[str, Any, dict]]:
8
+ def get_log_urls(base_url: str, key_type: str) -> List[Tuple[str, Any, dict]]:
9
9
  """Get all log related URL patterns.
10
10
 
11
11
  Args:
@@ -15,7 +15,7 @@ def get_log_urls(base_url: str) -> List[Tuple[str, Any, dict]]:
15
15
  List of (url_pattern, handler_class, handler_kwargs) tuples
16
16
  """
17
17
  BASE_URL = base_url + "/mito-ai"
18
-
18
+
19
19
  return [
20
- (url_path_join(BASE_URL, "log"), LogHandler, {}),
20
+ (url_path_join(BASE_URL, "log"), LogHandler, {"key_type": key_type}),
21
21
  ]
@@ -2,8 +2,9 @@
2
2
  # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
3
 
4
4
  import logging
5
+ import os
5
6
  from anthropic.types import MessageParam
6
- from typing import List, Tuple, cast
7
+ from typing import List, Optional, Tuple, cast
7
8
 
8
9
  from mito_ai.logger import get_logger
9
10
  from mito_ai.streamlit_conversion.streamlit_system_prompt import streamlit_system_prompt
@@ -11,6 +12,7 @@ from mito_ai.streamlit_conversion.validate_and_run_streamlit_code import streaml
11
12
  from mito_ai.streamlit_conversion.streamlit_utils import extract_code_blocks, create_app_file, parse_jupyter_notebook_to_extract_required_content
12
13
  from mito_ai.utils.anthropic_utils import stream_anthropic_completion_from_mito_server
13
14
  from mito_ai.completions.models import MessageType
15
+ from mito_ai.utils.telemetry_utils import log_streamlit_app_creation_error, log_streamlit_app_creation_retry, log_streamlit_app_creation_success
14
16
 
15
17
  STREAMLIT_AI_MODEL = "claude-3-5-haiku-latest"
16
18
 
@@ -93,20 +95,34 @@ class StreamlitCodeGeneration:
93
95
  return converted_code
94
96
 
95
97
 
96
- async def streamlit_handler(notebook_path: str, app_path: str) -> Tuple[bool, str]:
98
+ async def streamlit_handler(notebook_path: str) -> Tuple[bool, Optional[str], str]:
97
99
  """Handler function for streamlit code generation and validation"""
98
100
  notebook_code = parse_jupyter_notebook_to_extract_required_content(notebook_path)
99
101
  streamlit_code_generator = StreamlitCodeGeneration(notebook_code)
100
102
  streamlit_code = await streamlit_code_generator.generate_streamlit_code()
101
103
  has_validation_error, error = streamlit_code_validator(streamlit_code)
104
+
105
+
102
106
  tries = 0
103
107
  while has_validation_error and tries < 5:
104
108
  streamlit_code = await streamlit_code_generator.correct_error_in_generation(error)
105
109
  has_validation_error, error = streamlit_code_validator(streamlit_code)
110
+
111
+ if has_validation_error:
112
+ # TODO: We can't easily get the key type here, so for the beta release
113
+ # we are just defaulting to the mito server key since that is by far the most common.
114
+ log_streamlit_app_creation_retry('mito_server_key', MessageType.STREAMLIT_CONVERSION, error)
106
115
  tries+=1
107
116
 
108
117
  if has_validation_error:
109
- return False, "Error generating streamlit code by agent"
110
-
111
- success_flag, message = create_app_file(app_path, streamlit_code)
112
- return success_flag, message
118
+ log_streamlit_app_creation_error('mito_server_key', MessageType.STREAMLIT_CONVERSION, error)
119
+ return False, None, "Error generating streamlit code by agent"
120
+
121
+ app_directory = os.path.dirname(notebook_path)
122
+ success_flag, app_path, message = create_app_file(app_directory, streamlit_code)
123
+
124
+ if not success_flag:
125
+ log_streamlit_app_creation_error('mito_server_key', MessageType.STREAMLIT_CONVERSION, message)
126
+
127
+ log_streamlit_app_creation_success('mito_server_key', MessageType.STREAMLIT_CONVERSION)
128
+ return success_flag, app_path, message
@@ -28,6 +28,17 @@ CODE STRUCTURE:
28
28
  - Handle data loading and processing
29
29
  - Organize content with clear sections and headers
30
30
  - Include comments explaining key sections
31
+ - Always include the following code at the top of the file so the user does not use the wrong deploy button
32
+ ```python
33
+ st.markdown(\"\"\"
34
+ <style>
35
+ #MainMenu {visibility: hidden;}
36
+ .stAppDeployButton {display:none;}
37
+ footer {visibility: hidden;}
38
+ .stMainBlockContainer {padding: 2rem 1rem 2rem 1rem;}
39
+ </style>
40
+ \"\"\", unsafe_allow_html=True)
41
+ ```
31
42
 
32
43
  OUTPUT FORMAT:
33
44
  - Provide the complete app.py file code
@@ -3,7 +3,8 @@
3
3
 
4
4
  import re
5
5
  import json
6
- from typing import Dict, Tuple, Any
6
+ import os
7
+ from typing import Dict, Optional, Tuple, Any
7
8
 
8
9
  def extract_code_blocks(message_content: str) -> str:
9
10
  """
@@ -27,7 +28,7 @@ def extract_code_blocks(message_content: str) -> str:
27
28
  return '\n'.join(matches)
28
29
 
29
30
 
30
- def create_app_file(file_path: str, code: str) -> Tuple[bool, str]:
31
+ def create_app_file(app_directory: str, code: str) -> Tuple[bool, Optional[str], str]:
31
32
  """
32
33
  Create app.py file and write code to it with error handling
33
34
 
@@ -40,13 +41,14 @@ def create_app_file(file_path: str, code: str) -> Tuple[bool, str]:
40
41
 
41
42
  """
42
43
  try:
43
- with open(file_path+"/app.py", 'w') as f:
44
+ app_path = os.path.join(app_directory, "app.py")
45
+ with open(app_path, 'w') as f:
44
46
  f.write(code)
45
- return True, f"Successfully created {file_path}"
47
+ return True, app_path, f"Successfully created {app_directory}"
46
48
  except IOError as e:
47
- return False, f"Error creating file: {str(e)}"
49
+ return False, None, f"Error creating file: {str(e)}"
48
50
  except Exception as e:
49
- return False, f"Unexpected error: {str(e)}"
51
+ return False, None, f"Unexpected error: {str(e)}"
50
52
 
51
53
 
52
54
  def parse_jupyter_notebook_to_extract_required_content(notebook_path: str) -> Dict[str, Any]:
@@ -42,6 +42,7 @@ class StreamlitValidator:
42
42
  self.temp_dir = tempfile.mkdtemp()
43
43
  if self.temp_dir is None:
44
44
  raise RuntimeError("Failed to create temporary directory")
45
+
45
46
  app_path = os.path.join(self.temp_dir, "app.py")
46
47
 
47
48
  with open(app_path, 'w') as f:
@@ -0,0 +1,7 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ from .manager import get_preview_manager
5
+ from .handlers import StreamlitPreviewHandler
6
+
7
+ __all__ = ['get_preview_manager', 'StreamlitPreviewHandler']
@@ -0,0 +1,161 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ import os
5
+ import tempfile
6
+ import uuid
7
+ import tornado
8
+ from jupyter_server.base.handlers import APIHandler
9
+ from mito_ai.streamlit_conversion.streamlit_agent_handler import streamlit_handler
10
+ from mito_ai.streamlit_preview.manager import get_preview_manager
11
+ from mito_ai.utils.create import initialize_user
12
+
13
+
14
+ class StreamlitPreviewHandler(APIHandler):
15
+ """REST handler for streamlit preview operations."""
16
+
17
+ def initialize(self) -> None:
18
+ """Initialize the handler."""
19
+ self.preview_manager = get_preview_manager()
20
+
21
+ def _resolve_notebook_path(self, notebook_path: str) -> str:
22
+ """
23
+ Resolve the notebook path to an absolute path that can be found by the backend.
24
+
25
+ This method handles path resolution issues that can occur in different environments:
26
+
27
+ 1. **Test Environment**: Playwright tests create temporary directories with complex
28
+ paths like 'mitoai_ui_tests-app_builde-ab3a5-n-Test-Preview-as-Streamlit-chromium/'
29
+ that the backend can't directly access.
30
+
31
+ 2. **JupyterHub/Cloud Deployments**: In cloud environments, users may have notebooks
32
+ in subdirectories that aren't immediately accessible from the server root.
33
+
34
+ 3. **Docker Containers**: When running in containers, the working directory and
35
+ file paths may not align with what the frontend reports.
36
+
37
+ 4. **Multi-user Environments**: In enterprise deployments, users may have notebooks
38
+ in user-specific directories that require path resolution.
39
+
40
+ The method tries multiple strategies:
41
+ 1. If the path is already absolute, return it as-is
42
+ 2. Try to resolve relative to the Jupyter server's root directory
43
+ 3. Search recursively through subdirectories for a file with the same name
44
+ 4. Return the original path if not found (will cause a clear error message)
45
+
46
+ Args:
47
+ notebook_path (str): The notebook path from the frontend (may be relative or absolute)
48
+
49
+ Returns:
50
+ str: The resolved absolute path to the notebook file
51
+ """
52
+ # If the path is already absolute, return it
53
+ if os.path.isabs(notebook_path):
54
+ return notebook_path
55
+
56
+ # Get the Jupyter server's root directory
57
+ server_root = self.settings.get('server_root_dir', os.getcwd())
58
+
59
+ # Try to find the notebook file in the server root
60
+ resolved_path = os.path.join(server_root, notebook_path)
61
+ if os.path.exists(resolved_path):
62
+ return resolved_path
63
+
64
+ # If not found, try to find it in subdirectories
65
+ # This handles cases where the notebook is in a subdirectory that the frontend
66
+ # doesn't know about, or where the path structure differs between frontend and backend
67
+ for root, dirs, files in os.walk(server_root):
68
+ if os.path.basename(notebook_path) in files:
69
+ return os.path.join(root, os.path.basename(notebook_path))
70
+
71
+ # If still not found, return the original path (will cause a clear error)
72
+ # This ensures we get a meaningful error message rather than a generic "file not found"
73
+ return notebook_path
74
+
75
+ @tornado.web.authenticated
76
+ async def post(self) -> None:
77
+ """Start a new streamlit preview.
78
+
79
+ Expected JSON body:
80
+ {
81
+ "notebook_path": "path/to/notebook.ipynb"
82
+ }
83
+
84
+ Returns:
85
+ {
86
+ "id": "preview_id",
87
+ "port": 8501,
88
+ "url": "http://localhost:8501"
89
+ }
90
+ """
91
+ try:
92
+ # Parse request body
93
+ body = self.get_json_body()
94
+ if body is None:
95
+ self.set_status(400)
96
+ self.finish({"error": 'Invalid or missing JSON body'})
97
+ return
98
+
99
+ notebook_path = body.get('notebook_path')
100
+
101
+ if not notebook_path:
102
+ self.set_status(400)
103
+ self.finish({"error": 'Missing notebook_path parameter'})
104
+ return
105
+
106
+ # Resolve the notebook path to find the actual file
107
+ resolved_notebook_path = self._resolve_notebook_path(notebook_path)
108
+
109
+ # Generate preview ID
110
+ preview_id = str(uuid.uuid4())
111
+
112
+ # Generate streamlit code using existing handler
113
+ success, app_path, message = await streamlit_handler(resolved_notebook_path)
114
+
115
+ if not success or app_path is None:
116
+ self.set_status(500)
117
+ self.finish({"error": f'Failed to generate streamlit code: {message}'})
118
+ return
119
+
120
+ # Start streamlit preview
121
+ resolved_app_directory = os.path.dirname(resolved_notebook_path)
122
+ success, message, port = self.preview_manager.start_streamlit_preview(resolved_app_directory, preview_id)
123
+
124
+ if not success:
125
+ self.set_status(500)
126
+ self.finish({"error": f'Failed to start preview: {message}'})
127
+ return
128
+
129
+ # Return success response - APIHandler automatically handles JSON serialization
130
+ self.finish({
131
+ 'id': preview_id,
132
+ 'port': port,
133
+ 'url': f'http://localhost:{port}'
134
+ })
135
+
136
+ except Exception as e:
137
+ print(f"Error in streamlit preview handler: {e}")
138
+ self.set_status(500)
139
+ self.finish({"error": f'Internal server error: {str(e)}'})
140
+
141
+ @tornado.web.authenticated
142
+ def delete(self, preview_id: str) -> None:
143
+ """Stop a streamlit preview."""
144
+ try:
145
+ if not preview_id:
146
+ self.set_status(400)
147
+ self.finish({"error": 'Missing preview_id parameter'})
148
+ return
149
+
150
+ # Stop the preview
151
+ stopped = self.preview_manager.stop_preview(preview_id)
152
+
153
+ if stopped:
154
+ self.set_status(204) # No content
155
+ else:
156
+ self.set_status(404)
157
+ self.finish({"error": f'Preview {preview_id} not found'})
158
+
159
+ except Exception as e:
160
+ self.set_status(500)
161
+ self.finish({"error": f'Internal server error: {str(e)}'})
@@ -0,0 +1,159 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ import os
5
+ import socket
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ import threading
10
+ import requests
11
+ from typing import Dict, Optional, Tuple
12
+ from dataclasses import dataclass
13
+ from mito_ai.logger import get_logger
14
+
15
+
16
+ @dataclass
17
+ class PreviewProcess:
18
+ """Data class to track a streamlit preview process."""
19
+ proc: subprocess.Popen
20
+ port: int
21
+
22
+
23
+ class StreamlitPreviewManager:
24
+ """Manages streamlit preview processes and their lifecycle."""
25
+
26
+ def __init__(self) -> None:
27
+ self._previews: Dict[str, PreviewProcess] = {}
28
+ self._lock = threading.Lock()
29
+ self.log = get_logger()
30
+
31
+ def get_free_port(self) -> int:
32
+ """Get a free port for streamlit to use."""
33
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
34
+ s.bind(('', 0))
35
+ s.listen(1)
36
+ port = int(s.getsockname()[1])
37
+
38
+ return port
39
+
40
+ def start_streamlit_preview(self, app_directory: str, preview_id: str) -> Tuple[bool, str, Optional[int]]:
41
+ """Start a streamlit preview process.
42
+
43
+ Args:
44
+ app_code: The streamlit app code to run
45
+ preview_id: Unique identifier for this preview
46
+
47
+ Returns:
48
+ Tuple of (success, message, port)
49
+ """
50
+ try:
51
+
52
+ # Get free port
53
+ port = self.get_free_port()
54
+
55
+ # Start streamlit process
56
+ cmd = [
57
+ "streamlit", "run", 'app.py', # Since we run this command from the app_directory, we always just run app.py
58
+ "--server.port", str(port),
59
+ "--server.headless", "true",
60
+ "--server.address", "localhost",
61
+ "--server.enableXsrfProtection", "false",
62
+ "--server.runOnSave", "true", # auto-reload when app.py is saved
63
+ "--logger.level", "error"
64
+ ]
65
+
66
+ # TODO: Security considerations for production:
67
+ # - Consider enabling XSRF protection if needed, but we might already get this with the APIHandler?
68
+ # - Add authentication headers to streamlit
69
+
70
+ proc = subprocess.Popen(
71
+ cmd,
72
+ stdout=subprocess.PIPE,
73
+ stderr=subprocess.PIPE,
74
+ text=True,
75
+ cwd=app_directory
76
+ )
77
+
78
+ # Wait for app to be ready
79
+ ready = self._wait_for_app_ready(port)
80
+ if not ready:
81
+ proc.terminate()
82
+ proc.wait()
83
+ return False, "Streamlit app failed to start", None
84
+
85
+ # Register the process
86
+ with self._lock:
87
+ self._previews[preview_id] = PreviewProcess(
88
+ proc=proc,
89
+ port=port,
90
+ )
91
+
92
+ self.log.info(f"Started streamlit preview {preview_id} on port {port}")
93
+ return True, "Preview started successfully", port
94
+
95
+ except Exception as e:
96
+ self.log.error(f"Error starting streamlit preview: {e}")
97
+ return False, f"Failed to start preview: {str(e)}", None
98
+
99
+ def _wait_for_app_ready(self, port: int, timeout: int = 30) -> bool:
100
+ """Wait for streamlit app to be ready on the given port."""
101
+ start_time = time.time()
102
+
103
+ while time.time() - start_time < timeout:
104
+ try:
105
+ response = requests.get(f"http://localhost:{port}", timeout=5)
106
+ if response.status_code == 200:
107
+ return True
108
+ except requests.exceptions.RequestException as e:
109
+ print(f"Error waiting for app to be ready: {e}")
110
+ pass
111
+
112
+ time.sleep(1)
113
+
114
+ return False
115
+
116
+ def stop_preview(self, preview_id: str) -> bool:
117
+ """Stop a streamlit preview process.
118
+
119
+ Args:
120
+ preview_id: The preview ID to stop
121
+
122
+ Returns:
123
+ True if stopped successfully, False if not found
124
+ """
125
+ print(f"Stopping preview {preview_id}")
126
+ with self._lock:
127
+ if preview_id not in self._previews:
128
+ return False
129
+
130
+ preview = self._previews[preview_id]
131
+
132
+ # Terminate process
133
+ try:
134
+ preview.proc.terminate()
135
+ preview.proc.wait(timeout=5)
136
+ except subprocess.TimeoutExpired:
137
+ preview.proc.kill()
138
+ preview.proc.wait()
139
+ except Exception as e:
140
+ self.log.error(f"Error terminating process {preview_id}: {e}")
141
+
142
+ # Remove from registry
143
+ del self._previews[preview_id]
144
+
145
+ self.log.info(f"Stopped streamlit preview {preview_id}")
146
+ return True
147
+
148
+ def get_preview(self, preview_id: str) -> Optional[PreviewProcess]:
149
+ """Get a preview process by ID."""
150
+ with self._lock:
151
+ return self._previews.get(preview_id)
152
+
153
+ # Global instance
154
+ _preview_manager = StreamlitPreviewManager()
155
+
156
+
157
+ def get_preview_manager() -> StreamlitPreviewManager:
158
+ """Get the global preview manager instance."""
159
+ return _preview_manager