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.
- mito_ai/__init__.py +9 -1
- mito_ai/_version.py +1 -1
- mito_ai/app_builder/handlers.py +30 -30
- mito_ai/app_builder/models.py +1 -1
- mito_ai/log/handlers.py +10 -3
- mito_ai/log/urls.py +3 -3
- mito_ai/streamlit_conversion/streamlit_agent_handler.py +22 -6
- mito_ai/streamlit_conversion/streamlit_system_prompt.py +11 -0
- mito_ai/streamlit_conversion/streamlit_utils.py +8 -6
- mito_ai/streamlit_conversion/validate_and_run_streamlit_code.py +1 -0
- mito_ai/streamlit_preview/__init__.py +7 -0
- mito_ai/streamlit_preview/handlers.py +161 -0
- mito_ai/streamlit_preview/manager.py +159 -0
- mito_ai/streamlit_preview/urls.py +22 -0
- mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +16 -15
- mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +4 -5
- mito_ai/tests/streamlit_preview/test_streamlit_preview_manager.py +302 -0
- mito_ai/utils/telemetry_utils.py +28 -1
- {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
- {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
- {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
- {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
- 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
- mito_ai-0.1.38.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.5d1d7c234e2dc7c9d97b.js.map +1 -0
- 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
- 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
- {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/METADATA +1 -1
- {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/RECORD +39 -34
- mito_ai-0.1.37.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.831f63b48760c7119b9b.js.map +0 -1
- {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
- {mito_ai-0.1.37.data → mito_ai-0.1.38.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/WHEEL +0 -0
- {mito_ai-0.1.37.dist-info → mito_ai-0.1.38.dist-info}/entry_points.txt +0 -0
- {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
mito_ai/app_builder/handlers.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
108
|
-
notebook_path = message.
|
|
109
|
-
|
|
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
|
|
117
|
+
if not notebook_path:
|
|
117
118
|
error = AppBuilderError(
|
|
118
119
|
error_type="InvalidRequest",
|
|
119
|
-
title="Missing '
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
149
|
-
|
|
147
|
+
self.log.info("JWT token validation successful")
|
|
148
|
+
|
|
150
149
|
try:
|
|
151
150
|
|
|
152
|
-
|
|
153
|
-
|
|
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)
|
mito_ai/app_builder/models.py
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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]:
|
|
@@ -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
|