pip: declare the Studio dependencies the wheel's own modules import (#7493)
* pip: declare the Studio dependencies the wheel's own modules import The wheel packages studio/ and studio.backend*, so pip install unsloth puts five commands on PATH -- train, export, chat, inference, studio -- and every one of them imports studio.backend.*. None of those imports were declared, so all five ended in a rich traceback at ModuleNotFoundError: No module named 'structlog' (#4701, #5260, #7147). --help rendered fine for all of them because typer defers the import, which is why this went unnoticed. Walking module-level, non-try-guarded imports from each entry point shows structlog is the only hard requirement they share, once starlette's annotation-only import in loggers/handlers.py moves under TYPE_CHECKING. So structlog becomes a core dependency and the rest of the server stack (fastapi, uvicorn, matplotlib, pandas, pymupdf, ...) becomes a [studio] extra mirroring studio/backend/requirements/studio.txt, with a test that fails if the two drift. pip install unsloth -> train / export work pip install "unsloth[studio]" -> the server works * Apply ruff-format quote normalisation * Trim the comments added in this PR
This commit is contained in:
parent
8d38165771
commit
735fcde44c
3 changed files with 122 additions and 1 deletions
|
|
@ -52,6 +52,9 @@ dependencies = [
|
|||
"pydantic",
|
||||
"pyyaml",
|
||||
"nest-asyncio",
|
||||
# Every CLI command imports studio.backend.*, which reaches structlog at
|
||||
# module level. The rest of the server stack lives in the studio extra.
|
||||
"structlog>=24.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
@ -90,6 +93,33 @@ include = ["unsloth*", "unsloth_cli*", "studio", "studio.backend*"]
|
|||
exclude = ["images*", "tests*", "*.node_modules", "*.node_modules.*"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Studio's server stack. Mirrors studio/backend/requirements/studio.txt;
|
||||
# test_studio_extra_matches_requirements.py catches drift.
|
||||
studio = [
|
||||
"typer",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"pydantic",
|
||||
"packaging",
|
||||
"matplotlib==3.10.9",
|
||||
"pandas",
|
||||
"nest_asyncio",
|
||||
"datasets==4.3.0",
|
||||
"pyjwt",
|
||||
"huggingface-hub==0.36.2",
|
||||
"structlog>=24.1.0",
|
||||
"diceware",
|
||||
"ddgs",
|
||||
"cryptography>=42.0.0",
|
||||
"boto3>=1.34.0",
|
||||
"httpx>=0.27.0",
|
||||
"fastmcp>=3.0.2",
|
||||
"sqlite-vec==0.1.9",
|
||||
"pymupdf==1.27.2.3",
|
||||
"pymupdf4llm==0.3.4",
|
||||
"python-docx==1.2.0",
|
||||
]
|
||||
|
||||
triton = [
|
||||
"triton>=3.0.0 ; ('linux' in sys_platform)",
|
||||
"triton-windows ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
|
|
|
|||
|
|
@ -8,12 +8,19 @@ filter_sensitive_data (structlog processor for sanitization), and
|
|||
get_logger (factory for structured loggers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
# Annotations only: importing at runtime would make the ASGI stack a hard
|
||||
# dependency of every CLI command.
|
||||
if TYPE_CHECKING:
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from utils.native_path_leases import redact_native_paths
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""The studio extra must mirror studio/backend/requirements/studio.txt.
|
||||
|
||||
Nothing else keeps them in sync, and drift reintroduces #4701 / #5260 / #7147.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||
STUDIO_TXT = REPO_ROOT / "studio" / "backend" / "requirements" / "studio.txt"
|
||||
|
||||
# Imported at module scope by the studio.backend chain every CLI command walks.
|
||||
CORE_RUNTIME_PACKAGES = ("structlog",)
|
||||
|
||||
|
||||
def _load_pyproject() -> dict:
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
tomllib = pytest.importorskip("tomli")
|
||||
return tomllib.loads(PYPROJECT.read_text(encoding = "utf-8"))
|
||||
|
||||
|
||||
def _requirement_lines(path: pathlib.Path) -> list[str]:
|
||||
out = []
|
||||
for line in path.read_text(encoding = "utf-8").splitlines():
|
||||
text = line.split("#", 1)[0].strip()
|
||||
if text and not text.startswith("-"):
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
def _normalise(name: str) -> str:
|
||||
"""PEP 503 normalisation, so PyJWT/pyjwt and nest_asyncio/nest-asyncio match."""
|
||||
head = name
|
||||
for sep in ("===", "==", ">=", "<=", "~=", "!=", ">", "<", "[", ";", " "):
|
||||
idx = head.find(sep)
|
||||
if idx > 0:
|
||||
head = head[:idx]
|
||||
return head.strip().lower().replace("_", "-").replace(".", "-")
|
||||
|
||||
|
||||
def test_studio_extra_exists():
|
||||
extras = _load_pyproject()["project"]["optional-dependencies"]
|
||||
assert "studio" in extras, (
|
||||
"pyproject.toml has no `studio` extra. The wheel ships studio/ and "
|
||||
"studio.backend*, so their dependencies need a pip-installable home."
|
||||
)
|
||||
|
||||
|
||||
def test_studio_extra_matches_requirements_file():
|
||||
extras = _load_pyproject()["project"]["optional-dependencies"]
|
||||
extra = sorted(_normalise(entry) for entry in extras["studio"])
|
||||
required = sorted(_normalise(entry) for entry in _requirement_lines(STUDIO_TXT))
|
||||
|
||||
missing = sorted(set(required) - set(extra))
|
||||
surplus = sorted(set(extra) - set(required))
|
||||
assert not missing, (
|
||||
f"studio.txt lists {missing} but the `studio` extra does not. "
|
||||
'`pip install "unsloth[studio]"` would build a venv the Studio server '
|
||||
"cannot boot in. Add them to [project.optional-dependencies] studio."
|
||||
)
|
||||
assert not surplus, (
|
||||
f"The `studio` extra lists {surplus} but studio.txt does not. "
|
||||
"Remove them, or add them to studio.txt if install.sh needs them too."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("package", CORE_RUNTIME_PACKAGES)
|
||||
def test_cli_runtime_packages_are_core_dependencies(package):
|
||||
core = [_normalise(entry) for entry in _load_pyproject()["project"]["dependencies"]]
|
||||
assert _normalise(package) in core, (
|
||||
f"{package} is imported at module scope by the studio.backend chain "
|
||||
f"`unsloth train` / `unsloth export` walk, so a plain `pip install "
|
||||
f"unsloth` must provide it or they die with ModuleNotFoundError."
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue