zaturn 0.1.7__py3-none-any.whl → 0.2.0__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.
Files changed (45) hide show
  1. zaturn/mcp/__init__.py +97 -0
  2. zaturn/studio/__init__.py +5 -0
  3. zaturn/studio/agent_wrapper.py +131 -0
  4. zaturn/studio/app.py +288 -0
  5. zaturn/studio/static/fira_code.ttf +0 -0
  6. zaturn/studio/static/inter_ital_var.ttf +0 -0
  7. zaturn/studio/static/inter_var.ttf +0 -0
  8. zaturn/studio/static/js/htmx-multi-swap.js +44 -0
  9. zaturn/studio/static/js/htmx.min.js +1 -0
  10. zaturn/studio/static/logo.png +0 -0
  11. zaturn/studio/static/logo.svg +10 -0
  12. zaturn/studio/static/noto_emoji.ttf +0 -0
  13. zaturn/studio/storage.py +85 -0
  14. zaturn/studio/templates/_shell.html +38 -0
  15. zaturn/studio/templates/ai_message.html +4 -0
  16. zaturn/studio/templates/c_settings_updated.html +1 -0
  17. zaturn/studio/templates/c_source_card.html +19 -0
  18. zaturn/studio/templates/chat.html +22 -0
  19. zaturn/studio/templates/css/style.css +406 -0
  20. zaturn/studio/templates/function_call.html +7 -0
  21. zaturn/studio/templates/loader.html +1 -0
  22. zaturn/studio/templates/manage_sources.html +45 -0
  23. zaturn/studio/templates/nav.html +5 -0
  24. zaturn/studio/templates/new_conversation.html +13 -0
  25. zaturn/studio/templates/settings.html +29 -0
  26. zaturn/studio/templates/setup_prompt.html +6 -0
  27. zaturn/studio/templates/user_message.html +4 -0
  28. zaturn/tools/__init__.py +13 -0
  29. zaturn/{config.py → tools/config.py} +7 -9
  30. zaturn/tools/core.py +97 -0
  31. zaturn/{query_utils.py → tools/query_utils.py} +52 -2
  32. zaturn/tools/visualizations.py +267 -0
  33. zaturn-0.2.0.dist-info/METADATA +128 -0
  34. zaturn-0.2.0.dist-info/RECORD +39 -0
  35. {zaturn-0.1.7.dist-info → zaturn-0.2.0.dist-info}/WHEEL +1 -1
  36. zaturn-0.2.0.dist-info/entry_points.txt +3 -0
  37. zaturn/__init__.py +0 -14
  38. zaturn/core.py +0 -140
  39. zaturn/visualizations.py +0 -155
  40. zaturn-0.1.7.dist-info/METADATA +0 -185
  41. zaturn-0.1.7.dist-info/RECORD +0 -12
  42. zaturn-0.1.7.dist-info/entry_points.txt +0 -2
  43. /zaturn/{example_data → tools/example_data}/all_pokemon_data.csv +0 -0
  44. {zaturn-0.1.7.dist-info → zaturn-0.2.0.dist-info}/licenses/LICENSE +0 -0
  45. {zaturn-0.1.7.dist-info → zaturn-0.2.0.dist-info}/top_level.txt +0 -0
zaturn/mcp/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ import argparse
2
+ import os
3
+ import platformdirs
4
+ import pkg_resources
5
+ import sys
6
+
7
+ from fastmcp import FastMCP
8
+
9
+ from zaturn.tools import ZaturnTools
10
+
11
+ # Basic Setup
12
+ USER_DATA_DIR = platformdirs.user_data_dir('zaturn', 'zaturn')
13
+ SOURCES_FILE = os.path.join(USER_DATA_DIR, 'sources.txt')
14
+
15
+ # Parse command line args
16
+ parser = argparse.ArgumentParser(
17
+ description="Zaturn MCP: A read-only BI tool for analyzing various data sources"
18
+ )
19
+ parser.add_argument('sources', nargs=argparse.REMAINDER, default=[],
20
+ help='Data source (can be specified multiple times). Can be SQLite, MySQL, PostgreSQL connection string, or a path to CSV, Parquet, or DuckDB file.'
21
+ )
22
+ args = parser.parse_args()
23
+
24
+ source_list = []
25
+ if os.path.exists(SOURCES_FILE):
26
+ with open(SOURCES_FILE) as f:
27
+ source_list = [line.strip('\n') for line in f.readlines() if line.strip('\n')]
28
+
29
+ if not source_list:
30
+ source_list = args.sources
31
+
32
+ if not source_list:
33
+ source_list = [
34
+ pkg_resources.resource_filename(
35
+ 'zaturn',
36
+ os.path.join('mcp', 'example_data', 'all_pokemon_data.csv')
37
+ )
38
+ ]
39
+ print("No data sources provided. Loading example dataset for demonstration.")
40
+ print(f"\nTo load your datasets, add them to {SOURCES_FILE} (one source URL or full file path per line)")
41
+ print("\nOr use command line args to specify data sources:")
42
+ print("zaturn_mcp sqlite:///path/to/mydata.db /path/to/my_file.csv")
43
+ print(f"\nNOTE: Sources in command line args will be ignored if sources are found in {SOURCES_FILE}")
44
+
45
+
46
+ SOURCES = {}
47
+ for s in source_list:
48
+ source = s.lower()
49
+ if source.startswith('sqlite://'):
50
+ source_type = 'sqlite'
51
+ source_name = source.split('/')[-1].split('?')[0].split('.db')[0]
52
+ elif source.startswith('postgresql://'):
53
+ source_type = 'postgresql'
54
+ source_name = source.split('/')[-1].split('?')[0]
55
+ elif source.startswith("mysql://") or source.startswith("mysql+pymysql://"):
56
+ source_type = 'mysql'
57
+ s = s.replace('mysql://', 'mysql+pymysql://')
58
+ source_name = source.split('/')[-1].split('?')[0]
59
+ elif source.startswith('clickhouse://'):
60
+ source_type = 'clickhouse'
61
+ source_name = source.split('/')[-1].split('?')[0]
62
+ elif source.endswith(".duckdb"):
63
+ source_type = "duckdb"
64
+ source_name = source.split('/')[-1].split('.')[0]
65
+ elif source.endswith(".csv"):
66
+ source_type = "csv"
67
+ source_name = source.split('/')[-1].split('.')[0]
68
+ elif source.endswith(".parquet") or source.endswith(".pq"):
69
+ source_type = "parquet"
70
+ source_name = source.split('/')[-1].split('.')[0]
71
+ else:
72
+ continue
73
+
74
+ source_id = f'{source_name}-{source_type}'
75
+ if source_id in SOURCES:
76
+ i = 2
77
+ while True:
78
+ source_id = f'{source_name}{i}-{source_type}'
79
+ if source_id not in SOURCES:
80
+ break
81
+ i += 1
82
+
83
+ SOURCES[source_id] = {'url': s, 'source_type': source_type}
84
+
85
+
86
+ def ZaturnMCP(sources):
87
+ zaturn_tools = ZaturnTools(sources)
88
+ zaturn_mcp = FastMCP()
89
+ for tool in zaturn_tools.tools:
90
+ zaturn_mcp.add_tool(tool)
91
+
92
+ return zaturn_mcp
93
+
94
+
95
+ def main():
96
+ ZaturnMCP(SOURCES).run()
97
+
@@ -0,0 +1,5 @@
1
+ from zaturn.studio.app import app
2
+
3
+
4
+ def main():
5
+ app.run(port=6066, debug=True)
@@ -0,0 +1,131 @@
1
+ import asyncio
2
+ import json
3
+
4
+ from function_schema import get_function_schema
5
+ import httpx
6
+ from mcp.types import ImageContent
7
+
8
+
9
+ class Agent:
10
+
11
+ def __init__(self,
12
+ endpoint: str,
13
+ api_key: str,
14
+ model: str,
15
+ tools: list = [],
16
+ image_input: bool = False,
17
+ ):
18
+
19
+ self._post_url = f'{endpoint}/chat/completions'
20
+ self._api_key = api_key
21
+ self._model = model
22
+ self._image_input = image_input
23
+ self._system_message = {
24
+ 'role': 'system',
25
+ 'content': """
26
+ You are a helpful data analysis assistant.
27
+ Use only the tool provided data sources to process user inputs.
28
+ Do not use external sources or your own knowledge base.
29
+ Also, the tool outputs are shown to the user.
30
+ So, please avoid repeating the tool outputs in the generated text.
31
+ Use list_sources and describe_table whenever needed,
32
+ do not prompt the user for source names and column names.
33
+ """,
34
+ }
35
+
36
+ self._tools = []
37
+ self._tool_map = {}
38
+ for tool in tools:
39
+ tool_schema = get_function_schema(tool)
40
+ self._tools.append({
41
+ 'type': 'function',
42
+ 'function': tool_schema,
43
+ })
44
+ self._tool_map[tool_schema['name']] = tool
45
+
46
+
47
+ def _prepare_input_messages(self, messages):
48
+ input_messages = [self._system_message]
49
+ for message in messages:
50
+ if message['role']!='tool':
51
+ input_messages.append(message)
52
+ elif type(message['content']) is not list:
53
+ input_messages.append(message)
54
+ else:
55
+ new_content = []
56
+ image_content = None
57
+ for content in message['content']:
58
+ if content['type']=='image_url':
59
+ image_content = content
60
+ new_content.append({
61
+ 'type': 'text',
62
+ 'text': 'Tool call returned an image to the user.',
63
+ })
64
+ else:
65
+ new_content.append(content)
66
+ input_messages.append({
67
+ 'role': message['role'],
68
+ 'tool_call_id': message['tool_call_id'],
69
+ 'name': message['name'],
70
+ 'content': new_content,
71
+ })
72
+
73
+ return input_messages
74
+
75
+
76
+ def run(self, messages):
77
+ if type(messages) is str:
78
+ messages = [{'role': 'user', 'content': messages}]
79
+
80
+ while True:
81
+ res = httpx.post(
82
+ url = self._post_url,
83
+ headers = {
84
+ 'Authorization': f'Bearer {self._api_key}'
85
+ },
86
+ json = {
87
+ 'model': self._model,
88
+ 'messages': self._prepare_input_messages(messages),
89
+ 'tools': self._tools,
90
+ 'reasoning': {'exclude': True},
91
+ }
92
+ )
93
+
94
+ print(res.text)
95
+ resj = res.json()
96
+ reply = resj['choices'][0]['message']
97
+ messages.append(reply)
98
+
99
+ tool_calls = reply.get('tool_calls')
100
+ if tool_calls:
101
+ for tool_call in tool_calls:
102
+ tool_name = tool_call['function']['name']
103
+ tool_args = json.loads(tool_call['function']['arguments'])
104
+ tool_response = self._tool_map[tool_name](**tool_args)
105
+ if type(tool_response) is ImageContent:
106
+ b64_data = tool_response.data
107
+ data_url = f'data:image/png;base64,{b64_data}'
108
+ content = [{
109
+ 'type': 'image_url',
110
+ 'image_url': {
111
+ "url": data_url,
112
+ }
113
+ }]
114
+ else:
115
+ content = [{
116
+ 'type': 'text',
117
+ 'text': json.dumps(tool_response)
118
+ }]
119
+
120
+ messages.append({
121
+ 'role': 'tool',
122
+ 'tool_call_id': tool_call['id'],
123
+ 'name': tool_name,
124
+ 'content': content
125
+ })
126
+ else:
127
+ break
128
+
129
+ return messages
130
+
131
+
zaturn/studio/app.py ADDED
@@ -0,0 +1,288 @@
1
+ from datetime import datetime
2
+ import json
3
+
4
+ from flask import Flask, make_response, redirect, request, render_template
5
+ import httpx
6
+ import mistune
7
+ import tomli_w
8
+ from werkzeug.utils import secure_filename
9
+
10
+ from zaturn.studio import storage, agent_wrapper
11
+ from zaturn.tools import ZaturnTools
12
+
13
+
14
+ app = Flask(__name__)
15
+ app.config['state'] = storage.load_state()
16
+
17
+
18
+ def boost(content: str, fallback=None, retarget=None, reswap=None, push_url=None) -> str:
19
+ if request.headers.get('hx-boosted'):
20
+ response = make_response(content)
21
+ if retarget:
22
+ response.headers['hx-retarget'] = retarget
23
+ if reswap:
24
+ response.headers['hx-reswap'] = reswap
25
+ if push_url:
26
+ response.headers['hx-push-url'] = push_url
27
+ return response
28
+ else:
29
+ if fallback:
30
+ return fallback
31
+ else:
32
+ slugs = storage.list_chats()
33
+ return render_template('_shell.html', content=content, slugs=slugs)
34
+
35
+
36
+ @app.route('/')
37
+ def home() -> str:
38
+ state = app.config['state']
39
+ if state.get('api_key') and state.get('sources'):
40
+ return boost(render_template('new_conversation.html'))
41
+ elif state.get('api_key'):
42
+ return boost(render_template('manage_sources.html'))
43
+ else:
44
+ return boost(render_template('setup_prompt.html'))
45
+
46
+
47
+ @app.route('/settings')
48
+ def settings() -> str:
49
+ return boost(render_template(
50
+ 'settings.html',
51
+ current = app.config['state'],
52
+ updated = request.args.get('updated'),
53
+ ))
54
+
55
+
56
+ @app.route('/save_settings', methods=['POST'])
57
+ def save_settings() -> str:
58
+ app.config['state']['api_key'] = request.form.get('api_key')
59
+
60
+ api_model = request.form.get('api_model').strip('/')
61
+ api_endpoint = request.form.get('api_endpoint').strip('/')
62
+ app.config['state']['api_model'] = api_model
63
+ app.config['state']['api_endpoint'] = api_endpoint
64
+ app.config['state']['api_image_input'] = False
65
+
66
+ try:
67
+ model_info = httpx.get(
68
+ url = f'{api_endpoint}/models/{api_model}/endpoints'
69
+ ).json()
70
+ input_modalities = model_info['data']['architecture']['input_modalities']
71
+ if 'image' in input_modalities:
72
+ app.config['state']['api_image_input'] = True
73
+ except:
74
+ pass
75
+ storage.save_state(app.config['state'])
76
+ return redirect(f'/settings?updated={datetime.now().isoformat().split(".")[0]}')
77
+
78
+
79
+ @app.route('/sources/manage')
80
+ def manage_sources() -> str:
81
+ return boost(render_template(
82
+ 'manage_sources.html',
83
+ sources = app.config['state'].get('sources', {})
84
+ ))
85
+
86
+
87
+ @app.route('/source/toggle/', methods=['POST'])
88
+ def source_toggle_active():
89
+ key = request.form['key']
90
+ new_active = request.form['new_status']=='active'
91
+ app.config['state']['sources'][key]['active'] = new_active
92
+ storage.save_state(app.config['state'])
93
+
94
+ return boost(
95
+ render_template('c_source_card.html', key=key, active=new_active),
96
+ fallback = redirect('/sources/manage'),
97
+ retarget = f'#source-card-{key}',
98
+ reswap = 'outerHTML',
99
+ push_url = 'false',
100
+ )
101
+
102
+
103
+ @app.route('/upload_datafile', methods=['POST'])
104
+ def upload_datafile() -> str:
105
+ datafile = request.files.get('datafile')
106
+ filename = secure_filename(datafile.filename)
107
+
108
+ saved_path = storage.save_datafile(datafile, filename)
109
+ stem = saved_path.stem.replace('.', '_')
110
+ ext = saved_path.suffix.strip('.').lower()
111
+
112
+ app.config['state']['sources'] = app.config['state'].get('sources', {})
113
+ if ext in ['csv']:
114
+ app.config['state']['sources'][f'{stem}-csv'] = {
115
+ 'source_type': 'csv',
116
+ 'url': str(saved_path),
117
+ 'active': True,
118
+ }
119
+ elif ext in ['parquet', 'pq']:
120
+ app.config['state']['sources'][f'{stem}-parquet'] = {
121
+ 'source_type': 'parquet',
122
+ 'url': str(saved_path),
123
+ 'active': True,
124
+ }
125
+ elif ext in ['duckdb']:
126
+ app.config['state']['sources'][f'{stem}-duckdb'] = {
127
+ 'source_type': 'duckdb',
128
+ 'url': str(saved_path),
129
+ 'active': True,
130
+ }
131
+ elif ext in ['db', 'sqlite', 'sqlite3']:
132
+ app.config['state']['sources'][f'{stem}-sqlite'] = {
133
+ 'source_type': 'sqlite',
134
+ 'url': f'sqlite:///{str(saved_path)}',
135
+ 'active': True,
136
+ }
137
+ else:
138
+ storage.remove_datafile(saved_path)
139
+
140
+ storage.save_state(app.config['state'])
141
+
142
+ return redirect('/sources/manage')
143
+
144
+
145
+ @app.route('/add_dataurl', methods=['POST'])
146
+ def add_dataurl():
147
+ url = request.form['db_url']
148
+ name = url.split('/')[-1].split('?')[0]
149
+
150
+ if url.startswith("postgresql://"):
151
+ app.config['state']['sources'][f'{name}-postgresql'] = {
152
+ 'source_type': 'postgresql',
153
+ 'url': url,
154
+ 'active': True,
155
+ }
156
+ elif url.startswith("mysql://"):
157
+ app.config['state']['sources'][f'{name}-mysql'] = {
158
+ 'source_type': 'mysql',
159
+ 'url': url,
160
+ 'active': True,
161
+ }
162
+ elif url.startswith("clickhouse://"):
163
+ app.config['state']['sources'][f'{name}-clickhouse'] = {
164
+ 'source_type': 'clickhouse',
165
+ 'url': url,
166
+ 'active': True,
167
+ }
168
+ else:
169
+ pass
170
+
171
+ storage.save_state(app.config['state'])
172
+ return redirect('/sources/manage')
173
+
174
+
175
+ @app.route('/source/delete', methods=['POST'])
176
+ def delete_source():
177
+ key = request.form['key']
178
+ source = app.config['state']['sources'][key]
179
+ if source['source_type'] in ['csv', 'parquet', 'sqlite', 'duckdb']:
180
+ storage.remove_datafile(source['url'])
181
+
182
+ del app.config['state']['sources'][key]
183
+ storage.save_state(app.config['state'])
184
+ return redirect('/sources/manage')
185
+
186
+
187
+ def get_active_sources():
188
+ sources = {}
189
+ for key in app.config['state']['sources']:
190
+ source = app.config['state']['sources'][key]
191
+ if source['active']:
192
+ sources[key] = source
193
+ return sources
194
+
195
+
196
+ def prepare_chat_for_render(chat):
197
+ fn_calls = {}
198
+ for msg in chat['messages']:
199
+ if msg.get('role')=='assistant':
200
+ if msg.get('tool_calls'):
201
+ msg['is_tool_call'] = True
202
+ for tool_call in msg['tool_calls']:
203
+ fn_call = tool_call['function']
204
+ fn_call['arguments'] = tomli_w.dumps(
205
+ json.loads(fn_call['arguments'])
206
+ ).replace('\n', '<br>')
207
+ fn_calls[tool_call['id']] = fn_call
208
+ else:
209
+ msg['html'] = mistune.html(msg['content'])
210
+ if msg.get('role')=='tool':
211
+ msg['call_details'] = fn_calls[msg['tool_call_id']]
212
+ if type(msg['content']) is str:
213
+ msg['html'] = mistune.html(json.loads(msg['text']))
214
+ elif type(msg['content']) is list:
215
+ msg['html'] = ''
216
+ for content in msg['content']:
217
+ if content['type'] == 'image_url':
218
+ data_url = content['image_url']['url']
219
+ msg['html'] += f'<img src="{data_url}">'
220
+ else:
221
+ msg['html'] += mistune.html(json.loads(content['text']))
222
+
223
+ return chat
224
+
225
+
226
+ @app.route('/create_new_chat', methods=['POST'])
227
+ def create_new_chat():
228
+ question = request.form['question']
229
+ slug = storage.create_chat(question)
230
+ chat = storage.load_chat(slug)
231
+
232
+ state = app.config['state']
233
+ agent = agent_wrapper.Agent(
234
+ endpoint = state['api_endpoint'],
235
+ api_key = state['api_key'],
236
+ model = state['api_model'],
237
+ tools = ZaturnTools(get_active_sources()).tools,
238
+ image_input = state['api_image_input'],
239
+ )
240
+ chat['messages'] = agent.run(chat['messages'])
241
+ storage.save_chat(slug, chat)
242
+ chat = prepare_chat_for_render(chat)
243
+
244
+ return boost(
245
+ ''.join([
246
+ render_template('nav.html', slugs=storage.list_chats()),
247
+ '<main id="main">',
248
+ render_template('chat.html', chat=chat),
249
+ '</main>'
250
+ ]),
251
+ reswap = 'multi:#sidebar,#main',
252
+ push_url = f'/c/{slug}',
253
+ fallback = redirect(f'/c/{slug}'),
254
+ )
255
+
256
+
257
+ @app.route('/c/<slug>')
258
+ def show_chat(slug: str):
259
+ chat = prepare_chat_for_render(storage.load_chat(slug))
260
+ return boost(render_template('chat.html', chat=chat))
261
+
262
+
263
+ @app.route('/follow_up_message', methods=['POST'])
264
+ def follow_up_message():
265
+ slug = request.form['slug']
266
+ chat = storage.load_chat(slug)
267
+ chat['messages'].append({
268
+ 'role': 'user',
269
+ 'content': request.form['question'],
270
+ })
271
+
272
+ state = app.config['state']
273
+ agent = agent_wrapper.Agent(
274
+ endpoint = state['api_endpoint'],
275
+ api_key = state['api_key'],
276
+ model = state['api_model'],
277
+ tools = ZaturnTools(get_active_sources()).tools,
278
+ image_input = state['api_image_input'],
279
+ )
280
+ chat['messages'] = agent.run(chat['messages'])
281
+ storage.save_chat(slug, chat)
282
+ chat = prepare_chat_for_render(chat)
283
+
284
+ return boost(
285
+ render_template('chat.html', chat=chat),
286
+ push_url = 'false',
287
+ reswap = 'innerHTML scroll:bottom',
288
+ )
Binary file
Binary file
Binary file
@@ -0,0 +1,44 @@
1
+ (function() {
2
+ /** @type {import("../htmx").HtmxInternalApi} */
3
+ var api
4
+
5
+ htmx.defineExtension('multi-swap', {
6
+ init: function(apiRef) {
7
+ api = apiRef
8
+ },
9
+ isInlineSwap: function(swapStyle) {
10
+ return swapStyle.indexOf('multi:') === 0
11
+ },
12
+ handleSwap: function(swapStyle, target, fragment, settleInfo) {
13
+ if (swapStyle.indexOf('multi:') === 0) {
14
+ var selectorToSwapStyle = {}
15
+ var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/)
16
+
17
+ elements.forEach(function(element) {
18
+ var split = element.split(/\s*:\s*/)
19
+ var elementSelector = split[0]
20
+ var elementSwapStyle = typeof (split[1]) !== 'undefined' ? split[1] : 'innerHTML'
21
+
22
+ if (elementSelector.charAt(0) !== '#') {
23
+ console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.")
24
+ return
25
+ }
26
+
27
+ selectorToSwapStyle[elementSelector] = elementSwapStyle
28
+ })
29
+
30
+ for (var selector in selectorToSwapStyle) {
31
+ var swapStyle = selectorToSwapStyle[selector]
32
+ var elementToSwap = fragment.querySelector(selector)
33
+ if (elementToSwap) {
34
+ api.oobSwap(swapStyle, elementToSwap, settleInfo)
35
+ } else {
36
+ console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.")
37
+ }
38
+ }
39
+
40
+ return true
41
+ }
42
+ }
43
+ })
44
+ })()