Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
08b3dae
refactor(studio): unify setup terminal output style and add verbose s…
Imagineer99 Mar 19, 2026
234a94d
studio(windows): align setup.ps1 banner/steps with setup.sh (ANSI, ve…
Imagineer99 Mar 19, 2026
b4e6ba3
studio(setup): revert nvcc path reordering to match main
Imagineer99 Mar 20, 2026
de93634
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 20, 2026
38516de
Merge branch 'main' into feat/studio-setup-log-styling
Imagineer99 Mar 20, 2026
9a691e4
studio(setup): restore fail-fast llama.cpp setup flow
Imagineer99 Mar 20, 2026
d11dc31
studio(banner): use IPv6 loopback URL when binding :: or ::1
Imagineer99 Mar 21, 2026
67a997f
Merge main into feat/studio-setup-log-styling
danielhanchen Mar 24, 2026
67fda0c
Fix IPv6 URL bracketing, try_quiet stderr, _step label clamp
danielhanchen Mar 24, 2026
7ed5b69
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 24, 2026
28981e5
Add sandbox integration tests for PR #4494 UX fixes
danielhanchen Mar 24, 2026
86490e7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 24, 2026
630d6a9
Truncate step() labels in setup.sh to match PS1 and Python
danielhanchen Mar 24, 2026
83cab0c
Merge branch 'main' into feat/studio-setup-log-styling
danielhanchen Mar 25, 2026
331edae
Remove sandbox integration tests from PR
danielhanchen Mar 25, 2026
5daeba5
Merge main into feat/studio-setup-log-styling
danielhanchen Mar 25, 2026
07850d7
Merge branch 'main' into feat/studio-setup-log-styling
danielhanchen Mar 25, 2026
f3f7dbf
Show error output on failure instead of suppressing it
danielhanchen Mar 25, 2026
35ef245
Merge branch 'main' into feat/studio-setup-log-styling
danielhanchen Mar 25, 2026
81a963a
Show winget error output for Git and CMake installs on failure
danielhanchen Mar 25, 2026
3613325
Merge branch 'main' into feat/studio-setup-log-styling
danielhanchen Mar 25, 2026
e0de157
Merge branch 'main' into feat/studio-setup-log-styling
danielhanchen Mar 25, 2026
7dcd7d8
Merge origin/main into feat/studio-setup-log-styling
danielhanchen Mar 26, 2026
2a7c581
fix: preserve stderr for _run_quiet error messages in setup.sh
danielhanchen Mar 26, 2026
b15dc8f
feat: add --verbose flag to setup and update commands
danielhanchen Mar 26, 2026
3e05a2a
Merge branch 'main' into feat/studio-setup-log-styling
Imagineer99 Mar 26, 2026
618b63c
Merge branch 'main' into feat/studio-setup-log-styling
danielhanchen Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 6 additions & 13 deletions studio/backend/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import _platform_compat # noqa: F401

from loggers import get_logger
from startup_banner import print_studio_access_banner

logger = get_logger(__name__)

Expand Down Expand Up @@ -338,19 +339,11 @@ def _run():

if not silent:
display_host = _resolve_external_ip() if host == "0.0.0.0" else host

print("")
print("=" * 50)
print(f"🦥 Open your web browser, and enter http://localhost:{port}")
print("=" * 50)
print("")
print("=" * 50)
print(f"🦥 Unsloth Studio is running on port {port}")
print(f" Local Access: http://localhost:{port}")
print(f" Worldwide Web Address: http://{display_host}:{port}")
print(f" API: http://{display_host}:{port}/api")
print(f" Health: http://{display_host}:{port}/api/health")
print("=" * 50)
print_studio_access_banner(
port = port,
bind_host = host,
display_host = display_host,
)

return app

Expand Down
115 changes: 115 additions & 0 deletions studio/backend/startup_banner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Terminal banner for Studio startup.

Stdlib only — safe to import without the rest of the backend (no structlog/uvicorn).
"""

from __future__ import annotations

import os
import sys


def stdout_supports_color() -> bool:
"""True if we should emit ANSI colors."""
if os.environ.get("NO_COLOR", "").strip():
return False
if os.environ.get("FORCE_COLOR", "").strip():
return True
try:
return sys.stdout.isatty()
except Exception:
return False
Comment on lines +23 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The except Exception block is too broad and silently catches all exceptions. This can hide potential issues and make debugging difficult. It's best practice to catch specific exceptions or at least log the exception for future debugging, even if at a debug level. This aligns with the general rule: "Avoid using broad, silent exception handlers like except Exception: pass. Instead, log the exception, even if at a debug level, to aid in future debugging."

References
  1. Avoid using broad, silent exception handlers like except Exception: pass. Instead, log the exception, even if at a debug level, to aid in future debugging.



def print_port_in_use_notice(original_port: int, new_port: int) -> None:
"""Message when the requested port is taken and another is chosen."""
msg = f"Port {original_port} is in use, using port {new_port} instead."
if stdout_supports_color():
print(f"\033[38;5;245m{msg}\033[0m")
else:
print(msg)


def print_studio_access_banner(
*,
port: int,
bind_host: str,
display_host: str,
) -> None:
"""Pretty-print URLs after the server is listening (beginner-friendly)."""
Comment on lines +15 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with install_python_stack.py and to ensure colors work correctly on Windows, this function should also attempt to enable virtual terminal (VT) processing. The current implementation only checks if stdout is a TTY, which is not sufficient on its own for modern Windows terminals. Additionally, the broad except Exception: blocks have been modified to log the exception, adhering to the repository rule against silent exception handling for better debugging.

def stdout_supports_color() -> bool:
    """True if we should emit ANSI colors."""
    if os.environ.get("NO_COLOR", "").strip():
        return False
    if os.environ.get("FORCE_COLOR", "").strip():
        return True
    try:
        if not sys.stdout.isatty():
            return False
    except Exception as e:
        # Log the exception for debugging purposes as per repository rule.
        import logging
        logging.debug(f"stdout.isatty() check failed: {e}")
        return False

    if sys.platform == "win32":
        try:
            import ctypes
            kernel32 = ctypes.windll.kernel32
            handle = kernel32.GetStdHandle(-11)  # STD_OUTPUT_HANDLE
            mode = ctypes.c_ulong()
            kernel32.GetConsoleMode(handle, ctypes.byref(mode))
            kernel32.SetConsoleMode(handle, mode.value | 0x0004)
        except Exception as e:
            # Log the exception for debugging purposes as per repository rule.
            import logging
            logging.debug(f"Failed to enable VT processing on Windows: {e}")
            return False
    return True
References
  1. Avoid using broad, silent exception handlers like except Exception: pass. Instead, log the exception, even if at a debug level, to aid in future debugging.

use_color = stdout_supports_color()
dim = "\033[38;5;245m"
title = "\033[38;5;150m"
local_url_style = "\033[38;5;108;1m"
secondary = "\033[38;5;109m"
reset = "\033[0m"

def style(text: str, code: str) -> str:
return f"{code}{text}{reset}" if use_color else text

ipv6_bind = bind_host in ("::", "::1")
if ipv6_bind:
local_url = f"http://[::1]:{port}"
alt_local = f"http://localhost:{port}"
else:
local_url = f"http://127.0.0.1:{port}"
alt_local = f"http://localhost:{port}"
Comment thread
Imagineer99 marked this conversation as resolved.
if ":" in display_host:
external_url = f"http://[{display_host}]:{port}"
else:
external_url = f"http://{display_host}:{port}"
listen_all = bind_host in ("0.0.0.0", "::")
loopback_bind = bind_host in ("127.0.0.1", "localhost", "::1")
api_base = local_url if listen_all or loopback_bind else external_url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Print reachable API URLs when binding all interfaces

When bind_host is 0.0.0.0/::, api_base is forced to local_url, so the banner advertises 127.0.0.1/::1 API endpoints even though the server is intentionally exposed on another host address. In remote/server setups, users copying the shown /api URL from the banner get a non-routable loopback address instead of the reachable network address, which is a regression from the prior display_host-based API links.

Useful? React with 👍 / 👎.


lines: list[str] = [
"",
style("🦥 Unsloth Studio is running", title),
style("─" * 52, dim),
style(" On this machine — open this in your browser:", dim),
style(f" {local_url}", local_url_style),
style(f" (same as {alt_local})", dim),
]

if listen_all and display_host not in (
"127.0.0.1",
"localhost",
"::1",
"0.0.0.0",
"::",
):
lines.extend(
[
"",
style(" From another device on your network / to share:", dim),
style(f" {external_url}", secondary),
]
)
elif not listen_all and bind_host not in ("127.0.0.1", "localhost", "::1"):
lines.extend(
[
"",
style(" Bound address:", dim),
style(f" {external_url}", secondary),
]
)

lines.extend(
[
"",
style(" API & health:", dim),
style(f" {api_base}/api", secondary),
style(f" {api_base}/api/health", secondary),
style("─" * 52, dim),
style(
" Tip: if you are on the same computer, use the Local link above.",
dim,
),
"",
]
)

print("\n".join(lines))
80 changes: 51 additions & 29 deletions studio/install_python_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def _infer_no_torch() -> bool:
# -- Verbosity control ----------------------------------------------------------
# By default the installer shows a minimal progress bar (one line, in-place).
# Set UNSLOTH_VERBOSE=1 in the environment to restore full per-step output:
# CLI: unsloth studio setup --verbose
# Linux/Mac: UNSLOTH_VERBOSE=1 ./studio/setup.sh
# Windows: $env:UNSLOTH_VERBOSE="1" ; .\studio\setup.ps1
VERBOSE: bool = os.environ.get("UNSLOTH_VERBOSE", "0") == "1"
Expand Down Expand Up @@ -96,15 +97,18 @@ def _safe_print(*args: object, **kwargs: object) -> None:
)


# -- Color support ------------------------------------------------------
# ── Color support ──────────────────────────────────────────────────────
# Same logic as startup_banner: NO_COLOR disables, FORCE_COLOR or TTY enables.


def _enable_colors() -> bool:
"""Try to enable ANSI color support. Returns True if available."""
if not hasattr(sys.stdout, "fileno"):
def _stdout_supports_color() -> bool:
"""True if we should emit ANSI colors (matches startup_banner)."""
if os.environ.get("NO_COLOR", "").strip():
return False
if os.environ.get("FORCE_COLOR", "").strip():
return True
try:
if not os.isatty(sys.stdout.fileno()):
if not sys.stdout.isatty():
return False
except Exception:
return False
Comment on lines 113 to 114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The except Exception block is too broad and silently catches all exceptions. This can hide potential issues and make debugging difficult. It's best practice to catch specific exceptions or at least log the exception for future debugging, even if at a debug level. This aligns with the general rule: "Avoid using broad, silent exception handlers like except Exception: pass. Instead, log the exception, even if at a debug level, to aid in future debugging."

References
  1. Avoid using broad, silent exception handlers like except Exception: pass. Instead, log the exception, even if at a debug level, to aid in future debugging.

Expand All @@ -113,24 +117,26 @@ def _enable_colors() -> bool:
import ctypes

kernel32 = ctypes.windll.kernel32
# Enable ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x0004) on stdout
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
handle = kernel32.GetStdHandle(-11)
mode = ctypes.c_ulong()
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
return True
except Exception:
return False
Comment on lines 124 to 125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The except Exception block is too broad and silently catches all exceptions. This can hide potential issues and make debugging difficult. It's best practice to catch specific exceptions or at least log the exception for future debugging, even if at a debug level. This aligns with the general rule: "Avoid using broad, silent exception handlers like except Exception: pass. Instead, log the exception, even if at a debug level, to aid in future debugging."

References
  1. Avoid using broad, silent exception handlers like except Exception: pass. Instead, log the exception, even if at a debug level, to aid in future debugging.

return True # Unix terminals support ANSI by default
return True


_HAS_COLOR = _stdout_supports_color()


# Colors disabled -- Colab and most CI runners render ANSI fine, but plain output
# is cleaner in the notebook cell. Re-enable by setting _HAS_COLOR = _enable_colors()
_HAS_COLOR = False
# Column layout — matches setup.sh step() helper:
# 2-space indent, 15-char label (dim), then value.
_LABEL = "deps"
_COL = 15


def _green(msg: str) -> str:
return f"\033[92m{msg}\033[0m" if _HAS_COLOR else msg
return f"\033[38;5;108m{msg}\033[0m" if _HAS_COLOR else msg


def _cyan(msg: str) -> str:
Expand All @@ -141,21 +147,39 @@ def _red(msg: str) -> str:
return f"\033[91m{msg}\033[0m" if _HAS_COLOR else msg


def _progress(label: str) -> None:
"""Print an in-place progress bar for the current install step.
def _dim(msg: str) -> str:
return f"\033[38;5;245m{msg}\033[0m" if _HAS_COLOR else msg

Uses only stdlib (sys.stdout) -- no extra packages required.
In VERBOSE mode this is a no-op; per-step labels are printed by run() instead.
"""

def _title(msg: str) -> str:
return f"\033[38;5;150m{msg}\033[0m" if _HAS_COLOR else msg


_RULE = "\u2500" * 52


def _step(label: str, value: str, color_fn = None) -> None:
"""Print a single step line in the column format."""
if color_fn is None:
color_fn = _green
padded = label[:_COL]
print(f" {_dim(padded)}{' ' * (_COL - len(padded))}{color_fn(value)}")


def _progress(label: str) -> None:
"""Print an in-place progress bar aligned to the step column layout."""
global _STEP
_STEP += 1
if VERBOSE:
return # verbose mode: run() already printed the label
return
width = 20
filled = int(width * _STEP / _TOTAL)
bar = "=" * filled + "-" * (width - filled)
end = "\n" if _STEP >= _TOTAL else "" # newline only on the final step
sys.stdout.write(f"\r[{bar}] {_STEP:2}/{_TOTAL} {label:<40}{end}")
pad = " " * (_COL - len(_LABEL))
end = "\n" if _STEP >= _TOTAL else ""
sys.stdout.write(
f"\r {_dim(_LABEL)}{pad}[{bar}] {_STEP:2}/{_TOTAL} {label:<20}{end}"
)
sys.stdout.flush()


Expand All @@ -164,14 +188,14 @@ def run(
) -> subprocess.CompletedProcess[bytes]:
"""Run a command; on failure print output and exit."""
if VERBOSE:
print(f" {label}...")
_step(_LABEL, f"{label}...", _dim)
result = subprocess.run(
cmd,
stdout = subprocess.PIPE if quiet else None,
stderr = subprocess.STDOUT if quiet else None,
)
if result.returncode != 0:
_safe_print(_red(f"❌ {label} failed (exit code {result.returncode}):"))
_step("error", f"{label} failed (exit code {result.returncode})", _red)
if result.stdout:
print(result.stdout.decode(errors = "replace"))
sys.exit(result.returncode)
Expand Down Expand Up @@ -353,9 +377,7 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None:
text = True,
)
if result.returncode != 0:
_safe_print(
_red(f" ⚠️ Could not find package {package_name}, skipping patch")
)
_step(_LABEL, f"package {package_name} not found, skipping patch", _red)
return

location = None
Expand All @@ -365,11 +387,11 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None:
break

if not location:
_safe_print(_red(f" ⚠️ Could not determine location of {package_name}"))
_step(_LABEL, f"could not locate {package_name}", _red)
return

dest = Path(location) / relative_path
print(_cyan(f" Patching {dest.name} in {package_name}..."))
_step(_LABEL, f"patching {dest.name} in {package_name}...", _dim)
download_file(url, dest)


Expand Down Expand Up @@ -633,7 +655,7 @@ def install_python_stack() -> int:
stderr = subprocess.DEVNULL,
)

_safe_print(_green("✅ Python dependencies installed"))
_step(_LABEL, "installed")
return 0


Expand Down
Loading