saturnolab 0.1.0__tar.gz
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.
- saturnolab-0.1.0/.gitignore +12 -0
- saturnolab-0.1.0/LICENSE +21 -0
- saturnolab-0.1.0/PKG-INFO +109 -0
- saturnolab-0.1.0/README.md +69 -0
- saturnolab-0.1.0/pyproject.toml +78 -0
- saturnolab-0.1.0/saturnolab/__init__.py +3 -0
- saturnolab-0.1.0/saturnolab/cli.py +107 -0
- saturnolab-0.1.0/saturnolab/config.py +209 -0
- saturnolab-0.1.0/saturnolab/fs_service.py +344 -0
- saturnolab-0.1.0/saturnolab/kernel_manager.py +423 -0
- saturnolab-0.1.0/saturnolab/llm_service.py +410 -0
- saturnolab-0.1.0/saturnolab/main.py +442 -0
- saturnolab-0.1.0/saturnolab/static/assets/index-BgLzqqBL.js +301 -0
- saturnolab-0.1.0/saturnolab/static/assets/index-m1BJLZ3i.css +1 -0
- saturnolab-0.1.0/saturnolab/static/index.html +14 -0
saturnolab-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maicon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: saturnolab
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight JupyterLab-compatible Python notebook environment with native inline AI connected to live kernel memory.
|
|
5
|
+
Project-URL: Homepage, https://github.com/maicondallg/saturnolab
|
|
6
|
+
Project-URL: Repository, https://github.com/maicondallg/saturnolab
|
|
7
|
+
Project-URL: Issues, https://github.com/maicondallg/saturnolab/issues
|
|
8
|
+
Author: Maicon
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,data-science,ipynb,jupyter,jupyterlab,llm,notebook,python
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Jupyter
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Science/Research
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: fastapi>=0.115.0
|
|
27
|
+
Requires-Dist: httpx>=0.27.0
|
|
28
|
+
Requires-Dist: ipykernel>=6.29.0
|
|
29
|
+
Requires-Dist: jupyter-client>=8.6.0
|
|
30
|
+
Requires-Dist: nbformat>=5.10.0
|
|
31
|
+
Requires-Dist: pydantic>=2.7.0
|
|
32
|
+
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
33
|
+
Requires-Dist: websockets>=12.0
|
|
34
|
+
Provides-Extra: data
|
|
35
|
+
Requires-Dist: matplotlib>=3.8.0; extra == 'data'
|
|
36
|
+
Requires-Dist: numpy>=1.26.0; extra == 'data'
|
|
37
|
+
Requires-Dist: pandas>=2.0.0; extra == 'data'
|
|
38
|
+
Requires-Dist: seaborn>=0.13.0; extra == 'data'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# SaturnoLab
|
|
42
|
+
|
|
43
|
+
**SaturnoLab** (`saturnolab`) is a fast, lightweight, `.ipynb`-compatible Python notebook environment built from the ground up with **Inline AI (`Ctrl+I` / `Ctrl+K`)** connected directly to live Python kernel memory.
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **100% `.ipynb` Compatible**: Open, edit, execute, and save standard Jupyter notebooks (`nbformat` v4).
|
|
48
|
+
- **Zero-bloat Web UI**: Built with React, CodeMirror 6, and FastAPI (~305 KB gzipped) with 6 technical themes (*Graphite*, *Obsidian*, *Gruvbox*, *Nord*, *Jupyter Light*, *Warm Stone*).
|
|
49
|
+
- **Inline Cell AI (`Ctrl+I` / `Ctrl+K`)**:
|
|
50
|
+
- Generate or edit Python code directly inside any cell or over a selected snippet.
|
|
51
|
+
- Automatically injects **live kernel variable schemas** (`DataFrame` shapes, column names, `dtypes`, array dimensions) and preceding cell context into the prompt.
|
|
52
|
+
- Instant `Tab` to accept, `Shift+Tab` to toggle *Replace* vs *Append*, and `Ctrl+Enter` to accept & execute immediately.
|
|
53
|
+
- **Multi-Provider LLM Support**:
|
|
54
|
+
- **Google Gemini API** (`gemini-2.5-flash`, `gemini-2.5-pro`, etc.)
|
|
55
|
+
- **OpenAI Codex CLI / SDK** (`@openai/codex`)
|
|
56
|
+
- **OpenAI-compatible APIs** (OpenAI, local **Ollama**, OpenRouter, LM Studio)
|
|
57
|
+
- **Anthropic Claude API**
|
|
58
|
+
- **Automatic Virtualenv Detection**: Automatically detects `.venv` / `venv` / `$VIRTUAL_ENV` in the target project folder.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install saturnolab
|
|
66
|
+
|
|
67
|
+
# Optional: include common data science stack (pandas, numpy, matplotlib, seaborn)
|
|
68
|
+
pip install "saturnolab[data]"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or install globally as an isolated CLI tool with **`uv`**:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv tool install saturnolab
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Run `saturnolab` inside **any** project directory, just like `jupyter lab`:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Open in the current directory
|
|
85
|
+
saturnolab
|
|
86
|
+
|
|
87
|
+
# Or point to any folder
|
|
88
|
+
saturnolab /path/to/project
|
|
89
|
+
|
|
90
|
+
# Custom port or headless server mode
|
|
91
|
+
saturnolab --port 8900 --no-browser
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Keyboard Shortcuts
|
|
97
|
+
|
|
98
|
+
| Shortcut | Action |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `Ctrl + I` or `Ctrl + K` | Open Inline AI inside active cell or selection |
|
|
101
|
+
| `Shift + Tab` | Toggle between **Replace cell** and **Append to end** |
|
|
102
|
+
| `Tab` / `Enter` | Accept generated code |
|
|
103
|
+
| `Ctrl + Enter` | Run active cell (or Accept & Run in AI preview) |
|
|
104
|
+
| `Shift + Enter` | Run active cell and advance |
|
|
105
|
+
| `Alt + Enter` | Run active cell and insert new cell below |
|
|
106
|
+
| `Alt + ↑ / ↓` | Move cell up / down |
|
|
107
|
+
| `Ctrl + M` | Toggle cell between Python and Markdown |
|
|
108
|
+
| `Ctrl + Shift + Backspace` | Delete active cell |
|
|
109
|
+
| `Ctrl + S` | Save notebook |
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SaturnoLab
|
|
2
|
+
|
|
3
|
+
**SaturnoLab** (`saturnolab`) is a fast, lightweight, `.ipynb`-compatible Python notebook environment built from the ground up with **Inline AI (`Ctrl+I` / `Ctrl+K`)** connected directly to live Python kernel memory.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **100% `.ipynb` Compatible**: Open, edit, execute, and save standard Jupyter notebooks (`nbformat` v4).
|
|
8
|
+
- **Zero-bloat Web UI**: Built with React, CodeMirror 6, and FastAPI (~305 KB gzipped) with 6 technical themes (*Graphite*, *Obsidian*, *Gruvbox*, *Nord*, *Jupyter Light*, *Warm Stone*).
|
|
9
|
+
- **Inline Cell AI (`Ctrl+I` / `Ctrl+K`)**:
|
|
10
|
+
- Generate or edit Python code directly inside any cell or over a selected snippet.
|
|
11
|
+
- Automatically injects **live kernel variable schemas** (`DataFrame` shapes, column names, `dtypes`, array dimensions) and preceding cell context into the prompt.
|
|
12
|
+
- Instant `Tab` to accept, `Shift+Tab` to toggle *Replace* vs *Append*, and `Ctrl+Enter` to accept & execute immediately.
|
|
13
|
+
- **Multi-Provider LLM Support**:
|
|
14
|
+
- **Google Gemini API** (`gemini-2.5-flash`, `gemini-2.5-pro`, etc.)
|
|
15
|
+
- **OpenAI Codex CLI / SDK** (`@openai/codex`)
|
|
16
|
+
- **OpenAI-compatible APIs** (OpenAI, local **Ollama**, OpenRouter, LM Studio)
|
|
17
|
+
- **Anthropic Claude API**
|
|
18
|
+
- **Automatic Virtualenv Detection**: Automatically detects `.venv` / `venv` / `$VIRTUAL_ENV` in the target project folder.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install saturnolab
|
|
26
|
+
|
|
27
|
+
# Optional: include common data science stack (pandas, numpy, matplotlib, seaborn)
|
|
28
|
+
pip install "saturnolab[data]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install globally as an isolated CLI tool with **`uv`**:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv tool install saturnolab
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Run `saturnolab` inside **any** project directory, just like `jupyter lab`:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Open in the current directory
|
|
45
|
+
saturnolab
|
|
46
|
+
|
|
47
|
+
# Or point to any folder
|
|
48
|
+
saturnolab /path/to/project
|
|
49
|
+
|
|
50
|
+
# Custom port or headless server mode
|
|
51
|
+
saturnolab --port 8900 --no-browser
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Keyboard Shortcuts
|
|
57
|
+
|
|
58
|
+
| Shortcut | Action |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `Ctrl + I` or `Ctrl + K` | Open Inline AI inside active cell or selection |
|
|
61
|
+
| `Shift + Tab` | Toggle between **Replace cell** and **Append to end** |
|
|
62
|
+
| `Tab` / `Enter` | Accept generated code |
|
|
63
|
+
| `Ctrl + Enter` | Run active cell (or Accept & Run in AI preview) |
|
|
64
|
+
| `Shift + Enter` | Run active cell and advance |
|
|
65
|
+
| `Alt + Enter` | Run active cell and insert new cell below |
|
|
66
|
+
| `Alt + ↑ / ↓` | Move cell up / down |
|
|
67
|
+
| `Ctrl + M` | Toggle cell between Python and Markdown |
|
|
68
|
+
| `Ctrl + Shift + Backspace` | Delete active cell |
|
|
69
|
+
| `Ctrl + S` | Save notebook |
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "saturnolab"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight JupyterLab-compatible Python notebook environment with native inline AI connected to live kernel memory."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Maicon" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"jupyter",
|
|
17
|
+
"jupyterlab",
|
|
18
|
+
"notebook",
|
|
19
|
+
"ipynb",
|
|
20
|
+
"python",
|
|
21
|
+
"ai",
|
|
22
|
+
"llm",
|
|
23
|
+
"data-science",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Environment :: Web Environment",
|
|
28
|
+
"Framework :: FastAPI",
|
|
29
|
+
"Framework :: Jupyter",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"Intended Audience :: Science/Research",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
39
|
+
]
|
|
40
|
+
dependencies = [
|
|
41
|
+
"fastapi>=0.115.0",
|
|
42
|
+
"uvicorn[standard]>=0.30.0",
|
|
43
|
+
"websockets>=12.0",
|
|
44
|
+
"jupyter-client>=8.6.0",
|
|
45
|
+
"ipykernel>=6.29.0",
|
|
46
|
+
"nbformat>=5.10.0",
|
|
47
|
+
"httpx>=0.27.0",
|
|
48
|
+
"pydantic>=2.7.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.optional-dependencies]
|
|
52
|
+
data = [
|
|
53
|
+
"pandas>=2.0.0",
|
|
54
|
+
"numpy>=1.26.0",
|
|
55
|
+
"matplotlib>=3.8.0",
|
|
56
|
+
"seaborn>=0.13.0",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[project.urls]
|
|
60
|
+
Homepage = "https://github.com/maicondallg/saturnolab"
|
|
61
|
+
Repository = "https://github.com/maicondallg/saturnolab"
|
|
62
|
+
Issues = "https://github.com/maicondallg/saturnolab/issues"
|
|
63
|
+
|
|
64
|
+
[project.scripts]
|
|
65
|
+
saturnolab = "saturnolab.cli:main"
|
|
66
|
+
|
|
67
|
+
[tool.hatch.build.targets.wheel]
|
|
68
|
+
packages = ["saturnolab"]
|
|
69
|
+
artifacts = ["saturnolab/static/**"]
|
|
70
|
+
|
|
71
|
+
[tool.hatch.build.targets.sdist]
|
|
72
|
+
include = [
|
|
73
|
+
"/saturnolab",
|
|
74
|
+
"/README.md",
|
|
75
|
+
"/LICENSE",
|
|
76
|
+
"/pyproject.toml",
|
|
77
|
+
]
|
|
78
|
+
artifacts = ["saturnolab/static/**"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import uvicorn
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_available_port(host: str, preferred_port: int, max_tries: int = 50) -> int:
|
|
17
|
+
for offset in range(max_tries):
|
|
18
|
+
port = preferred_port + offset
|
|
19
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
20
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
21
|
+
try:
|
|
22
|
+
sock.bind((host, port))
|
|
23
|
+
return port
|
|
24
|
+
except OSError:
|
|
25
|
+
continue
|
|
26
|
+
return preferred_port
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> None:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="saturnolab",
|
|
32
|
+
description="SaturnoLab — Ambiente leve de notebooks Python com IA Inline integrada ao Kernel.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"directory",
|
|
36
|
+
nargs="?",
|
|
37
|
+
default=".",
|
|
38
|
+
help="Diretório do projeto para abrir no SaturnoLab (padrão: diretório atual).",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-p",
|
|
42
|
+
"--port",
|
|
43
|
+
type=int,
|
|
44
|
+
default=int(os.environ.get("SATURNOLAB_PORT", "8899")),
|
|
45
|
+
help="Porta HTTP inicial (padrão: 8899; avança automaticamente se ocupada).",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--host",
|
|
49
|
+
default=os.environ.get("SATURNOLAB_HOST", "127.0.0.1"),
|
|
50
|
+
help="Host para escutar conexões (padrão: 127.0.0.1).",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--no-browser",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="Não abrir o navegador automaticamente ao iniciar.",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--reload",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Habilitar auto-reload do servidor (desenvolvimento).",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"-v",
|
|
64
|
+
"--version",
|
|
65
|
+
action="version",
|
|
66
|
+
version=f"saturnolab {__version__}",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
|
|
71
|
+
workspace_path = Path(args.directory).expanduser().resolve()
|
|
72
|
+
if not workspace_path.exists():
|
|
73
|
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
elif not workspace_path.is_dir():
|
|
75
|
+
parser.error(f"O caminho informado não é um diretório: {workspace_path}")
|
|
76
|
+
|
|
77
|
+
os.environ["SATURNOLAB_WORKSPACE"] = str(workspace_path)
|
|
78
|
+
|
|
79
|
+
port = find_available_port(args.host, args.port)
|
|
80
|
+
url = f"http://{args.host}:{port}"
|
|
81
|
+
|
|
82
|
+
print(f"🪐 SaturnoLab v{__version__}")
|
|
83
|
+
print(f"📂 Diretório : {workspace_path}")
|
|
84
|
+
print(f"🌐 Servidor : {url}")
|
|
85
|
+
print(" Pressione Ctrl+C para encerrar.\n")
|
|
86
|
+
|
|
87
|
+
if not args.no_browser:
|
|
88
|
+
def _open_browser() -> None:
|
|
89
|
+
time.sleep(0.8)
|
|
90
|
+
try:
|
|
91
|
+
webbrowser.open(url)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
threading.Thread(target=_open_browser, daemon=True).start()
|
|
96
|
+
|
|
97
|
+
uvicorn.run(
|
|
98
|
+
"saturnolab.main:app",
|
|
99
|
+
host=args.host,
|
|
100
|
+
port=port,
|
|
101
|
+
reload=args.reload,
|
|
102
|
+
log_level="warning",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path.home() / ".saturnolab"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
13
|
+
LEGACY_CONFIG_FILE = Path.home() / ".saturno" / "config.json"
|
|
14
|
+
|
|
15
|
+
VALID_THEMES = {
|
|
16
|
+
"graphite",
|
|
17
|
+
"obsidian",
|
|
18
|
+
"gruvbox",
|
|
19
|
+
"nord",
|
|
20
|
+
"paper",
|
|
21
|
+
"solarized_light",
|
|
22
|
+
"dark",
|
|
23
|
+
"light",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProviderSettings(BaseModel):
|
|
28
|
+
api_key: str = ""
|
|
29
|
+
model: str = ""
|
|
30
|
+
base_url: str = ""
|
|
31
|
+
reasoning_effort: str = "low"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SaturnoConfig(BaseModel):
|
|
35
|
+
active_provider: str = "gemini" # gemini | codex | openai_compatible | anthropic
|
|
36
|
+
theme: str = "graphite"
|
|
37
|
+
default_ai_apply_mode: str = "replace_cell" # replace_cell | append_cell
|
|
38
|
+
include_kernel_variables: bool = True
|
|
39
|
+
providers: dict[str, ProviderSettings] = Field(
|
|
40
|
+
default_factory=lambda: {
|
|
41
|
+
"gemini": ProviderSettings(
|
|
42
|
+
api_key="",
|
|
43
|
+
model="gemini-2.5-flash",
|
|
44
|
+
base_url="https://generativelanguage.googleapis.com/v1beta",
|
|
45
|
+
),
|
|
46
|
+
"codex": ProviderSettings(
|
|
47
|
+
api_key="",
|
|
48
|
+
model="",
|
|
49
|
+
base_url="",
|
|
50
|
+
reasoning_effort="low",
|
|
51
|
+
),
|
|
52
|
+
"openai_compatible": ProviderSettings(
|
|
53
|
+
api_key="",
|
|
54
|
+
model="gpt-4.1-mini",
|
|
55
|
+
base_url="https://api.openai.com/v1",
|
|
56
|
+
),
|
|
57
|
+
"anthropic": ProviderSettings(
|
|
58
|
+
api_key="",
|
|
59
|
+
model="claude-sonnet-4-5",
|
|
60
|
+
base_url="https://api.anthropic.com/v1",
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConfigManager:
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._migrate_legacy_if_needed()
|
|
69
|
+
self._config = self._load()
|
|
70
|
+
self._workspace_path = self._resolve_initial_workspace()
|
|
71
|
+
|
|
72
|
+
def _migrate_legacy_if_needed(self) -> None:
|
|
73
|
+
if not CONFIG_FILE.exists() and LEGACY_CONFIG_FILE.exists():
|
|
74
|
+
try:
|
|
75
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
shutil.copy2(LEGACY_CONFIG_FILE, CONFIG_FILE)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def _resolve_initial_workspace(self) -> Path:
|
|
81
|
+
env_ws = (
|
|
82
|
+
os.environ.get("SATURNOLAB_WORKSPACE", "").strip()
|
|
83
|
+
or os.environ.get("SATURNO_WORKSPACE", "").strip()
|
|
84
|
+
)
|
|
85
|
+
if env_ws:
|
|
86
|
+
target = Path(env_ws).expanduser().resolve()
|
|
87
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
return target
|
|
89
|
+
return Path.cwd().resolve()
|
|
90
|
+
|
|
91
|
+
def _load(self) -> SaturnoConfig:
|
|
92
|
+
cfg = SaturnoConfig()
|
|
93
|
+
if CONFIG_FILE.exists():
|
|
94
|
+
try:
|
|
95
|
+
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
96
|
+
# Ignore deprecated workspace_dir key from older configs
|
|
97
|
+
raw.pop("workspace_dir", None)
|
|
98
|
+
loaded = SaturnoConfig.model_validate(raw)
|
|
99
|
+
for key, default_val in cfg.providers.items():
|
|
100
|
+
if key not in loaded.providers:
|
|
101
|
+
loaded.providers[key] = default_val
|
|
102
|
+
cfg = loaded
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
return cfg
|
|
106
|
+
|
|
107
|
+
def save(self) -> None:
|
|
108
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
CONFIG_FILE.write_text(
|
|
110
|
+
json.dumps(self._config.model_dump(), indent=2, ensure_ascii=False),
|
|
111
|
+
encoding="utf-8",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def config(self) -> SaturnoConfig:
|
|
116
|
+
return self._config
|
|
117
|
+
|
|
118
|
+
def get_workspace_path(self) -> Path:
|
|
119
|
+
if not self._workspace_path.exists():
|
|
120
|
+
self._workspace_path.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
return self._workspace_path
|
|
122
|
+
|
|
123
|
+
def set_workspace_path(self, new_dir: str) -> Path:
|
|
124
|
+
target = Path(new_dir).expanduser().resolve()
|
|
125
|
+
if not target.exists() or not target.is_dir():
|
|
126
|
+
raise ValueError(f"Diretório não encontrado: {new_dir}")
|
|
127
|
+
self._workspace_path = target
|
|
128
|
+
return target
|
|
129
|
+
|
|
130
|
+
def update_settings(self, patch: dict[str, Any]) -> SaturnoConfig:
|
|
131
|
+
if "active_provider" in patch and patch["active_provider"]:
|
|
132
|
+
self._config.active_provider = str(patch["active_provider"])
|
|
133
|
+
if "theme" in patch and patch["theme"] in VALID_THEMES:
|
|
134
|
+
self._config.theme = str(patch["theme"])
|
|
135
|
+
if "default_ai_apply_mode" in patch and patch["default_ai_apply_mode"] in (
|
|
136
|
+
"replace_cell",
|
|
137
|
+
"append_cell",
|
|
138
|
+
):
|
|
139
|
+
self._config.default_ai_apply_mode = patch["default_ai_apply_mode"]
|
|
140
|
+
if "include_kernel_variables" in patch:
|
|
141
|
+
self._config.include_kernel_variables = bool(patch["include_kernel_variables"])
|
|
142
|
+
if "providers" in patch and isinstance(patch["providers"], dict):
|
|
143
|
+
for prov_name, prov_data in patch["providers"].items():
|
|
144
|
+
if not isinstance(prov_data, dict):
|
|
145
|
+
continue
|
|
146
|
+
existing = self._config.providers.get(prov_name, ProviderSettings())
|
|
147
|
+
updated_dict = existing.model_dump()
|
|
148
|
+
for k, v in prov_data.items():
|
|
149
|
+
if k == "api_key" and v == "********":
|
|
150
|
+
continue
|
|
151
|
+
if k in updated_dict and v is not None:
|
|
152
|
+
updated_dict[k] = v
|
|
153
|
+
self._config.providers[prov_name] = ProviderSettings(**updated_dict)
|
|
154
|
+
self.save()
|
|
155
|
+
return self._config
|
|
156
|
+
|
|
157
|
+
def resolve_api_key(self, provider: str) -> str:
|
|
158
|
+
prov = self._config.providers.get(provider)
|
|
159
|
+
if prov and prov.api_key.strip():
|
|
160
|
+
return prov.api_key.strip()
|
|
161
|
+
env_map = {
|
|
162
|
+
"gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
|
163
|
+
"codex": ["CODEX_API_KEY", "OPENAI_API_KEY"],
|
|
164
|
+
"openai_compatible": ["OPENAI_API_KEY"],
|
|
165
|
+
"anthropic": ["ANTHROPIC_API_KEY"],
|
|
166
|
+
}
|
|
167
|
+
for env_name in env_map.get(provider, []):
|
|
168
|
+
val = os.environ.get(env_name, "").strip()
|
|
169
|
+
if val:
|
|
170
|
+
return val
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def resolve_codex_binary() -> str | None:
|
|
175
|
+
found = shutil.which("codex")
|
|
176
|
+
if found:
|
|
177
|
+
return found
|
|
178
|
+
local_bin = Path.home() / ".local" / "bin" / "codex"
|
|
179
|
+
if local_bin.exists():
|
|
180
|
+
return str(local_bin)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def public_dict(self) -> dict[str, Any]:
|
|
184
|
+
data = self._config.model_dump()
|
|
185
|
+
codex_bin = self.resolve_codex_binary()
|
|
186
|
+
npx_bin = shutil.which("npx")
|
|
187
|
+
codex_auth_exists = (Path.home() / ".codex" / "auth.json").exists()
|
|
188
|
+
|
|
189
|
+
providers_out: dict[str, Any] = {}
|
|
190
|
+
for name, p in data["providers"].items():
|
|
191
|
+
resolved_key = self.resolve_api_key(name)
|
|
192
|
+
providers_out[name] = {
|
|
193
|
+
**p,
|
|
194
|
+
"api_key": "********" if p.get("api_key") else "",
|
|
195
|
+
"has_key": bool(resolved_key),
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
**data,
|
|
199
|
+
"workspace_dir": str(self.get_workspace_path()),
|
|
200
|
+
"providers": providers_out,
|
|
201
|
+
"codex_status": {
|
|
202
|
+
"cli_installed": bool(codex_bin),
|
|
203
|
+
"auth_found": codex_auth_exists,
|
|
204
|
+
"sdk_ready": bool(codex_bin or npx_bin),
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
config_manager = ConfigManager()
|