unsloth/studio/backend/colab.py
Daniel Han 100b8857f2
Fix Studio crash on Anaconda/conda-forge Python (#4484)
* Fix Studio crash on Anaconda Python due to platform._sys_version() parse failure

Anaconda and conda-forge modify sys.version to include distributor
metadata between pipe characters, e.g.:

    3.12.4 | packaged by Anaconda, Inc. | (main, ...) [MSC v.1929 ...]

Python's platform._sys_version() has a hardcoded regex that cannot
parse this format, raising ValueError. CPython closed this as "not
planned" (cpython#102396) since Anaconda modified the binary.

This breaks the import chain: run.py -> structlog -> rich -> attrs,
which calls platform.python_implementation() at module scope.

Fix: before any library imports, strip the pipe segments, parse the
cleaned version string via the standard parser, and cache the result
under the original sys.version key so all subsequent platform calls
hit the cache.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add defensive fallback for unpaired pipe edge cases in version patch

Address Gemini review suggestion: if the paired-pipe regex leaves
residual pipes (hypothetical single-pipe distributor metadata), fall
back to extracting the version number and the parenthesized build
info directly. Wrap the entire patch in try/except so unexpected
version string formats degrade gracefully instead of crashing the
patch itself.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refactor into shared _platform_compat module, cover colab.py entrypoint

Address reviewer feedback:

1. Extract the Anaconda/conda-forge sys.version fix into a shared
   _platform_compat.py module that wraps platform._sys_version() with
   a retry-on-ValueError fallback. This is more robust than cache-seeding
   because it handles all future platform._sys_version() calls, not just
   the first one.

2. Import the fix from both run.py and colab.py entrypoints, so Studio
   no longer crashes on Anaconda Python regardless of the launch path.

3. The wrapper is idempotent (guarded by a flag) and handles edge cases:
   paired pipes (Anaconda, conda-forge), unpaired pipes (hypothetical),
   and standard CPython strings (no-op since ValueError is never raised).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Replace monkey-patch with cache-prime, fix colab.py duplicate sys.path, cover main.py

- Rewrite _platform_compat.py: replace function-wrapping monkey-patch with
  one-shot cache seed (_seed_sys_version_cache). Parses cleaned sys.version
  once and seeds platform._sys_version_cache so the stdlib parser never sees
  the problematic Anaconda/conda-forge pipe-delimited string. No function
  replacement, no idempotency flag, no reload edge cases.

- colab.py: remove duplicate backend_path sys.path insertion after
  _bootstrap_studio_venv(). The early insertion (before _platform_compat
  import) already covers it. This also fixes backend/ ending up behind
  venv site-packages in sys.path ordering.

- run.py: move PYTHONWARNINGS=ignore before _platform_compat import to
  preserve original intent of suppressing warnings early.

- main.py: add sys.path + _platform_compat import before route imports,
  covering the direct `uvicorn main:app` launch path.

- Add test_platform_compat.py with 7 tests covering Anaconda, conda-forge,
  and standard CPython version strings, plus the loggers import chain.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove test_platform_compat.py from PR

* Handle Format B conda-forge version strings with duplicate paren groups

Some conda-forge builds produce sys.version with the build info both
before and after the pipe label (e.g. "3.9.7 (default, ...) | packaged
by conda-forge | (default, ...) \n[GCC 7.5.0]"). After stripping the
pipe segment, two consecutive (...) groups remain, which still fails
platform._sys_version(). Add a second regex pass to drop the duplicate
paren group.

* Guard _sys_version call with try/except to avoid making things worse

If the cleaned version string is still unparseable by the stdlib regex
(e.g. nested parens, exotic multi-pipe formats), silently give up
instead of letting ValueError propagate at import time -- which would
be a worse crash than the original deferred one.

---------

Co-authored-by: Daniel Han <danielhanchen@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-22 05:36:55 -07:00

136 lines
4.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Colab-specific helpers for running Unsloth Studio.
Uses Colab's built-in proxy - no external tunneling needed!
"""
from pathlib import Path
import sys
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
# See: https://github.com/python/cpython/issues/102396
_backend_dir = str(Path(__file__).parent)
if _backend_dir not in sys.path:
sys.path.insert(0, _backend_dir)
import _platform_compat # noqa: F401
def _bootstrap_studio_venv() -> None:
"""Expose the Studio venv's site-packages to the current interpreter.
On Colab, notebook cells run outside the venv subshell. Instead of
installing the full stack into system Python, we prepend the venv's
site-packages so that packages like structlog, fastapi, etc. are
importable from notebook cells and take priority over system copies.
"""
venv_lib = Path.home() / ".unsloth" / "studio" / ".venv" / "lib"
if not venv_lib.exists():
import warnings
warnings.warn(
f"Studio venv not found at {venv_lib.parent} -- run 'unsloth studio setup' first",
stacklevel = 2,
)
return
for sp in venv_lib.glob("python*/site-packages"):
sp_str = str(sp)
if sp_str not in sys.path:
sys.path.insert(0, sp_str)
_bootstrap_studio_venv()
from loggers import get_logger
logger = get_logger(__name__)
def get_colab_url(port: int = 8888) -> str:
"""
Get the actual Colab proxy URL for a port.
"""
try:
from google.colab.output import eval_js
# Use Colab's proxy mechanism
url = eval_js(f"google.colab.kernel.proxyPort({port})", timeout_sec = 5)
return url if url else f"http://localhost:{port}"
except Exception as e:
logger.info(f"Note: Could not get Colab URL ({e})")
return f"http://localhost:{port}"
def show_link(port: int = 8888):
"""Display a styled clickable link to the UI."""
from IPython.display import display, HTML
# Get real Colab proxy URL
url = get_colab_url(port)
short_url = (
url[: url.index("-", url.index(f"{port}-") + len(str(port)) + 1) + 1] + "..."
if f"{port}-" in url
else url
)
html = f"""
<div style="display: inline-block; padding: 20px; background: #ffffff; border: 2px solid #000000;
border-radius: 12px; margin: 10px 0; font-family: system-ui, -apple-system, sans-serif;">
<h2 style="color: #000000; margin: 0 0 12px 0; font-size: 26px; font-weight: 800;
display: flex; align-items: center; gap: 12px;">
<img src="https://github.com/unslothai/unsloth/raw/main/studio/frontend/public/unsloth-gem.png"
height="48" style="display:block;">
Unsloth Studio is Ready!
</h2>
<a href="{url}" target="_blank"
style="display: inline-flex; align-items: center; gap: 10px; padding: 14px 28px;
background: #000000; color: white; text-decoration: none; border-radius: 8px;
font-weight: 800; font-size: 16px;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="white"><polygon points="5,3 19,12 5,21"/></svg>
Open Unsloth Studio
</a>
<p style="color: #333333; margin: 16px 0 0 0; font-size: 13px; font-family: monospace;">
{short_url}
</p>
</div>
"""
display(HTML(html))
def start(port: int = 8888):
"""
Start Unsloth Studio server in Colab and display the URL.
Usage:
from colab import start
start()
"""
import sys
logger.info("🦥 Starting Unsloth Studio...")
logger.info(" Loading backend...")
from run import run_server
# Auto-detect frontend path
repo_root = Path(__file__).parent.parent
frontend_path = repo_root / "frontend" / "dist"
if not frontend_path.exists():
logger.info("❌ Frontend not built! Please run the setup cell first.")
return
logger.info(" Starting server...")
# Start server silently
run_server(host = "0.0.0.0", port = port, frontend_path = frontend_path, silent = True)
logger.info(" Server started!")
# Show the clickable link with real URL
show_link(port)
if __name__ == "__main__":
start()