diff --git a/pyproject.toml b/pyproject.toml index 134ceef3a8..fdbee69c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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')", diff --git a/studio/backend/loggers/handlers.py b/studio/backend/loggers/handlers.py index 716c4f40d2..7f085439d4 100644 --- a/studio/backend/loggers/handlers.py +++ b/studio/backend/loggers/handlers.py @@ -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 diff --git a/tests/studio/install/test_studio_extra_matches_requirements.py b/tests/studio/install/test_studio_extra_matches_requirements.py new file mode 100644 index 0000000000..a74d52ff40 --- /dev/null +++ b/tests/studio/install/test_studio_extra_matches_requirements.py @@ -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." + )