pixelweaver 0.1.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.
@@ -0,0 +1 @@
1
+ """PixelWeaver collaboration server."""
@@ -0,0 +1,4 @@
1
+ """Enable python -m pixelweaver."""
2
+ from pixelweaver.cli import main
3
+
4
+ main()
@@ -0,0 +1,114 @@
1
+ """Auto-save: periodic flush of in-memory state to disk.
2
+
3
+ The auto-saver runs as an asyncio background task. State is saved when:
4
+ - A debounce timer expires after the last state mutation, OR
5
+ - A fixed interval elapses since the last save
6
+
7
+ Whichever fires first triggers a save, preventing both data loss and
8
+ excessive I/O.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+
17
+ from pixelweaver.state import ServerState
18
+ from pixelweaver.storage import save_project
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class AutoSaver:
24
+ """Periodically flushes server state to disk."""
25
+
26
+ def __init__(
27
+ self,
28
+ state: ServerState,
29
+ save_dir: str,
30
+ debounce_ms: int = 500,
31
+ interval_s: int = 30,
32
+ ) -> None:
33
+ self.state = state
34
+ self.save_dir = save_dir
35
+ self.debounce_ms = debounce_ms
36
+ self.interval_s = interval_s
37
+ self._dirty = False
38
+ self._last_save: float = 0
39
+ self._task: asyncio.Task[None] | None = None
40
+ self._debounce_task: asyncio.Task[None] | None = None
41
+
42
+ def mark_dirty(self) -> None:
43
+ """Called after any state change to schedule a debounced save."""
44
+ self._dirty = True
45
+ # Reset the debounce timer
46
+ if self._debounce_task is not None and not self._debounce_task.done():
47
+ self._debounce_task.cancel()
48
+ self._debounce_task = asyncio.ensure_future(self._debounce_save())
49
+
50
+ async def _debounce_save(self) -> None:
51
+ """Wait for the debounce period, then save if still dirty."""
52
+ try:
53
+ await asyncio.sleep(self.debounce_ms / 1000.0)
54
+ if self._dirty:
55
+ await self.save_now()
56
+ except asyncio.CancelledError:
57
+ pass # Debounce was reset by a newer mutation
58
+
59
+ async def start(self) -> None:
60
+ """Start the auto-save background task (periodic interval saves)."""
61
+ self._task = asyncio.ensure_future(self._periodic_loop())
62
+ logger.info(
63
+ "Auto-saver started (debounce=%dms, interval=%ds, dir=%s)",
64
+ self.debounce_ms,
65
+ self.interval_s,
66
+ self.save_dir,
67
+ )
68
+
69
+ async def stop(self) -> None:
70
+ """Stop the background task gracefully, saving any pending changes."""
71
+ if self._task is not None:
72
+ self._task.cancel()
73
+ try:
74
+ await self._task
75
+ except asyncio.CancelledError:
76
+ pass
77
+ if self._debounce_task is not None:
78
+ self._debounce_task.cancel()
79
+ # Final save if dirty
80
+ if self._dirty:
81
+ await self.save_now()
82
+
83
+ async def _periodic_loop(self) -> None:
84
+ """Run periodic saves on a fixed interval."""
85
+ try:
86
+ while True:
87
+ await asyncio.sleep(self.interval_s)
88
+ if self._dirty:
89
+ await self.save_now()
90
+ except asyncio.CancelledError:
91
+ pass
92
+
93
+ async def save_now(self) -> None:
94
+ """Flush all project state to disk immediately."""
95
+ if not self.state.projects:
96
+ self._dirty = False
97
+ return
98
+
99
+ # Track per-loop failures so a single failed project doesn't lose
100
+ # the dirty marker for the rest. _dirty is a single flag here, so
101
+ # if anything failed we leave it set and the next debounce/interval
102
+ # tick will retry the whole batch.
103
+ any_failed = False
104
+ for project in self.state.projects.values():
105
+ try:
106
+ await asyncio.to_thread(save_project, project, self.save_dir)
107
+ except Exception:
108
+ any_failed = True
109
+ logger.error("Failed to save project %s", project.name, exc_info=True)
110
+
111
+ if not any_failed:
112
+ self._dirty = False
113
+ self._last_save = time.monotonic()
114
+ logger.debug("Auto-saved %d project(s) to %s", len(self.state.projects), self.save_dir)
pixelweaver/bridge.py ADDED
@@ -0,0 +1,127 @@
1
+ """Desktop file I/O bridge for pywebview.
2
+
3
+ Provides native file dialogs and filesystem access callable from JavaScript
4
+ via pywebview's js_api mechanism. All binary data is transferred as
5
+ base64-encoded strings since the JS bridge serializes return values as JSON.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class DesktopBridge:
18
+ """File I/O bridge exposed to the frontend via pywebview js_api.
19
+
20
+ Methods on this class become callable from JavaScript as:
21
+ window.pywebview.api.method_name(args)
22
+ """
23
+
24
+ def save_file(
25
+ self,
26
+ data_b64: str,
27
+ default_name: str,
28
+ filter_name: str,
29
+ extensions: list[str],
30
+ ) -> str | None:
31
+ """Show a native Save dialog and write the file.
32
+
33
+ Returns the saved file path, or None if the user cancelled.
34
+ """
35
+ import webview
36
+
37
+ window = webview.windows[0]
38
+ file_types = (f"{filter_name} ({' '.join('*.' + e for e in extensions)})",)
39
+ result = window.create_file_dialog(
40
+ webview.SAVE_DIALOG,
41
+ save_filename=default_name,
42
+ file_types=file_types,
43
+ )
44
+ if not result:
45
+ return None
46
+ path = result if isinstance(result, str) else result[0]
47
+ try:
48
+ data = base64.b64decode(data_b64)
49
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
50
+ Path(path).write_bytes(data)
51
+ return path
52
+ except Exception:
53
+ logger.exception("Failed to write file %s", path)
54
+ return None
55
+
56
+ def open_file(
57
+ self,
58
+ filter_name: str,
59
+ extensions: list[str],
60
+ ) -> dict | None:
61
+ """Show a native Open dialog and read the file.
62
+
63
+ Returns {"path": str, "data": base64_string} or None if cancelled.
64
+ """
65
+ import webview
66
+
67
+ window = webview.windows[0]
68
+ file_types = (f"{filter_name} ({' '.join('*.' + e for e in extensions)})",)
69
+ result = window.create_file_dialog(
70
+ webview.OPEN_DIALOG,
71
+ file_types=file_types,
72
+ )
73
+ if not result:
74
+ return None
75
+ path = result[0] if isinstance(result, (tuple, list)) else result
76
+ try:
77
+ data = Path(path).read_bytes()
78
+ return {
79
+ "path": str(path),
80
+ "data": base64.b64encode(data).decode("ascii"),
81
+ }
82
+ except Exception:
83
+ logger.exception("Failed to read file %s", path)
84
+ return None
85
+
86
+ def write_file(self, path: str, data_b64: str) -> bool:
87
+ """Write binary data to a specific path (no dialog)."""
88
+ try:
89
+ data = base64.b64decode(data_b64)
90
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
91
+ Path(path).write_bytes(data)
92
+ return True
93
+ except Exception:
94
+ logger.exception("Failed to write file %s", path)
95
+ return False
96
+
97
+ def pick_directory(self) -> str | None:
98
+ """Show a native directory picker. Returns path or None if cancelled."""
99
+ import webview
100
+
101
+ window = webview.windows[0]
102
+ result = window.create_file_dialog(webview.FOLDER_DIALOG)
103
+ if not result:
104
+ return None
105
+ return result[0] if isinstance(result, (tuple, list)) else str(result)
106
+
107
+ def write_files_to_directory(
108
+ self,
109
+ dir_path: str,
110
+ files: list[dict],
111
+ ) -> bool:
112
+ """Write multiple files to a directory.
113
+
114
+ Each entry in files should be {"name": str, "data": base64_string}.
115
+ """
116
+ try:
117
+ base = Path(dir_path)
118
+ base.mkdir(parents=True, exist_ok=True)
119
+ for entry in files:
120
+ file_path = base / entry["name"]
121
+ file_path.parent.mkdir(parents=True, exist_ok=True)
122
+ data = base64.b64decode(entry["data"])
123
+ file_path.write_bytes(data)
124
+ return True
125
+ except Exception:
126
+ logger.exception("Failed to write files to %s", dir_path)
127
+ return False
pixelweaver/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """PixelWeaver CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import strictcli
10
+ from strictcli import App
11
+
12
+ _DEFAULT_PID = Path(".pixelweaver.pid")
13
+
14
+
15
+ def _get_version() -> str:
16
+ from importlib.metadata import version
17
+ return version("pixelweaver")
18
+
19
+
20
+ def _pkg_version(package_name: str, module_name: str | None = None) -> str:
21
+ """Get package version, trying module attr first then importlib.metadata."""
22
+ if module_name:
23
+ try:
24
+ mod = __import__(module_name)
25
+ ver = getattr(mod, "__version__", None)
26
+ if ver:
27
+ return ver
28
+ except ImportError:
29
+ pass
30
+ try:
31
+ from importlib.metadata import version
32
+ return version(package_name)
33
+ except Exception:
34
+ return "NOT FOUND"
35
+
36
+
37
+ app = App(
38
+ name="pixelweaver",
39
+ help="PixelWeaver collaboration server",
40
+ version=_get_version(),
41
+ config=True,
42
+ )
43
+
44
+ # Reusable tag for --data-dir flag (shared by multiple commands)
45
+ _data_dir_tag = strictcli.Tag(name="data", flags=[
46
+ strictcli.Flag(
47
+ name="data-dir",
48
+ type=str,
49
+ help="Projects directory",
50
+ default="./projects",
51
+ ),
52
+ ])
53
+
54
+
55
+ @app.command("serve", help="Start the server", tags=[_data_dir_tag])
56
+ @strictcli.flag("host", type=str, help="Bind address", default="127.0.0.1")
57
+ @strictcli.flag("port", type=int, help="Port", default=7779)
58
+ @strictcli.flag("reload", type=bool, help="Auto-reload on file changes")
59
+ def cmd_serve(*, host: str, port: int, data_dir: str, reload: bool, **_kw: object) -> None:
60
+ from pixelweaver.config import set_data_dir
61
+ from wesktop import serve
62
+ set_data_dir(data_dir)
63
+ serve("pixelweaver.main:app", foreground=True, host=host, port=port, pid_path=_DEFAULT_PID, reload=reload, name="PIXELWEAVER")
64
+
65
+
66
+ @app.command(
67
+ "new",
68
+ help="Create a new project on disk",
69
+ args=[strictcli.Arg(name="name", help="Project name")],
70
+ tags=[_data_dir_tag],
71
+ )
72
+ @strictcli.flag("width", type=int, help="Canvas width in pixels")
73
+ @strictcli.flag("height", type=int, help="Canvas height in pixels")
74
+ def cmd_new(name: str, *, width: int, height: int, data_dir: str, **_kw: object) -> None:
75
+ from pixelweaver.state import ServerState
76
+ from pixelweaver.storage import save_project
77
+ state = ServerState()
78
+ project = state.create_project(name, width, height)
79
+ save_project(project, data_dir)
80
+ print(f"Created project '{name}' ({width}x{height}) in {data_dir}/")
81
+
82
+
83
+ @app.command("list", help="List projects on disk", tags=[_data_dir_tag])
84
+ def cmd_list(*, data_dir: str, **_kw: object) -> None:
85
+ from pixelweaver.storage import list_projects
86
+ projects = list_projects(data_dir)
87
+ if not projects:
88
+ print("No projects found.")
89
+ else:
90
+ for name in projects:
91
+ print(f" {name}")
92
+
93
+
94
+ @app.command("mcp", help="Start the MCP tool server (stdio)", tags=[_data_dir_tag])
95
+ def cmd_mcp(*, data_dir: str, **_kw: object) -> None:
96
+ from pixelweaver.config import set_data_dir
97
+ from pixelweaver.mcp_server import run_mcp_server
98
+ set_data_dir(data_dir)
99
+ run_mcp_server()
100
+
101
+
102
+ @app.command("dev", help="Development mode with Vite HMR", tags=[_data_dir_tag])
103
+ @strictcli.flag("host", type=str, help="Bind address", default="127.0.0.1")
104
+ @strictcli.flag("port", type=int, help="Port", default=7779)
105
+ @strictcli.flag("vite-port", type=int, help="Vite dev server port", default=5173)
106
+ def cmd_dev(*, host: str, port: int, data_dir: str, vite_port: int, **_kw: object) -> None:
107
+ from pixelweaver.config import set_data_dir
108
+ from wesktop import dev
109
+ set_data_dir(data_dir)
110
+ dev(
111
+ "pixelweaver.main:app",
112
+ vite_command="npm run dev",
113
+ vite_port=vite_port,
114
+ host=host,
115
+ port=port,
116
+ pid_path=_DEFAULT_PID,
117
+ name="PIXELWEAVER",
118
+ )
119
+
120
+
121
+ @app.command("open", help="Launch desktop window", tags=[_data_dir_tag])
122
+ @strictcli.flag("host", type=str, help="Bind address", default="127.0.0.1")
123
+ @strictcli.flag("port", type=int, help="Port", default=7779)
124
+ @strictcli.flag("browser", type=bool, help="Open in browser instead of native window")
125
+ def cmd_open(*, host: str, port: int, data_dir: str, browser: bool, **_kw: object) -> None:
126
+ from pixelweaver.config import set_data_dir
127
+ set_data_dir(data_dir)
128
+
129
+ if browser:
130
+ import threading
131
+ import webbrowser
132
+ from wesktop import serve
133
+ url = serve(
134
+ "pixelweaver.main:app",
135
+ foreground=False,
136
+ host=host,
137
+ port=port,
138
+ pid_path=_DEFAULT_PID,
139
+ name="PIXELWEAVER",
140
+ )
141
+ webbrowser.open(url)
142
+ try:
143
+ threading.Event().wait()
144
+ except KeyboardInterrupt:
145
+ pass
146
+ else:
147
+ from pixelweaver.bridge import DesktopBridge
148
+ from wesktop import run
149
+ run(
150
+ "pixelweaver.main:app",
151
+ title="PixelWeaver",
152
+ width=1280,
153
+ height=800,
154
+ host=host,
155
+ port=port,
156
+ pid_path=_DEFAULT_PID,
157
+ icon=str(Path("assets/icon.png").resolve()),
158
+ js_api=DesktopBridge(),
159
+ name="PIXELWEAVER",
160
+ )
161
+
162
+
163
+ @app.command("diagnose", help="Check runtime environment and dependencies")
164
+ def cmd_diagnose(**_kw: object) -> None:
165
+ rows: list[tuple[str, str]] = []
166
+ rows.append(("Python", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"))
167
+ rows.append(("pixelweaver", _get_version()))
168
+
169
+ for label, pkg, mod in [
170
+ ("wesktop", "wesktop", "wesktop"),
171
+ ("pywebview", "pywebview", "webview"),
172
+ ("pydantic", "pydantic", "pydantic"),
173
+ ("pillow", "pillow", "PIL"),
174
+ ("mcp", "mcp", "mcp"),
175
+ ]:
176
+ ver = _pkg_version(pkg, mod)
177
+ suffix = " (ok)" if ver != "NOT FOUND" else ""
178
+ rows.append((label, f"{ver}{suffix}"))
179
+
180
+ rows.append(("platform", f"{platform.system()} {platform.machine()}"))
181
+ rows.append(("config", app.config_file_path))
182
+
183
+ label_width = max(len(label) for label, _ in rows)
184
+ for label, value in rows:
185
+ print(f" {label:<{label_width}} {value}")
186
+
187
+
188
+ @app.command("stop", help="Stop the running server")
189
+ def cmd_stop(**_kw: object) -> None:
190
+ from wesktop import stop
191
+ if not _DEFAULT_PID.exists():
192
+ print(f"No PID file found at {_DEFAULT_PID}")
193
+ return
194
+ stop(_DEFAULT_PID)
195
+ print("Server stopped.")
196
+
197
+
198
+ def main() -> None:
199
+ app.run()
pixelweaver/config.py ADDED
@@ -0,0 +1,24 @@
1
+ """Process-wide configuration for PixelWeaver server.
2
+
3
+ Single source of truth for settings that are set once at startup (e.g. from
4
+ CLI arguments) and then read throughout the application. Both the HTTP
5
+ server (main.py) and the MCP server (mcp_server.py) share this module so
6
+ they never drift out of sync.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # Default matches the CLI's DEFAULT_DATA_DIR; overridden via set_data_dir()
12
+ # before the server app starts.
13
+ _data_dir: str = "./projects"
14
+
15
+
16
+ def get_data_dir() -> str:
17
+ """Return the currently configured projects data directory."""
18
+ return _data_dir
19
+
20
+
21
+ def set_data_dir(data_dir: str) -> None:
22
+ """Set the projects data directory (called from CLI before app starts)."""
23
+ global _data_dir
24
+ _data_dir = data_dir
@@ -0,0 +1,54 @@
1
+ """WebSocket connection manager for PixelWeaver.
2
+
3
+ Tracks active connections and provides helpers for sending messages
4
+ to individual clients or broadcasting to all/most clients.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from wesktop import WebSocket
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ConnectionManager:
18
+ """Manages active WebSocket connections."""
19
+
20
+ def __init__(self) -> None:
21
+ self.active_connections: list[WebSocket] = []
22
+
23
+ async def connect(self, websocket: WebSocket) -> None:
24
+ """Accept and register a new WebSocket connection."""
25
+ await websocket.accept()
26
+ self.active_connections.append(websocket)
27
+ logger.info("Client connected (%d total)", len(self.active_connections))
28
+
29
+ def disconnect(self, websocket: WebSocket) -> None:
30
+ """Unregister a WebSocket connection."""
31
+ try:
32
+ self.active_connections.remove(websocket)
33
+ except ValueError:
34
+ pass # Already removed
35
+ logger.info("Client disconnected (%d remaining)", len(self.active_connections))
36
+
37
+ async def broadcast(
38
+ self,
39
+ message: dict[str, Any],
40
+ exclude: WebSocket | None = None,
41
+ ) -> None:
42
+ """Send a JSON message to all connected clients, optionally excluding one."""
43
+ # Snapshot the list so disconnects triggered by send failures (via
44
+ # exception handlers mutating active_connections) don't corrupt iteration.
45
+ for connection in list(self.active_connections):
46
+ if connection is not exclude:
47
+ try:
48
+ await connection.send_json(message)
49
+ except Exception:
50
+ logger.warning("Failed to send broadcast to a client", exc_info=True)
51
+
52
+ async def send(self, websocket: WebSocket, message: dict[str, Any]) -> None:
53
+ """Send a JSON message to a single client."""
54
+ await websocket.send_json(message)