Skip to content

Commit 0e3da0f

Browse files
committed
fix: resolve PyInstaller frozen binary runtime errors
Three issues caused the binary to crash on startup: 1. STATIC_DIR used Path(__file__).parent which points inside the PyInstaller temp extraction dir, not where --add-data places files. Fixed to use sys._MEIPASS / <package> / static when frozen in both manager/main.py and connector_tester/main.py. 2. app_registry._find_project_root() returned Path(sys.executable).parent when frozen, but --onefile binaries have no apps/ dir next to them. Fixed to use sys._MEIPASS where the bundled pyproject.toml files live. 3. uvicorn.run() was passed a module-string ('ai_utilities_manager.main:app') which fails inside a frozen binary. Fixed to pass the app object directly in both the manager and connector-tester. Also bundle apps/connector-tester/pyproject.toml in build.py so that discover_apps() can read it from _MEIPASS at runtime.
1 parent b1a6618 commit 0e3da0f

File tree

7 files changed

+74
-21
lines changed

7 files changed

+74
-21
lines changed

apps/connector-tester/src/connector_tester/main.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import sys
34
import uvicorn
45
from fastapi import FastAPI, HTTPException
56
from fastapi.responses import FileResponse
@@ -14,7 +15,11 @@
1415
version="0.1.0",
1516
)
1617

17-
STATIC_DIR = Path(__file__).parent / "static"
18+
# When frozen by PyInstaller the static files are under sys._MEIPASS.
19+
if getattr(sys, "frozen", False):
20+
STATIC_DIR = Path(sys._MEIPASS) / "connector_tester" / "static"
21+
else:
22+
STATIC_DIR = Path(__file__).parent / "static"
1823

1924

2025
# ── API routes ──────────────────────────────────────────────────────────────
@@ -64,7 +69,8 @@ async def serve_index():
6469

6570

6671
def run():
67-
uvicorn.run("connector_tester.main:app", host="0.0.0.0", port=8001, reload=False)
72+
# Pass the app object directly so this works inside a PyInstaller bundle.
73+
uvicorn.run(app, host="0.0.0.0", port=8001, reload=False)
6874

6975

7076
if __name__ == "__main__":

build.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,13 @@ def build():
7272
# Add paths for workspace packages
7373
"--paths", str(MANAGER_SRC.parent),
7474
"--paths", str(CONNECTOR_SRC.parent),
75-
# Bundle static assets
75+
# Bundle static assets – destination paths match what the code expects
76+
# under sys._MEIPASS at runtime.
7677
"--add-data", f"{manager_static}{chr(58)}ai_utilities_manager/static",
7778
"--add-data", f"{connector_static}{chr(58)}connector_tester/static",
79+
# Bundle workspace pyproject.toml files so discover_apps() can read
80+
# them from sys._MEIPASS/apps/<app-id>/pyproject.toml when frozen.
81+
"--add-data", f"{ROOT / 'apps' / 'connector-tester' / 'pyproject.toml'}{chr(58)}apps/connector-tester",
7882
]
7983

8084
# macOS: target ARM64

manager/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ai-utilities-manager"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "AI Utilities Manager - Directory manager for AI-powered developer tools"
55
readme = "README.md"
66
requires-python = ">=3.13"

manager/src/ai_utilities_manager/app_registry.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,22 @@ def to_dict(self) -> dict:
7070

7171

7272
def _find_project_root() -> Path:
73-
"""Find the ai-utilities project root by traversing up from this file."""
74-
# When running as a PyInstaller executable, look relative to the executable
73+
"""Find the ai-utilities project root by traversing up from this file.
74+
75+
When running as a frozen PyInstaller binary, the bundled data files
76+
(including apps/*/pyproject.toml) are extracted into sys._MEIPASS, so we
77+
use that as the root – the apps/ directory will be present there.
78+
79+
When running from source we walk up from __file__ until we hit the directory
80+
that contains the apps/ folder.
81+
"""
7582
if getattr(sys, "frozen", False):
76-
return Path(sys.executable).parent
83+
# sys._MEIPASS is the temp directory where PyInstaller unpacks the bundle.
84+
# We bundle apps/connector-tester/pyproject.toml → apps/connector-tester/
85+
# so apps/ is present directly under _MEIPASS.
86+
return Path(sys._MEIPASS)
7787

78-
# When running from source, traverse up from this file:
88+
# Running from source: traverse up from this file:
7989
# .../ai-utilities/manager/src/ai_utilities_manager/app_registry.py
8090
here = Path(__file__).resolve()
8191
# Go up: ai_utilities_manager -> src -> manager -> ai-utilities
@@ -148,16 +158,15 @@ def launch_app(app: AppInfo) -> bool:
148158
webbrowser.open(f"http://localhost:{app.port}")
149159
return False
150160

151-
# Build the command: use uv run in the app directory
161+
# Build the command to launch the app subprocess
152162
root = _find_project_root()
153-
app_dir = root / "apps" / app.id
154-
155-
cmd = [sys.executable, "-m", app.entry_module]
156163
if getattr(sys, "frozen", False):
157-
# Running as frozen executable – call the bundled module directly
158-
cmd = [sys.executable, "-m", app.entry_module]
164+
# Re-invoke the same frozen binary with a --run-app flag.
165+
# The entry point (run() in main.py) handles this argument.
166+
cmd = [sys.executable, "--run-app", app.id]
159167
else:
160168
# Running from source with uv
169+
app_dir = root / "apps" / app.id
161170
cmd = ["uv", "run", "--project", str(app_dir), "python", "-m", app.entry_module]
162171

163172
try:

manager/src/ai_utilities_manager/main.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,26 @@
55
"""
66
from __future__ import annotations
77

8-
import asyncio
8+
import sys
99
import webbrowser
1010
from contextlib import asynccontextmanager
1111
from pathlib import Path
1212

1313
import uvicorn
1414
from fastapi import FastAPI, HTTPException
15-
from fastapi.responses import FileResponse, JSONResponse
15+
from fastapi.responses import FileResponse
1616
from fastapi.staticfiles import StaticFiles
1717

1818
from ai_utilities_manager import app_registry, updater
1919
from ai_utilities_manager.config import load_config, save_config
2020

21-
STATIC_DIR = Path(__file__).parent / "static"
21+
# When running as a PyInstaller --onefile binary, data files are extracted to
22+
# sys._MEIPASS and placed under the package-relative path used in --add-data.
23+
# When running from source, they live next to this file.
24+
if getattr(sys, "frozen", False):
25+
STATIC_DIR = Path(sys._MEIPASS) / "ai_utilities_manager" / "static"
26+
else:
27+
STATIC_DIR = Path(__file__).parent / "static"
2228

2329
# ── Lifespan ─────────────────────────────────────────────────────────────────
2430

@@ -134,16 +140,44 @@ async def serve_index():
134140
# ── Entry point ───────────────────────────────────────────────────────────────
135141

136142

143+
def _run_sub_app(app_id: str) -> None:
144+
"""Launch a bundled workspace app by its ID (used when frozen).
145+
146+
The manager re-invokes itself with ``--run-app <app_id>`` to start a
147+
workspace app in-process without needing a separate Python environment.
148+
"""
149+
from ai_utilities_manager.app_registry import _APP_METADATA
150+
meta = _APP_METADATA.get(app_id)
151+
if not meta:
152+
raise SystemExit(f"Unknown app id: {app_id}")
153+
154+
module_name = meta["entry_module"]
155+
import importlib
156+
mod = importlib.import_module(module_name)
157+
mod.run() # type: ignore[attr-defined]
158+
159+
137160
def run():
138-
"""Start the AI Utilities Manager and open the browser."""
161+
"""Start the AI Utilities Manager (or a sub-app when --run-app is passed)."""
139162
import threading, time
140163

164+
# When the frozen binary is re-invoked with --run-app <id>, start that
165+
# workspace app instead of the manager dashboard.
166+
if "--run-app" in sys.argv:
167+
idx = sys.argv.index("--run-app")
168+
if idx + 1 < len(sys.argv):
169+
_run_sub_app(sys.argv[idx + 1])
170+
return
171+
141172
def _open_browser():
142173
time.sleep(1.2)
143174
webbrowser.open("http://localhost:8000")
144175

145176
threading.Thread(target=_open_browser, daemon=True).start()
146-
uvicorn.run("ai_utilities_manager.main:app", host="0.0.0.0", port=8000, reload=False)
177+
# Pass the app object directly (not as a string) so that uvicorn works
178+
# correctly inside a PyInstaller frozen binary where module-string imports
179+
# would fail. reload=False is required when passing the app object.
180+
uvicorn.run(app, host="0.0.0.0", port=8000, reload=False)
147181

148182

149183
if __name__ == "__main__":

manager/src/ai_utilities_manager/updater.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
GITHUB_REPO = "latalkdesk/ai-utilities"
2424
RELEASES_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
25-
CURRENT_VERSION = "0.1.0"
25+
CURRENT_VERSION = "0.1.1"
2626

2727
# Asset name pattern for macOS ARM
2828
ASSET_SUFFIX_MAP = {

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)