Merge remote-tracking branch 'origin/main' into r7188
This commit is contained in:
commit
559d17dac1
141 changed files with 13472 additions and 669 deletions
|
|
@ -17,7 +17,8 @@ if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then
|
|||
fi
|
||||
|
||||
mkdir -p "$artifact_dir"
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf "$studio_home/auth"
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \
|
||||
>"$server_log" 2>&1 &
|
||||
studio_pid=$!
|
||||
|
|
|
|||
10
.github/workflows/local-agent-guides-ci.yml
vendored
10
.github/workflows/local-agent-guides-ci.yml
vendored
|
|
@ -167,7 +167,9 @@ jobs:
|
|||
# ── boot the server under test (factored helper) ──────────────────
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe, not reset-password: since #7573 the reset rotates in place and
|
||||
# prints the new passphrase, which would land unmasked in the job log.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
|
|
@ -371,7 +373,7 @@ jobs:
|
|||
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
|
|
@ -554,7 +556,7 @@ jobs:
|
|||
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
|
|
@ -718,7 +720,7 @@ jobs:
|
|||
|
||||
- name: Serve unsloth run --disable-tools (gemma-3-270m)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
|
|
|
|||
3
.github/workflows/release-desktop.yml
vendored
3
.github/workflows/release-desktop.yml
vendored
|
|
@ -766,6 +766,7 @@ jobs:
|
|||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
||||
PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
|
||||
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
|
||||
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
|
|
@ -911,6 +912,8 @@ jobs:
|
|||
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
|
||||
metadata = {
|
||||
'version': os.environ['APP_VERSION'],
|
||||
# App version is SemVer; CHANGELOG.md is keyed by the backend release.
|
||||
'pypi_version': os.environ['PYPI_VERSION'],
|
||||
'notes': notes,
|
||||
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
'platforms': {
|
||||
|
|
|
|||
156
.github/workflows/startup-profile-ci.yml
vendored
Normal file
156
.github/workflows/startup-profile-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Measures where Studio's startup time goes, on each platform.
|
||||
#
|
||||
# Nothing recorded a number before: main.py logs "lifespan startup completed in X ms"
|
||||
# and studio_test_kit polls /healthz, but both throw the elapsed time away. A first
|
||||
# local run (Linux, warm cache, 18-core server) put `import main` at 5.7-6.6s BEFORE
|
||||
# the server can bind, dominated by eager module-level imports pulled in by routes:
|
||||
# torch ~1.9s self, unsloth_zoo ~0.8s, routes ~0.6s, transformers ~0.5s.
|
||||
#
|
||||
# Not a gate yet: --max-healthz-seconds exists, but a budget should come from
|
||||
# observed numbers rather than a guess.
|
||||
|
||||
name: Startup profile
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
# The measured import graph is the whole backend tree: main.py imports auth,
|
||||
# core, hub, loggers, models, picker, routes and utils at module scope.
|
||||
- 'studio/backend/**'
|
||||
- '!studio/backend/tests/**'
|
||||
# The launch phase spawns `unsloth studio --api-only`, so the CLI counts too.
|
||||
- 'unsloth_cli/**'
|
||||
- 'studio/src-tauri/src/preflight**'
|
||||
# The profiler hardcodes the desktop argv that process.rs::backend_args builds,
|
||||
# so a change there must schedule a run or the two silently diverge.
|
||||
- 'studio/src-tauri/src/process.rs'
|
||||
- 'scripts/profile_startup.py'
|
||||
- '.github/workflows/startup-profile-ci.yml'
|
||||
# The job profiles whatever `install.sh --local` built: the installers pick the
|
||||
# venv's Python and the dependency specs, and pyproject's include list is what
|
||||
# makes --local overlay studio.backend*.
|
||||
- 'install.sh'
|
||||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
# --local also runs the checkout's setup scripts (install.sh picks
|
||||
# $_REPO_ROOT/studio/setup.sh, the editable install resolves setup.ps1 to the
|
||||
# repo), and both call install_python_stack.py, which picks the dependencies.
|
||||
- 'studio/setup.sh'
|
||||
- 'studio/setup.ps1'
|
||||
- 'studio/install_python_stack.py'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repeats:
|
||||
description: 'launch repeats per OS (median reported)'
|
||||
type: string
|
||||
default: '3'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
profile:
|
||||
name: startup ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-14, windows-latest]
|
||||
|
||||
env:
|
||||
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
|
||||
# A wildcard bind calls ifconfig.me on the startup path; loopback times our code.
|
||||
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Studio
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p logs
|
||||
# --local is load-bearing: it overlays the checkout, so the profiled server
|
||||
# is this diff. Without it install.sh resolves unsloth from PyPI.
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
pwsh -NoProfile -File ./install.ps1 --local 2>&1 | tee logs/install.log
|
||||
else
|
||||
bash install.sh --local 2>&1 | tee logs/install.log
|
||||
fi
|
||||
|
||||
- name: Profile startup
|
||||
shell: bash
|
||||
run: |
|
||||
BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth"
|
||||
[ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/unsloth.exe"
|
||||
[ -x "$BIN" ] || BIN=""
|
||||
# Profile imports with the INSTALLED interpreter: that venv is what launches.
|
||||
PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/python"
|
||||
[ -x "$PY" ] || PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/python.exe"
|
||||
[ -x "$PY" ] || PY="$(command -v python3 || command -v python)"
|
||||
python3 scripts/profile_startup.py \
|
||||
--python "$PY" \
|
||||
${BIN:+--bin "$BIN"} \
|
||||
--repeats "${{ inputs.repeats || '3' }}" \
|
||||
--json "startup-${{ matrix.os }}.json" 2>&1 | tee logs/profile.log
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
f="startup-${{ matrix.os }}.json"
|
||||
[ -f "$f" ] || { echo "no profile produced"; exit 0; }
|
||||
python3 - "$f" >> "$GITHUB_STEP_SUMMARY" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
print(f"### {d['platform']} / {d['machine']} (py {d['python']}, {d['cpu_count']} cpu)\n")
|
||||
imp = d.get("imports", {})
|
||||
# Gate on ok: a failed `import main` still leaves rows, so a total can lie.
|
||||
if imp.get("ok"):
|
||||
print(f"**`import main`: {imp['total_seconds']}s**\n")
|
||||
print("| package | self ms |")
|
||||
print("|---|---:|")
|
||||
for k, v in list(imp.get("self_by_package_ms", {}).items())[:8]:
|
||||
print(f"| {k} | {v} |")
|
||||
print()
|
||||
else:
|
||||
print("**`import main` failed - no valid import profile**\n")
|
||||
print("```\n" + (imp.get("error") or "")[-1500:] + "\n```\n")
|
||||
lau = d.get("launch") or {}
|
||||
runs = len(lau.get("runs") or [])
|
||||
failed = lau.get("failed_runs") or 0
|
||||
if lau.get("healthz_median_seconds") is not None:
|
||||
# The aggregates cover only the runs that reached healthz, so flag the
|
||||
# failures: bare numbers would read as a normal fast startup.
|
||||
note = f" _({runs - failed} of {runs} launches; {failed} never became healthy)_" if failed else ""
|
||||
print(f"**time to a healthy port: {lau['healthz_median_seconds']}s median, "
|
||||
f"{lau['healthz_max_seconds']}s max**{note}\n")
|
||||
elif lau.get("skipped"):
|
||||
print(f"_launch phase skipped: {lau['skipped']}_\n")
|
||||
elif runs:
|
||||
print(f"**no launch measurement: all {runs} launches failed to become healthy**\n")
|
||||
PY
|
||||
|
||||
- name: Upload profile
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: startup-profile-${{ matrix.os }}
|
||||
path: |
|
||||
startup-*.json
|
||||
logs/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
3
.github/workflows/studio-api-smoke.yml
vendored
3
.github/workflows/studio-api-smoke.yml
vendored
|
|
@ -113,7 +113,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
10
.github/workflows/studio-backend-ci.yml
vendored
10
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -223,6 +223,16 @@ jobs:
|
|||
tests/studio/test_is_mlx_dispatch_gate.py \
|
||||
tests/studio/test_xpu_spoof_pipeline.py
|
||||
|
||||
- name: CLI tests (unsloth_cli)
|
||||
# unsloth_cli/tests had no CI at all: `unsloth_cli/**` was only a paths
|
||||
# trigger and a ruff target, so 673 tests covering the studio launcher,
|
||||
# the pre-exposure gate and the auth secret writers ran nowhere, and
|
||||
# four of them had been failing on main unnoticed.
|
||||
# Own step, not folded into the tests/ discovery above: pyproject's
|
||||
# testpaths is tests/, and this suite needs no PYTHONPATH or CUDA spoof
|
||||
# (it self-bootstraps sys.path and imports neither unsloth nor torch).
|
||||
run: python -m pytest unsloth_cli/tests -q --tb=short
|
||||
|
||||
- name: Shell installer tests
|
||||
# Auto-discovered rather than allowlisted. The old hardcoded list had
|
||||
# silently fallen seven files behind tests/run_all.sh, including
|
||||
|
|
|
|||
7
.github/workflows/studio-inference-smoke.yml
vendored
7
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -127,7 +127,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -400,7 +401,7 @@ jobs:
|
|||
# tool_policy=None so each request's `enable_tools` field is
|
||||
# honoured.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -978,7 +979,7 @@ jobs:
|
|||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
3
.github/workflows/studio-mac-api-smoke.yml
vendored
3
.github/workflows/studio-mac-api-smoke.yml
vendored
|
|
@ -101,7 +101,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -126,7 +126,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -386,7 +387,7 @@ jobs:
|
|||
# tool_policy=None so each request's `enable_tools` field is
|
||||
# honoured.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -831,7 +832,7 @@ jobs:
|
|||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
11
.github/workflows/studio-mac-ui-smoke.yml
vendored
11
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -146,7 +146,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -190,7 +191,7 @@ jobs:
|
|||
# runner's kernel briefly runs out of socket buffers, and (3) a
|
||||
# goto 'interrupted by another navigation' when the SPA auth
|
||||
# guard redirects mid-navigation. The retry FULLY resets Unsloth
|
||||
# (kill, reset-password, reboot, wait /api/health, re-export
|
||||
# (kill, wipe auth, reboot, wait /api/health, re-export
|
||||
# bootstrap pw) before re-running the script. A real test failure
|
||||
# (assertion / timeout) does NOT match any pattern so it bypasses
|
||||
# retry and surfaces immediately.
|
||||
|
|
@ -213,7 +214,7 @@ jobs:
|
|||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> "logs/studio_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_PID=$!
|
||||
|
|
@ -251,7 +252,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
|
|
@ -308,7 +309,7 @@ jobs:
|
|||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> "logs/studio_extra_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_EXTRA_PID=$!
|
||||
|
|
|
|||
9
.github/workflows/studio-ui-smoke.yml
vendored
9
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -115,7 +115,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -193,7 +194,7 @@ jobs:
|
|||
# warm install we already did) so this adds little wall time.
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18894)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
|
|
@ -253,7 +254,7 @@ jobs:
|
|||
# (RAG embedder + llama.cpp probe) stay hidden from the picker.
|
||||
- name: Reset auth + boot Unsloth for model-config tests (port 18898)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \
|
||||
> logs/studio_modelcfg.log 2>&1 &
|
||||
|
|
@ -299,7 +300,7 @@ jobs:
|
|||
# earlier UI tests. No GGUF -- the bug surface is the composer.
|
||||
- name: Reset auth + boot Unsloth for IME / i18n tests (port 18896)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
|
||||
> logs/studio_ime.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -179,7 +179,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -229,7 +229,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -573,7 +574,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only, default tool policy)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -1074,7 +1075,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -1546,7 +1547,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -1888,8 +1889,11 @@ jobs:
|
|||
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
|
||||
$script:StudioVtOk = $false
|
||||
$script:UnslothVerbose = $false
|
||||
# Get-HostMachineArch is reached only on the absent path, where
|
||||
# Test-VCRedistInstalled consults it before trusting the System32 DLL, so
|
||||
# part A passes without it and only the clean-box part fails.
|
||||
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
|
||||
'Invoke-SetupCommand', 'Refresh-Environment',
|
||||
'Invoke-SetupCommand', 'Refresh-Environment', 'Get-HostMachineArch',
|
||||
'Test-VCRedistInstalled', 'Ensure-VCRedist')) {
|
||||
$src = Get-FunctionSource -Path $setup -Name $fn
|
||||
if (-not $src) { throw "Function '$fn' not found in setup.ps1" }
|
||||
|
|
|
|||
|
|
@ -297,7 +297,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -352,7 +353,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -208,6 +208,9 @@ tmp/
|
|||
**/node_modules/
|
||||
auth.db
|
||||
|
||||
# Packaging snapshot of the root CHANGELOG.md (written by build.sh)
|
||||
studio/CHANGELOG.md
|
||||
|
||||
# Tauri local build/generated output
|
||||
studio/src-tauri/target/
|
||||
studio/src-tauri/gen/
|
||||
|
|
|
|||
88
CHANGELOG.md
Normal file
88
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Changelog
|
||||
|
||||
Release notes for Unsloth and Unsloth Studio.
|
||||
|
||||
Unsloth Studio reads this file to show release notes inside the "New Unsloth
|
||||
version" update popup. Edit it here and the popup picks the change up on the
|
||||
next update check, with no release or rebuild required.
|
||||
|
||||
## Format
|
||||
|
||||
Every release is a level-2 heading whose first token is the version, optionally
|
||||
followed by a date:
|
||||
|
||||
```md
|
||||
## 2026.7.6 - 2026-07-22
|
||||
```
|
||||
|
||||
`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a
|
||||
heading, up to the next level-2 heading, is that release's notes and renders as
|
||||
Markdown in the popup.
|
||||
|
||||
Notes are matched to one exact version. When Studio offers an update to
|
||||
`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section
|
||||
is missing, the popup links out to the online changelog rather than showing
|
||||
notes from an unrelated release, so a new version needs its own section here
|
||||
before its notes can appear.
|
||||
|
||||
Keep the newest release at the top. Lead each bullet with the change itself:
|
||||
the collapsed popup highlights the first sentence and dims the rest.
|
||||
`## Unreleased` is ignored by the popup, so it is safe to stage notes there and
|
||||
rename the heading at release time.
|
||||
|
||||
<!-- Add new releases directly below this line. -->
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 2026.7.5
|
||||
|
||||
### What's Changed
|
||||
|
||||
- AMD support is here. Train, run RL, chat with and deploy 500+ models on
|
||||
Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux,
|
||||
up to 2x faster with 70% less VRAM and no accuracy loss.
|
||||
- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and
|
||||
training alongside the NVIDIA, AMD and Apple paths.
|
||||
- Local speech to text dictation runs fully offline, with slim Whisper bundles
|
||||
and a picker for custom models.
|
||||
- DoRA training is available in Studio, selectable next to LoRA and full
|
||||
fine-tuning in the training tab.
|
||||
- The update popup previews release notes inline, pulled from this file and
|
||||
matched to the exact version being offered.
|
||||
|
||||
### AMD, 23 July update
|
||||
|
||||
Our AMD collaboration, custom Triton kernels and math algorithms bring local
|
||||
training and inference to AMD hardware. The 23 July update builds on the
|
||||
[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta):
|
||||
|
||||
- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to
|
||||
detect GPUs on Strix Halo and other AMD cards.
|
||||
- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed
|
||||
automatically instead of stopping the install.
|
||||
- Unified memory safetensors loading is 2x faster, with much faster gradient
|
||||
checkpointing on unified memory devices.
|
||||
- Voice dictation through whisper.cpp has preliminary support.
|
||||
- Rollback environments left by installs no longer eat 5GB of disk. They are
|
||||
cleaned up automatically.
|
||||
|
||||
Optimized ROCm builds cover GGUF and safetensors inference, and ROCm
|
||||
compatibility is improved for MI300X and MI325X. Full guide:
|
||||
[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd).
|
||||
|
||||
### Running larger models
|
||||
|
||||
- Automatic GPU placement, or pick exactly which GPUs and layers to use.
|
||||
- Move MoE expert layers into system memory so larger models fit.
|
||||
- Split a model across several GPUs, or use tensor parallelism.
|
||||
- Hardware settings are saved per model and quant.
|
||||
|
||||
### Also in this release
|
||||
|
||||
- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare.
|
||||
- Web search reads PDF papers and manuals, and parallel tool calls, reasoning
|
||||
output and tool retries are more reliable.
|
||||
- The model download location is configurable, so weights can live on a second
|
||||
drive instead of the default cache.
|
||||
- Stalled Hugging Face XET downloads retry over standard HTTP, and existing
|
||||
GGUF files are reused instead of downloaded again.
|
||||
2
MANIFEST.in
Normal file
2
MANIFEST.in
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
include _changelog_build.py
|
||||
include CHANGELOG.md
|
||||
36
_changelog_build.py
Normal file
36
_changelog_build.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Snapshot CHANGELOG.md into the studio package at build time.
|
||||
|
||||
CHANGELOG.md at the repo root stays the one file to edit. Copying it here,
|
||||
rather than in build.sh, means every packaging path ships it, so release notes
|
||||
still render when the popup cannot reach GitHub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools.command.build_py import build_py as _build_py
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SOURCE = ROOT / "CHANGELOG.md"
|
||||
SNAPSHOT = ROOT / "studio" / "CHANGELOG.md"
|
||||
|
||||
|
||||
class build_py(_build_py):
|
||||
def run(self) -> None:
|
||||
# Beside the sources only if writable (PEP 517 may build an immutable
|
||||
# checkout); into the staging directory always.
|
||||
if SOURCE.is_file():
|
||||
try:
|
||||
shutil.copyfile(SOURCE, SNAPSHOT)
|
||||
except OSError:
|
||||
pass
|
||||
super().run()
|
||||
if not SOURCE.is_file():
|
||||
return
|
||||
staged = Path(self.build_lib) / "studio" / "CHANGELOG.md"
|
||||
staged.parent.mkdir(parents = True, exist_ok = True)
|
||||
shutil.copyfile(SOURCE, staged)
|
||||
6
build.sh
6
build.sh
|
|
@ -103,9 +103,13 @@ else
|
|||
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
|
||||
fi
|
||||
|
||||
# 4. Build wheel/sdist
|
||||
# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio
|
||||
# package so release notes render offline.
|
||||
python -m build
|
||||
|
||||
# Drop the snapshot so a source checkout never serves a stale copy.
|
||||
rm -f studio/CHANGELOG.md
|
||||
|
||||
if [ "${1:-}" = "publish" ]; then
|
||||
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
|
||||
fi
|
||||
|
|
|
|||
146
install.ps1
146
install.ps1
|
|
@ -57,6 +57,26 @@ function Install-UnslothStudio {
|
|||
}
|
||||
}
|
||||
|
||||
# Machine arch; Get-TauriDiagArch above reports the process. An emulated x64 shell on
|
||||
# ARM64 reports AMD64, but PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
|
||||
function Get-HostMachineArch {
|
||||
$osArch = ""
|
||||
try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
|
||||
$signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
|
||||
foreach ($s in $signals) {
|
||||
if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
|
||||
}
|
||||
foreach ($s in $signals) {
|
||||
if ([string]::IsNullOrWhiteSpace($s)) { continue }
|
||||
switch ($s.ToLowerInvariant()) {
|
||||
"amd64" { return "x86_64" }
|
||||
"x64" { return "x86_64" }
|
||||
"x86" { return "x86" }
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function Get-TauriTorchIndexFamily {
|
||||
param([string]$TorchIndexUrl)
|
||||
if ($SkipTorch) { return "none" }
|
||||
|
|
@ -1124,10 +1144,27 @@ exit 0
|
|||
return $false
|
||||
}
|
||||
|
||||
# The interpreter's own arch, asked of it: win-amd64|win-arm64|win32|"".
|
||||
function Get-PythonPlatformTag {
|
||||
param([string]$Exe)
|
||||
try {
|
||||
return (& $Exe -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
|
||||
} catch { return "" }
|
||||
}
|
||||
|
||||
# Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
|
||||
# The resolved Path is passed to `uv venv --python` to prevent uv from
|
||||
# re-resolving the version string back to a conda interpreter.
|
||||
function Find-CompatiblePython {
|
||||
# -X64Only: best installed x64 interpreter or $null, never ARM64. Last resort for
|
||||
# Install-X64Python, where x64 of a lower-priority minor beats ARM64.
|
||||
param([switch]$X64Only)
|
||||
# Windows on ARM: prefer x64. pyarrow (via datasets) and hf-transfer ship no
|
||||
# win_arm64 wheel, so a native ARM64 Python source-builds both and dies on CMake /
|
||||
# Rust minutes in; x64 runs fine emulated. ARM64 is still returned when it is all
|
||||
# there is, and the caller then bootstraps x64 or warns.
|
||||
$preferX64 = $X64Only -or ((Get-HostMachineArch) -eq "arm64")
|
||||
$candidates = @()
|
||||
# Try the Python Launcher first (most reliable on Windows)
|
||||
# py.exe resolves to the standard CPython install, not conda.
|
||||
# Prefer the requested $PythonVersion, then newest-first fallback.
|
||||
|
|
@ -1145,7 +1182,8 @@ exit 0
|
|||
# Resolve the actual executable path and verify it is not conda-based
|
||||
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
|
||||
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) {
|
||||
return @{ Version = $ver; Path = $resolvedExe }
|
||||
if (-not $preferX64) { return @{ Version = $ver; Path = $resolvedExe; Arch = "" } }
|
||||
$candidates += @{ Version = $ver; Path = $resolvedExe }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -1166,11 +1204,53 @@ exit 0
|
|||
try {
|
||||
$out = & $cmd.Source --version 2>&1 | Out-String
|
||||
if ($out -match "Python (3\.1[1-3])\.\d+") {
|
||||
return @{ Version = $Matches[1]; Path = $cmd.Source }
|
||||
if (-not $preferX64) { return @{ Version = $Matches[1]; Path = $cmd.Source; Arch = "" } }
|
||||
$candidates += @{ Version = $Matches[1]; Path = $cmd.Source }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
# `py -3.12` runs the launcher's preferred build, normally the native ARM64 one, so
|
||||
# a same-minor x64 install that is neither preferred nor on PATH never becomes a
|
||||
# candidate. `-3.12-64` cannot disambiguate (deprecated, it only means "not
|
||||
# 32-bit"), so enumerate every registration with -0p and probe each path.
|
||||
if ($preferX64) {
|
||||
foreach ($pyLauncher in @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue)) {
|
||||
if ($pyLauncher.Source -match $script:CondaSkipPattern) { continue }
|
||||
$listed = @()
|
||||
try { $listed = @(& $pyLauncher.Source "-0p" 2>$null) } catch {}
|
||||
foreach ($line in $listed) {
|
||||
# " -V:3.12 * C:\...\python.exe": tag, optional default marker, path.
|
||||
$m = [regex]::Match([string]$line, '(?i)^\s*-\S+\s+\*?\s*"?(?<p>\S.*?\.exe)"?\s*$')
|
||||
if (-not $m.Success) { continue }
|
||||
$exe = $m.Groups['p'].Value.Trim()
|
||||
if ($candidates | Where-Object { $_.Path -eq $exe }) { continue }
|
||||
if (-not (Test-Path -LiteralPath $exe)) { continue }
|
||||
if (Test-IsCondaPython $exe) { continue }
|
||||
try {
|
||||
$out = & $exe --version 2>&1 | Out-String
|
||||
if ($out -match "Python (3\.1[1-3])\.\d+") {
|
||||
$candidates += @{ Version = $Matches[1]; Path = $exe }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Prefer x64, but only within one minor: $minors is the caller's version preference,
|
||||
# so ranking on arch alone would answer UNSLOTH_PYTHON=3.12 with an x64 3.13 and
|
||||
# never bootstrap x64 3.12. Probing costs a subprocess, so non-ARM returned above.
|
||||
foreach ($c in $candidates) {
|
||||
$tag = Get-PythonPlatformTag $c.Path
|
||||
$c.Arch = if ($tag -eq "win-amd64") { "x86_64" } elseif ($tag -eq "win-arm64") { "arm64" } else { "unknown" }
|
||||
}
|
||||
foreach ($minor in $minors) {
|
||||
$sameMinor = @($candidates | Where-Object { $_.Version -eq $minor })
|
||||
if ($sameMinor.Count -eq 0) { continue }
|
||||
$x64 = $sameMinor | Where-Object { $_.Arch -eq "x86_64" } | Select-Object -First 1
|
||||
if ($x64) { return $x64 }
|
||||
if (-not $X64Only) { return $sameMinor[0] }
|
||||
}
|
||||
if (-not $X64Only -and $candidates.Count -gt 0) { return $candidates[0] }
|
||||
return $null
|
||||
}
|
||||
|
||||
|
|
@ -1181,8 +1261,11 @@ exit 0
|
|||
# (no UAC), putting python.exe + the py launcher on PATH. Mirrors the uv ->
|
||||
# astral.sh fallback below. Returns @{ Version; Path } or $null.
|
||||
function Install-PythonFromPythonOrg {
|
||||
# $Arch overrides the host arch, to pull x64 onto an ARM64 box.
|
||||
param([string]$Arch = "")
|
||||
# python.org ships one installer per architecture.
|
||||
$archSuffix = switch (Get-TauriDiagArch) {
|
||||
$targetArch = if ($Arch) { $Arch } else { Get-TauriDiagArch }
|
||||
$archSuffix = switch ($targetArch) {
|
||||
"x86_64" { "-amd64" }
|
||||
"arm64" { "-arm64" }
|
||||
"x86" { "" }
|
||||
|
|
@ -1247,6 +1330,28 @@ exit 0
|
|||
return (Find-CompatiblePython)
|
||||
}
|
||||
|
||||
# ── Windows on ARM: get an x64 CPython ──
|
||||
# --architecture x64 forces winget off the ARM64 build; python.org takes the same override.
|
||||
function Install-X64Python {
|
||||
if ($script:WingetAvailable) {
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
winget install -e --id "Python.Python.$PythonVersion" --source winget --architecture x64 --accept-package-agreements --accept-source-agreements
|
||||
} catch { }
|
||||
$ErrorActionPreference = $prevEAP
|
||||
Refresh-SessionPath
|
||||
$found = Find-CompatiblePython
|
||||
if ($found -and $found.Arch -eq "x86_64") { return $found }
|
||||
substep "winget could not provide an x64 Python -- trying python.org..." "Yellow"
|
||||
}
|
||||
$found = Install-PythonFromPythonOrg -Arch "x86_64"
|
||||
if ($found -and $found.Arch -eq "x86_64") { return $found }
|
||||
# Nothing installable (offline / no winget): an x64 build of another supported minor
|
||||
# still runs the wheels ARM64 cannot, so take it over the native interpreter.
|
||||
return (Find-CompatiblePython -X64Only)
|
||||
}
|
||||
|
||||
# ── Install Python if no compatible version (3.11-3.13) found ──
|
||||
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
|
||||
Write-TauriLog "STEP" "Installing Python"
|
||||
|
|
@ -1318,6 +1423,26 @@ exit 0
|
|||
return (Exit-InstallFailure "Python installation failed")
|
||||
}
|
||||
}
|
||||
# ── Windows on ARM: swap a native ARM64 interpreter for x64 ──
|
||||
# pyarrow and hf-transfer publish no win_arm64 wheel, so an ARM64 Python source-builds
|
||||
# both and fails deep into the run. Warn up front if x64 is unobtainable.
|
||||
if ($DetectedPython -and (Get-HostMachineArch) -eq "arm64" -and $DetectedPython.Arch -ne "x86_64") {
|
||||
substep "windows on arm: only a native ARM64 Python $($DetectedPython.Version) was found." "Yellow"
|
||||
substep "pyarrow and hf-transfer publish no win_arm64 wheels, so installing x64 Python..." "Yellow"
|
||||
$X64Python = Install-X64Python
|
||||
if ($X64Python) {
|
||||
$DetectedPython = $X64Python
|
||||
step "python" "using x64 Python $($DetectedPython.Version) under emulation"
|
||||
} else {
|
||||
Write-Host "[WARN] Could not install an x64 Python on this ARM64 machine." -ForegroundColor Yellow
|
||||
Write-Host " Continuing with ARM64 Python $($DetectedPython.Version), but the install is likely to fail:" -ForegroundColor Yellow
|
||||
Write-Host " pyarrow (via datasets) and hf-transfer ship no win_arm64 wheels and will be" -ForegroundColor Yellow
|
||||
Write-Host " built from source, which needs CMake plus the MSVC and Rust toolchains." -ForegroundColor Yellow
|
||||
Write-Host " Fix: install x64 Python from https://www.python.org/downloads/windows/" -ForegroundColor Yellow
|
||||
Write-Host " (choose 'Windows installer (64-bit)', not ARM64), then re-run this installer." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
$DiagPythonVersion = $PythonVersion
|
||||
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
|
||||
$InitialGpuBranch = "unknown"
|
||||
|
|
@ -2438,6 +2563,13 @@ exit 0
|
|||
}
|
||||
} else {
|
||||
Write-TauriLog "STEP" "Installing PyTorch"
|
||||
# Windows on ARM lacks only torchaudio (whl/cpu win_arm64: torch 42,
|
||||
# torchvision 60, torchaudio 0), so drop that pin instead of aborting. Ask the
|
||||
# interpreter, not PROCESSOR_ARCHITECTURE; reached when no x64 Python exists.
|
||||
$VenvPlatform = ""
|
||||
try {
|
||||
$VenvPlatform = (& $VenvPython -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
|
||||
} catch { $VenvPlatform = "" }
|
||||
substep "installing PyTorch ($(Remove-IndexUrlCredentials $TorchIndexUrl))..."
|
||||
# Bound the companions to the capped torch on EVERY index, cu<digits>
|
||||
# families included: torchaudio 2.11 dropped its exact torch pin from
|
||||
|
|
@ -2445,7 +2577,13 @@ exit 0
|
|||
# resolve a mismatched 2.11.0 build. Mirrors install.sh.
|
||||
$_pinVisionSpec = "torchvision>=0.19,<0.26.0"
|
||||
$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" $_pinVisionSpec $_pinAudioSpec --default-index $TorchIndexUrl }
|
||||
$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec)
|
||||
if ($VenvPlatform -eq "win-arm64") {
|
||||
substep "windows on arm: skipping torchaudio (upstream publishes no"
|
||||
substep "win_arm64 wheel); torch and torchvision install normally."
|
||||
$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec)
|
||||
}
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython @_torchSpecs --default-index $TorchIndexUrl }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
|
|
|
|||
16
install.sh
16
install.sh
|
|
@ -19,6 +19,17 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
set -e
|
||||
# ── Why the installer lives in a function ──
|
||||
# Under `curl ... | sh`, sh is the pipe READER. This file is ~150KB, so a top-level
|
||||
# `exit` left most of it unread, the write end failed, and curl tacked
|
||||
# "(56) Failure writing output to destination" onto our own error message. Wrapping
|
||||
# the body forces sh to parse to the closing brace first, so the pipe always drains
|
||||
# (install.ps1 has always had this shape).
|
||||
#
|
||||
# Body is deliberately NOT reindented: reflowing 4000+ lines would bury the change,
|
||||
# and `exit` still exits the shell from inside a function. Do not add
|
||||
# `exec < /dev/null`: for a piped shell that closes the script's own source.
|
||||
_unsloth_main() {
|
||||
|
||||
# ── Output style (aligned with studio/setup.sh) ──
|
||||
RULE=""
|
||||
|
|
@ -4447,3 +4458,8 @@ else
|
|||
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# Every byte above is parsed before this line runs, which is the point.
|
||||
_unsloth_main "$@"
|
||||
|
|
|
|||
|
|
@ -47,9 +47,14 @@ version = {attr = "unsloth.models._utils.__version__"}
|
|||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.cmdclass]
|
||||
# Snapshots CHANGELOG.md into studio/ so every build path ships it.
|
||||
build_py = "_changelog_build.build_py"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
|
||||
studio = [
|
||||
"CHANGELOG.md",
|
||||
"*.sh",
|
||||
"*.ps1",
|
||||
"*.bat",
|
||||
|
|
@ -128,14 +133,19 @@ huggingfacenotorch = [
|
|||
]
|
||||
# torchcodec backend for Gemma audio / datasets>=4 (#7225).
|
||||
# Pick the audio-torch* pin matching your torch minor (see TORCH_TORCHCODEC).
|
||||
# torchcodec publishes no sdist and only manylinux_2_28_x86_64, macosx_*_arm64
|
||||
# and win_amd64 wheels, so Linux aarch64, Windows ARM64 and Intel Mac have
|
||||
# nothing to resolve and pip fails the whole install rather than skipping audio.
|
||||
# Gate on the platforms that have a wheel, matching
|
||||
# PLATFORM_LACKS_TORCHCODEC_WHEEL in studio/install_python_stack.py.
|
||||
audio-torch210 = [
|
||||
"torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10'",
|
||||
"torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
|
||||
]
|
||||
audio-torch290 = [
|
||||
"torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10'",
|
||||
"torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
|
||||
]
|
||||
audio-torch280 = [
|
||||
"torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9'",
|
||||
"torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
|
||||
]
|
||||
huggingface = [
|
||||
"unsloth[huggingfacenotorch]",
|
||||
|
|
|
|||
377
scripts/profile_startup.py
Normal file
377
scripts/profile_startup.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Measure where Unsloth Studio's startup time goes, per platform.
|
||||
|
||||
Nothing measured this before: the backend logs "lifespan startup completed in X ms"
|
||||
but no test or CI job asserted a budget, and studio_test_kit discards the elapsed
|
||||
time of its /healthz poll. A first local run (Linux, warm cache, fast server CPU)
|
||||
found `import main` alone costs 6.6s before the server can bind, dominated by eager
|
||||
module-level imports pulled in by the `routes` package:
|
||||
|
||||
torch 1930 ms self
|
||||
unsloth_zoo 914 ms self
|
||||
routes 779 ms self
|
||||
transformers 524 ms self
|
||||
|
||||
Phases measured:
|
||||
import `python -X importtime -c "import main"`, top cumulative + per-package self
|
||||
spawn process start -> first byte on stdout
|
||||
healthz process start -> /api/health (or /healthz) answers 200
|
||||
lifespan the backend's own "lifespan startup completed in X ms" log line
|
||||
|
||||
Usage:
|
||||
python scripts/profile_startup.py --repeats 3 --json out.json
|
||||
python scripts/profile_startup.py --import-only # no server, no port needed
|
||||
|
||||
Exit code is 0 unless --max-healthz-seconds is given and exceeded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND = REPO_ROOT / "studio" / "backend"
|
||||
|
||||
_IMPORTTIME_RE = re.compile(r"import time:\s+(\d+)\s+\|\s+(\d+)\s+\|(\s*)(\S.*)")
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
def profile_imports(python: str, top: int = 15) -> dict:
|
||||
"""Cumulative and self import cost for the backend's module graph.
|
||||
|
||||
Run in a subprocess with -X importtime: the numbers are only meaningful for a
|
||||
cold interpreter, and importing in-process would measure a warm sys.modules.
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
[python, "-X", "importtime", "-c", "import sys; sys.path.insert(0, '.'); import main"],
|
||||
cwd = BACKEND,
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 900,
|
||||
)
|
||||
rows = []
|
||||
for line in proc.stderr.splitlines():
|
||||
m = _IMPORTTIME_RE.match(line)
|
||||
if m:
|
||||
rows.append((int(m.group(1)), int(m.group(2)), m.group(4).strip()))
|
||||
if not rows:
|
||||
return {"ok": False, "error": (proc.stderr or proc.stdout)[-2000:]}
|
||||
if proc.returncode != 0:
|
||||
# Rows survive up to the failure, so any total from a partial graph is wrong.
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (proc.stderr or proc.stdout)[-2000:],
|
||||
"partial_rows": len(rows),
|
||||
}
|
||||
|
||||
by_cum = sorted(rows, key = lambda r: -r[1])
|
||||
# Total comes from the `main` row, not by_cum[0]: -X importtime also prints the
|
||||
# interpreter's own startup graph (`site`), which can outrank a trivial main.
|
||||
main_row = next((r for r in reversed(rows) if r[2] == "main"), None)
|
||||
if main_row is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "no `import main` row in -X importtime output\n"
|
||||
+ (proc.stderr or proc.stdout)[-2000:],
|
||||
}
|
||||
self_by_pkg: dict[str, int] = {}
|
||||
for self_us, _cum, name in rows:
|
||||
pkg = name.split(".")[0]
|
||||
self_by_pkg[pkg] = self_by_pkg.get(pkg, 0) + self_us
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"total_seconds": round(main_row[1] / 1e6, 3),
|
||||
"top_cumulative": [
|
||||
{"module": n, "seconds": round(c / 1e6, 3)} for _s, c, n in by_cum[:top]
|
||||
],
|
||||
"self_by_package_ms": {
|
||||
k: round(v / 1000) for k, v in sorted(self_by_pkg.items(), key = lambda x: -x[1])[:top]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _terminate_tree(proc: subprocess.Popen) -> None:
|
||||
"""Stop the server AND its children, which on Windows are a separate process.
|
||||
|
||||
CI profiles `Scripts/unsloth.exe`, a distlib launcher stub that CreateProcess's
|
||||
the venv python and waits, so terminate() reaps the stub only: the real backend
|
||||
keeps the inherited stdout handle, the reader thread never sees EOF, and
|
||||
--repeats strands one server per iteration on the shared UNSLOTH_STUDIO_HOME.
|
||||
taskkill /T walks the tree, as unsloth_cli/commands/start.py already does.
|
||||
"""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
if os.name == "nt":
|
||||
try:
|
||||
killed = subprocess.run(
|
||||
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
check = False,
|
||||
)
|
||||
if killed.returncode == 0:
|
||||
return
|
||||
except Exception:
|
||||
# taskkill missing or timed out; fall through so the stub still dies.
|
||||
pass
|
||||
# check=False: a nonzero taskkill does not raise, so fall through as well.
|
||||
proc.terminate()
|
||||
|
||||
|
||||
def profile_launch(
|
||||
bin_path: str,
|
||||
port: int,
|
||||
timeout_s: int = 300,
|
||||
) -> dict:
|
||||
"""Spawn the backend the way the desktop app does and time it to first 200."""
|
||||
log_lines: list[str] = []
|
||||
first_byte: list[float] = []
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.Popen(
|
||||
[bin_path, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)],
|
||||
cwd = REPO_ROOT,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
bufsize = 1,
|
||||
)
|
||||
|
||||
def _drain() -> None:
|
||||
# Runs alongside the health polling: the first read timestamps the spawn
|
||||
# phase, and an undrained pipe blocks the backend before it binds.
|
||||
for line in proc.stdout:
|
||||
if not first_byte:
|
||||
first_byte.append(time.perf_counter() - t0)
|
||||
log_lines.append(line.rstrip("\n"))
|
||||
|
||||
reader = threading.Thread(target = _drain, daemon = True)
|
||||
reader.start()
|
||||
|
||||
t_healthz = None
|
||||
deadline = t0 + timeout_s
|
||||
try:
|
||||
while time.perf_counter() < deadline:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
if t_healthz is None:
|
||||
for url in (
|
||||
f"http://127.0.0.1:{port}/api/health",
|
||||
f"http://127.0.0.1:{port}/healthz",
|
||||
):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout = 2) as r:
|
||||
if r.status == 200:
|
||||
t_healthz = time.perf_counter() - t0
|
||||
break
|
||||
except (urllib.error.URLError, OSError, TimeoutError):
|
||||
pass
|
||||
if t_healthz is not None:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
finally:
|
||||
_terminate_tree(proc)
|
||||
try:
|
||||
# Safe: the reader drains the pipe, so the child cannot block on write().
|
||||
proc.wait(timeout = 30)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
reader.join(timeout = 10)
|
||||
|
||||
t_first_byte = first_byte[0] if first_byte else None
|
||||
lifespan_ms = None
|
||||
for line in log_lines:
|
||||
m = re.search(r"lifespan startup completed in ([\d.]+)ms", line)
|
||||
if m:
|
||||
lifespan_ms = float(m.group(1))
|
||||
return {
|
||||
"spawn_seconds": round(t_first_byte, 3) if t_first_byte is not None else None,
|
||||
"healthz_seconds": round(t_healthz, 3) if t_healthz is not None else None,
|
||||
"lifespan_ms": lifespan_ms,
|
||||
"reached_healthz": t_healthz is not None,
|
||||
"log_tail": log_lines[-25:],
|
||||
}
|
||||
|
||||
|
||||
def python_version_of(python: str) -> str:
|
||||
"""Version of the interpreter that runs the imports, not the one running us.
|
||||
|
||||
--python points at the installed Studio venv while this script runs under the
|
||||
runner's system python, so platform.python_version() would label it wrong.
|
||||
"""
|
||||
if python == sys.executable:
|
||||
return platform.python_version()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[python, "-c", "import platform; print(platform.python_version())"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 60,
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
return proc.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def find_bin() -> str | None:
|
||||
home = os.environ.get("UNSLOTH_STUDIO_HOME") or str(Path.home() / ".unsloth" / "studio")
|
||||
names = ["unsloth.exe", "unsloth"] if platform.system() == "Windows" else ["unsloth"]
|
||||
subdirs = ["unsloth_studio/Scripts", "unsloth_studio/bin", "bin", "Scripts"]
|
||||
for sd in subdirs:
|
||||
for n in names:
|
||||
p = Path(home) / sd / n
|
||||
if p.exists():
|
||||
return str(p)
|
||||
return shutil.which("unsloth")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument(
|
||||
"--repeats",
|
||||
type = int,
|
||||
default = 1,
|
||||
help = "launch repeats; the median is reported (imports are measured once)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--python",
|
||||
default = sys.executable,
|
||||
help = "interpreter used for the import profile (default: this one)",
|
||||
)
|
||||
ap.add_argument("--bin", help = "path to the unsloth CLI (default: autodetect)")
|
||||
ap.add_argument(
|
||||
"--import-only",
|
||||
action = "store_true",
|
||||
help = "skip the server phases (no install needed beyond the deps)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--max-healthz-seconds",
|
||||
type = float,
|
||||
help = "fail if the median time to a healthy port exceeds this",
|
||||
)
|
||||
ap.add_argument("--json", help = "write the full report here")
|
||||
a = ap.parse_args(argv)
|
||||
# range(0) launches nothing, leaving the budget check with nothing to fail on.
|
||||
if a.repeats < 1:
|
||||
ap.error("--repeats must be at least 1")
|
||||
# Same reason: --import-only never launches anything.
|
||||
if a.import_only and a.max_healthz_seconds is not None:
|
||||
ap.error("--max-healthz-seconds cannot be combined with --import-only")
|
||||
# nan and inf parse fine as floats but `med > budget` is then always False,
|
||||
# so the gate would report success without ever bounding anything.
|
||||
if a.max_healthz_seconds is not None and not math.isfinite(a.max_healthz_seconds):
|
||||
ap.error("--max-healthz-seconds must be a finite number")
|
||||
|
||||
report: dict = {
|
||||
"platform": platform.system().lower(),
|
||||
"machine": platform.machine(),
|
||||
"python": python_version_of(a.python),
|
||||
"cpu_count": os.cpu_count(),
|
||||
}
|
||||
|
||||
print("== import graph ==")
|
||||
report["imports"] = profile_imports(a.python)
|
||||
imp = report["imports"]
|
||||
if imp.get("ok"):
|
||||
print(f" import main: {imp['total_seconds']}s")
|
||||
for row in imp["top_cumulative"][:8]:
|
||||
print(f" {row['seconds']:7.3f}s {row['module']}")
|
||||
print(" self time by package (ms):")
|
||||
for k, v in list(imp["self_by_package_ms"].items())[:8]:
|
||||
print(f" {v:8} ms {k}")
|
||||
else:
|
||||
print(f" FAILED: {imp.get('error', '')[:400]}")
|
||||
|
||||
if not a.import_only:
|
||||
bin_path = a.bin or find_bin()
|
||||
if not bin_path:
|
||||
print(
|
||||
"== launch == skipped: no unsloth CLI found "
|
||||
"(set UNSLOTH_STUDIO_HOME or pass --bin)"
|
||||
)
|
||||
report["launch"] = {"skipped": "no unsloth CLI found"}
|
||||
else:
|
||||
print(f"== launch == {bin_path}")
|
||||
runs = []
|
||||
for i in range(a.repeats):
|
||||
r = profile_launch(bin_path, _free_port())
|
||||
runs.append(r)
|
||||
print(
|
||||
f" run {i + 1}: healthz={r['healthz_seconds']}s "
|
||||
f"lifespan={r['lifespan_ms']}ms reached={r['reached_healthz']}"
|
||||
)
|
||||
got = [r["healthz_seconds"] for r in runs if r["healthz_seconds"] is not None]
|
||||
report["launch"] = {
|
||||
"runs": runs,
|
||||
"failed_runs": sum(1 for r in runs if not r["reached_healthz"]),
|
||||
"healthz_median_seconds": round(statistics.median(got), 3) if got else None,
|
||||
"healthz_max_seconds": round(max(got), 3) if got else None,
|
||||
}
|
||||
if got:
|
||||
print(
|
||||
f" median time to healthy port: {report['launch']['healthz_median_seconds']}s"
|
||||
)
|
||||
|
||||
if a.json:
|
||||
Path(a.json).write_text(json.dumps(report, indent = 2), encoding = "utf-8")
|
||||
print(f"\nwrote {a.json}")
|
||||
|
||||
if a.max_healthz_seconds is not None:
|
||||
launch = report.get("launch") or {}
|
||||
med = launch.get("healthz_median_seconds")
|
||||
failed = launch.get("failed_runs") or 0
|
||||
if failed:
|
||||
# Failed launches fail the budget; dropping them would keep only the fast ones.
|
||||
print(
|
||||
f"::error::startup regression: {failed} of {len(launch.get('runs') or [])} "
|
||||
f"launches never became healthy within the timeout"
|
||||
)
|
||||
return 1
|
||||
if med is None:
|
||||
# Nothing measured: exiting 0 would pass a requested budget without a
|
||||
# single health request, so fail closed.
|
||||
print(
|
||||
"::error::startup regression: no healthz measurement, so the "
|
||||
f"{a.max_healthz_seconds}s budget was never checked "
|
||||
f"({launch.get('skipped') or 'launch phase produced no runs'})"
|
||||
)
|
||||
return 1
|
||||
elif med > a.max_healthz_seconds:
|
||||
print(
|
||||
f"::error::startup regression: {med}s median to a healthy port "
|
||||
f"exceeds the {a.max_healthz_seconds}s budget"
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
|
@ -11,11 +11,12 @@ import jwt
|
|||
|
||||
from .storage import (
|
||||
API_KEY_PREFIX,
|
||||
credential_generation,
|
||||
get_jwt_secret,
|
||||
get_user_and_secret,
|
||||
load_jwt_secret,
|
||||
save_refresh_token,
|
||||
validate_api_key,
|
||||
validate_api_key_with_credential,
|
||||
verify_refresh_token,
|
||||
)
|
||||
|
||||
|
|
@ -54,11 +55,14 @@ def create_access_token(
|
|||
expires_delta: Optional[timedelta] = None,
|
||||
*,
|
||||
desktop: bool = False,
|
||||
secret: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a signed JWT for the given subject (e.g. username).
|
||||
|
||||
Valid across restarts: the signing secret is stored in SQLite.
|
||||
Valid across restarts: the signing secret is stored in SQLite. Callers that
|
||||
already verified a credential pass ``secret`` so a rotation landing mid-request
|
||||
cannot sign the token with the credential that just replaced it.
|
||||
"""
|
||||
to_encode = {"sub": subject}
|
||||
if desktop:
|
||||
|
|
@ -69,7 +73,7 @@ def create_access_token(
|
|||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
_get_secret_for_subject(subject),
|
||||
secret if secret is not None else _get_secret_for_subject(subject),
|
||||
algorithm = ALGORITHM,
|
||||
)
|
||||
|
||||
|
|
@ -96,15 +100,28 @@ def is_desktop_access_token(token: str) -> bool:
|
|||
return payload.get("sub") == subject and payload.get("desktop") is True
|
||||
|
||||
|
||||
def create_refresh_token(subject: str, *, desktop: bool = False) -> str:
|
||||
def create_refresh_token(
|
||||
subject: str,
|
||||
*,
|
||||
desktop: bool = False,
|
||||
secret: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a random refresh token, store its hash in SQLite, and return it.
|
||||
|
||||
Refresh tokens are opaque (not JWTs); expire after REFRESH_TOKEN_EXPIRE_DAYS.
|
||||
``secret`` stamps the token with the credential version the caller verified,
|
||||
so a rotation cannot leave a token minted from the replaced credential valid.
|
||||
"""
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
save_refresh_token(token, subject, expires_at.isoformat(), is_desktop = desktop)
|
||||
save_refresh_token(
|
||||
token,
|
||||
subject,
|
||||
expires_at.isoformat(),
|
||||
is_desktop = desktop,
|
||||
secret_gen = credential_generation(secret) if secret is not None else None,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
|
|
@ -137,7 +154,22 @@ def reload_secret() -> None:
|
|||
|
||||
async def get_current_subject(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||
"""Validate JWT and require the password-change flow to be completed."""
|
||||
return await _get_current_subject(
|
||||
subject, _generation = await _get_current_credential(
|
||||
credentials,
|
||||
allow_password_change = False,
|
||||
)
|
||||
return subject
|
||||
|
||||
|
||||
async def get_current_credential(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""As get_current_subject, but also returns the credential generation.
|
||||
|
||||
For routes that persist a new credential and must not do so on behalf of one
|
||||
a concurrent reset has revoked.
|
||||
"""
|
||||
return await _get_current_credential(
|
||||
credentials,
|
||||
allow_password_change = False,
|
||||
)
|
||||
|
|
@ -158,10 +190,11 @@ async def get_current_subject_allow_password_change(
|
|||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str:
|
||||
"""Validate JWT but allow access to the password-change endpoint."""
|
||||
return await _get_current_subject(
|
||||
subject, _generation = await _get_current_credential(
|
||||
credentials,
|
||||
allow_password_change = True,
|
||||
)
|
||||
return subject
|
||||
|
||||
|
||||
# The literal the examples ship with; pasted unedited more often than a revoked key.
|
||||
|
|
@ -179,21 +212,27 @@ def _invalid_api_key_detail(token: str) -> str:
|
|||
return "Invalid or expired API key"
|
||||
|
||||
|
||||
async def _get_current_subject(
|
||||
async def _get_current_credential(
|
||||
credentials: HTTPAuthorizationCredentials, *, allow_password_change: bool
|
||||
) -> str:
|
||||
"""FastAPI dependency: validate the JWT and return the subject. Use on protected routes."""
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""Validate the bearer and return ``(subject, credential generation)``.
|
||||
|
||||
The generation is the credential version this request actually authenticated
|
||||
against. Routes that persist new credentials must bind their write to it, or
|
||||
a reset landing mid-request would bless what it just revoked.
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
# --- API key path (sk-unsloth-...) ---
|
||||
if token.startswith(API_KEY_PREFIX):
|
||||
username = validate_api_key(token)
|
||||
if username is None:
|
||||
verified = validate_api_key_with_credential(token)
|
||||
if verified is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = _invalid_api_key_detail(token),
|
||||
)
|
||||
return username
|
||||
username, secret = verified
|
||||
return username, credential_generation(secret)
|
||||
|
||||
# --- JWT path ---
|
||||
subject = _decode_subject_without_verification(token)
|
||||
|
|
@ -224,7 +263,7 @@ async def _get_current_subject(
|
|||
status_code = status.HTTP_403_FORBIDDEN,
|
||||
detail = "Password change required",
|
||||
)
|
||||
return subject
|
||||
return subject, credential_generation(jwt_secret)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import ipaddress
|
|||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
|
|
@ -30,6 +31,97 @@ _BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password"
|
|||
_bootstrap_password: Optional[str] = None
|
||||
|
||||
|
||||
def _bootstrap_file_bytes(password: str) -> bytes:
|
||||
"""Exact on-disk form: the secret plus one LF.
|
||||
|
||||
Bytes, not text: text mode writes CRLF on Windows, and `$(cat ...)` strips
|
||||
the LF but leaves the CR attached to the credential.
|
||||
"""
|
||||
return (password + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _persist_bootstrap_password(password: str) -> None:
|
||||
"""Atomically write the bootstrap password 0600, LF terminated on every OS.
|
||||
|
||||
A partial write would destroy the only plaintext recovery credential.
|
||||
"""
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix = f".{_BOOTSTRAP_PW_PATH.name}.", dir = _BOOTSTRAP_PW_PATH.parent
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(_bootstrap_file_bytes(password))
|
||||
try:
|
||||
os.chmod(tmp_name, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp_name, _BOOTSTRAP_PW_PATH)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _normalise_bootstrap_file(raw: bytes, password: str) -> None:
|
||||
"""Append the LF a pre-newline release left off.
|
||||
|
||||
Append-only, and only when the file is exactly the credential:
|
||||
clear_bootstrap_password() may unlink or (when unlink fails, notably on
|
||||
Windows while this descriptor is open) truncate through another descriptor
|
||||
after we read, so a rewrite could restore revoked plaintext. An append
|
||||
cannot: worst case is a lone "\\n" over a cleared file, which strips back to
|
||||
no bootstrap password. Pre-newline releases wrote no terminator at all, so
|
||||
that is the only shape in the wild; anything else reads fine, since every
|
||||
reader strips, and is left alone.
|
||||
"""
|
||||
if raw != password.encode("utf-8"):
|
||||
return
|
||||
|
||||
# O_BINARY: without it Windows opens in text mode and turns the LF straight
|
||||
# back into CRLF, the bug being fixed.
|
||||
fd = os.open(
|
||||
_BOOTSTRAP_PW_PATH,
|
||||
os.O_WRONLY | os.O_APPEND | getattr(os, "O_BINARY", 0),
|
||||
)
|
||||
try:
|
||||
os.write(fd, b"\n")
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
except (AttributeError, OSError):
|
||||
# fchmod only reached Windows in 3.13.
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _read_persisted_bootstrap_password() -> Optional[str]:
|
||||
"""Read the persisted password, normalising the file if it is malformed."""
|
||||
if not _BOOTSTRAP_PW_PATH.is_file():
|
||||
return None
|
||||
|
||||
# No caller handles a raise, so an unreadable file has to mean "no bootstrap
|
||||
# password", not a dead backend. We write UTF-8, so undecodable bytes are
|
||||
# damage whose plaintext is worthless anyway.
|
||||
try:
|
||||
raw = _BOOTSTRAP_PW_PATH.read_bytes()
|
||||
password = raw.decode("utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
if not password:
|
||||
return None
|
||||
|
||||
# Older releases wrote no terminator; best-effort, a read-only auth dir must
|
||||
# not fail startup.
|
||||
if raw != _bootstrap_file_bytes(password):
|
||||
try:
|
||||
_normalise_bootstrap_file(raw, password)
|
||||
except OSError:
|
||||
pass
|
||||
return password
|
||||
|
||||
|
||||
def generate_bootstrap_password() -> str:
|
||||
"""Generate a 4-word diceware passphrase and persist it to disk.
|
||||
|
||||
|
|
@ -43,10 +135,10 @@ def generate_bootstrap_password() -> str:
|
|||
return _bootstrap_password
|
||||
|
||||
# Persisted from a previous run?
|
||||
if _BOOTSTRAP_PW_PATH.is_file():
|
||||
_bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
|
||||
if _bootstrap_password:
|
||||
return _bootstrap_password
|
||||
persisted = _read_persisted_bootstrap_password()
|
||||
if persisted:
|
||||
_bootstrap_password = persisted
|
||||
return _bootstrap_password
|
||||
|
||||
# First startup: generate a fresh passphrase.
|
||||
import diceware
|
||||
|
|
@ -57,11 +149,7 @@ def generate_bootstrap_password() -> str:
|
|||
|
||||
# Persist so the same passphrase survives restarts until password change.
|
||||
ensure_dir(_BOOTSTRAP_PW_PATH.parent)
|
||||
_BOOTSTRAP_PW_PATH.write_text(_bootstrap_password, encoding = "utf-8")
|
||||
try:
|
||||
os.chmod(_BOOTSTRAP_PW_PATH, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
_persist_bootstrap_password(_bootstrap_password)
|
||||
|
||||
return _bootstrap_password
|
||||
|
||||
|
|
@ -72,13 +160,14 @@ def get_bootstrap_password() -> Optional[str]:
|
|||
|
||||
|
||||
def _load_bootstrap_password() -> Optional[str]:
|
||||
"""Load an existing bootstrap password without creating one."""
|
||||
"""Load an existing bootstrap password without creating one.
|
||||
|
||||
Upgrades take this path, not generate_bootstrap_password()
|
||||
(ensure_default_admin short-circuits once the admin row exists), so it has
|
||||
to normalise too.
|
||||
"""
|
||||
global _bootstrap_password
|
||||
_bootstrap_password = None
|
||||
if _BOOTSTRAP_PW_PATH.is_file():
|
||||
bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
|
||||
if bootstrap_password:
|
||||
_bootstrap_password = bootstrap_password
|
||||
_bootstrap_password = _read_persisted_bootstrap_password()
|
||||
return _bootstrap_password
|
||||
|
||||
|
||||
|
|
@ -97,7 +186,7 @@ def clear_bootstrap_password() -> None:
|
|||
# Removal failed (Windows AV, read-only auth dir). The hash is already
|
||||
# committed, so don't fail the change -- but truncate the file so its
|
||||
# stale plaintext can't be re-seeded by generate_bootstrap_password()
|
||||
# if a later reset-password deletes auth.db and re-validates it.
|
||||
# if auth.db is ever recreated.
|
||||
try:
|
||||
_BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8")
|
||||
cleared = True
|
||||
|
|
@ -132,6 +221,31 @@ def _hash_token(token: str) -> str:
|
|||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class CredentialRotated(Exception):
|
||||
"""A password reset revoked the credential this request authenticated with."""
|
||||
|
||||
|
||||
def credential_generation(jwt_secret: str) -> str:
|
||||
"""Marker for the credential version a refresh token was issued under.
|
||||
|
||||
Every password change rotates ``jwt_secret``, so a token stamped with the
|
||||
previous one is rejected even if it was inserted after the revoking DELETE.
|
||||
"""
|
||||
return hashlib.sha256(jwt_secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _current_secret(conn: sqlite3.Connection, username: str) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT jwt_secret FROM auth_user WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
return row["jwt_secret"] if row else None
|
||||
|
||||
|
||||
def _current_generation(conn: sqlite3.Connection, username: str) -> Optional[str]:
|
||||
secret = _current_secret(conn, username)
|
||||
return credential_generation(secret) if secret is not None else None
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Get a connection to the auth database, creating tables if needed."""
|
||||
ensure_dir(DB_PATH.parent)
|
||||
|
|
@ -175,7 +289,8 @@ def get_connection() -> sqlite3.Connection:
|
|||
token_hash TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
is_desktop INTEGER NOT NULL DEFAULT 0
|
||||
is_desktop INTEGER NOT NULL DEFAULT 0,
|
||||
secret_gen TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
@ -214,6 +329,8 @@ def get_connection() -> sqlite3.Connection:
|
|||
refresh_columns = {row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")}
|
||||
if "is_desktop" not in refresh_columns:
|
||||
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0")
|
||||
if "secret_gen" not in refresh_columns:
|
||||
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN secret_gen TEXT")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
|
@ -587,12 +704,22 @@ def update_password(
|
|||
new_password: str,
|
||||
*,
|
||||
revoke_refresh_tokens: bool = False,
|
||||
) -> bool:
|
||||
expect_password_hash: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Update password, clear first-login requirement, rotate JWT secret.
|
||||
|
||||
Returns the new JWT secret, or None when nothing was updated. Callers that
|
||||
mint tokens for the caller must sign with the returned secret: re-reading it
|
||||
would pick up a reset that landed between this commit and the mint.
|
||||
|
||||
``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME
|
||||
transaction: a separate delete could fail after the password commit and
|
||||
leave a pre-change token still able to mint access tokens.
|
||||
|
||||
``expect_password_hash`` makes the write conditional on the credential the
|
||||
caller verified still being current, so a request that checked the old
|
||||
password cannot overwrite a reset that landed while it was in flight.
|
||||
Returns False when the credential moved underneath it.
|
||||
"""
|
||||
from .hashing import hash_password
|
||||
|
||||
|
|
@ -600,21 +727,32 @@ def update_password(
|
|||
jwt_secret = secrets.token_urlsafe(64)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE auth_user
|
||||
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
|
||||
WHERE username = ?
|
||||
""",
|
||||
(salt, pwd_hash, jwt_secret, username),
|
||||
)
|
||||
if expect_password_hash is None:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE auth_user
|
||||
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
|
||||
WHERE username = ?
|
||||
""",
|
||||
(salt, pwd_hash, jwt_secret, username),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE auth_user
|
||||
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
|
||||
WHERE username = ? AND password_hash = ?
|
||||
""",
|
||||
(salt, pwd_hash, jwt_secret, username, expect_password_hash),
|
||||
)
|
||||
if revoke_refresh_tokens and cursor.rowcount > 0:
|
||||
conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,))
|
||||
conn.commit()
|
||||
if cursor.rowcount > 0:
|
||||
clear_bootstrap_password()
|
||||
clear_desktop_secret()
|
||||
return cursor.rowcount > 0
|
||||
return jwt_secret
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -625,35 +763,49 @@ def save_refresh_token(
|
|||
expires_at: str,
|
||||
*,
|
||||
is_desktop: bool = False,
|
||||
secret_gen: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Store a hashed refresh token with its associated username and expiry.
|
||||
|
||||
``secret_gen`` binds the token to a credential version; it defaults to the
|
||||
current one, and callers that already verified a credential must pass the
|
||||
version they verified rather than let this re-read a rotated one.
|
||||
"""
|
||||
token_hash = _hash_token(token)
|
||||
conn = get_connection()
|
||||
try:
|
||||
if secret_gen is None:
|
||||
secret_gen = _current_generation(conn, username)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop, secret_gen)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(token_hash, username, expires_at, int(is_desktop)),
|
||||
(token_hash, username, expires_at, int(is_desktop), secret_gen),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
||||
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool, str]]:
|
||||
"""Atomically validate-and-delete a refresh token for single-use rotation.
|
||||
|
||||
DELETE RETURNING fuses validate and delete into one statement so two
|
||||
concurrent refresh requests cannot both consume the same token.
|
||||
concurrent refresh requests cannot both consume the same token. Returns
|
||||
``(username, is_desktop, jwt_secret)``; the caller must mint the replacement
|
||||
tokens against that secret so a rotation landing mid-refresh cannot issue a
|
||||
post-rotation session from a pre-rotation token.
|
||||
"""
|
||||
token_hash = _hash_token(token)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
# One transaction with the delete: an unstamped legacy row has no
|
||||
# generation to compare, so reading the credential after committing would
|
||||
# hand a reset's new secret to a token issued before it.
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||
(now,),
|
||||
|
|
@ -662,15 +814,21 @@ def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
|||
"""
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE token_hash = ? AND expires_at >= ?
|
||||
RETURNING username, is_desktop
|
||||
RETURNING username, is_desktop, secret_gen
|
||||
""",
|
||||
(token_hash, now),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
if row is None:
|
||||
conn.commit()
|
||||
return None
|
||||
return row["username"], bool(row["is_desktop"])
|
||||
secret = _current_secret(conn, row["username"])
|
||||
conn.commit()
|
||||
if secret is None:
|
||||
return None
|
||||
if row["secret_gen"] is not None and row["secret_gen"] != credential_generation(secret):
|
||||
return None
|
||||
return row["username"], bool(row["is_desktop"]), secret
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -694,7 +852,7 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
|||
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT id, username, expires_at, is_desktop FROM refresh_tokens
|
||||
SELECT id, username, expires_at, is_desktop, secret_gen FROM refresh_tokens
|
||||
WHERE token_hash = ?
|
||||
""",
|
||||
(token_hash,),
|
||||
|
|
@ -703,6 +861,13 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
|||
if row is None:
|
||||
return None
|
||||
|
||||
if row["secret_gen"] is not None and row["secret_gen"] != _current_generation(
|
||||
conn, row["username"]
|
||||
):
|
||||
conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],))
|
||||
conn.commit()
|
||||
return None
|
||||
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(row["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
|
|
@ -747,30 +912,41 @@ def create_desktop_secret() -> str:
|
|||
conn.close()
|
||||
|
||||
|
||||
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
|
||||
"""Return the real admin username when the desktop secret matches."""
|
||||
def validate_desktop_secret_with_credential(raw_secret: str) -> Optional[Tuple[str, str]]:
|
||||
"""Validate the desktop secret and return ``(username, jwt_secret)``.
|
||||
|
||||
Both reads share one transaction so the returned secret is the credential
|
||||
version the desktop secret was checked against; a reset landing mid-request
|
||||
then invalidates the tokens minted from it rather than blessing them.
|
||||
"""
|
||||
if not raw_secret.startswith(DESKTOP_SECRET_PREFIX):
|
||||
return None
|
||||
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is None:
|
||||
return None
|
||||
|
||||
secret_hash = _pbkdf2_desktop_secret(raw_secret)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
conn.execute("BEGIN")
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_secrets WHERE key = ?",
|
||||
(_DESKTOP_SECRET_HASH_KEY,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
).fetchone()
|
||||
if row is None or not secrets.compare_digest(row["value"], secret_hash):
|
||||
return None
|
||||
if not secrets.compare_digest(row["value"], secret_hash):
|
||||
jwt_secret = _current_secret(conn, DEFAULT_ADMIN_USERNAME)
|
||||
if jwt_secret is None:
|
||||
return None
|
||||
return DEFAULT_ADMIN_USERNAME
|
||||
return DEFAULT_ADMIN_USERNAME, jwt_secret
|
||||
finally:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
|
||||
|
||||
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
|
||||
"""Return the real admin username when the desktop secret matches."""
|
||||
verified = validate_desktop_secret_with_credential(raw_secret)
|
||||
return verified[0] if verified else None
|
||||
|
||||
|
||||
def clear_desktop_secret() -> None:
|
||||
"""Remove backend-side desktop auth state."""
|
||||
conn = get_connection()
|
||||
|
|
@ -796,6 +972,7 @@ def create_api_key(
|
|||
name: str,
|
||||
expires_at: Optional[str] = None,
|
||||
internal: bool = False,
|
||||
expect_gen: Optional[str] = None,
|
||||
) -> Tuple[str, dict]:
|
||||
"""Create a new API key for *username*.
|
||||
|
||||
|
|
@ -804,6 +981,10 @@ def create_api_key(
|
|||
|
||||
Pass ``internal=True`` for keys minted by workflows (e.g. data-recipe
|
||||
runs) that should not appear in user-facing key listings.
|
||||
|
||||
``expect_gen`` ties the insert to the credential generation the request
|
||||
authenticated under, so a session revoked by a concurrent password reset
|
||||
cannot mint a key that outlives it. Raises ``CredentialRotated`` if it moved.
|
||||
"""
|
||||
raw_key = API_KEY_PREFIX + secrets.token_hex(16)
|
||||
key_hash = _pbkdf2_api_key(raw_key)
|
||||
|
|
@ -812,6 +993,12 @@ def create_api_key(
|
|||
|
||||
conn = get_connection()
|
||||
try:
|
||||
if expect_gen is not None:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if _current_generation(conn, username) != expect_gen:
|
||||
raise CredentialRotated(
|
||||
"The credential this request authenticated with was revoked."
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at, expires_at, is_internal)
|
||||
|
|
@ -900,15 +1087,25 @@ def revoke_internal_api_key(key_id: int) -> bool:
|
|||
|
||||
|
||||
def validate_api_key(raw_key: str) -> Optional[str]:
|
||||
"""Validate *raw_key* and return the owning username, or ``None``.
|
||||
"""Validate *raw_key* and return the owning username, or ``None``."""
|
||||
verified = validate_api_key_with_credential(raw_key)
|
||||
return verified[0] if verified else None
|
||||
|
||||
Also updates ``last_used_at`` on success.
|
||||
|
||||
def validate_api_key_with_credential(raw_key: str) -> Optional[Tuple[str, str]]:
|
||||
"""Validate *raw_key* and return ``(username, jwt_secret)``, or ``None``.
|
||||
|
||||
Also updates ``last_used_at`` on success. The key check and the credential
|
||||
read share one write transaction, so the returned version is the one the key
|
||||
was actually valid under: a reset committing right after cannot have its new
|
||||
generation handed to a request the key it revoked authenticated.
|
||||
"""
|
||||
cache_id = _api_key_cache_id(raw_key)
|
||||
cached_hash = _api_key_hash_cache.get(cache_id)
|
||||
key_hash = cached_hash if cached_hash is not None else _pbkdf2_api_key(raw_key)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
cur = conn.execute(
|
||||
"SELECT id, username, is_active, expires_at FROM api_keys WHERE key_hash = ?",
|
||||
(key_hash,),
|
||||
|
|
@ -928,11 +1125,15 @@ def validate_api_key(raw_key: str) -> Optional[str]:
|
|||
expires = datetime.fromisoformat(row["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires:
|
||||
return None
|
||||
secret = _current_secret(conn, row["username"])
|
||||
if secret is None:
|
||||
return None
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return row["username"]
|
||||
return row["username"], secret
|
||||
finally:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@ class CloudflareTunnel:
|
|||
stderr = subprocess.STDOUT,
|
||||
stdin = subprocess.DEVNULL,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
bufsize = 1,
|
||||
**_windows_hidden_kwargs(),
|
||||
|
|
|
|||
|
|
@ -257,6 +257,8 @@ def _run_oxc_batch(
|
|||
cwd = str(_OXC_TOOL_DIR),
|
||||
input = json.dumps(payload),
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
capture_output = True,
|
||||
check = False,
|
||||
env = env,
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ class InferenceBackend:
|
|||
_meta_path = Path(config.path) / "export_metadata.json"
|
||||
try:
|
||||
if _meta_path.exists():
|
||||
_meta = json.loads(_meta_path.read_text(encoding = "utf-8"))
|
||||
_meta = json.loads(_meta_path.read_text(encoding = "utf-8-sig"))
|
||||
if _meta.get("base_model"):
|
||||
processor_source = _meta["base_model"]
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ from core.tool_healing import (
|
|||
strip_outside_think,
|
||||
)
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.child_stdio import utf8_child_env
|
||||
from utils.hf_xet_fallback import hf_hub_download_with_xet_fallback
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
|
|
@ -581,7 +582,7 @@ def _load_swa_cache() -> dict:
|
|||
if _SWA_CACHE is not None:
|
||||
return _SWA_CACHE
|
||||
try:
|
||||
with open(_swa_cache_path(), encoding = "utf-8") as f:
|
||||
with open(_swa_cache_path(), encoding = "utf-8-sig") as f:
|
||||
_SWA_CACHE = json.load(f)
|
||||
if not isinstance(_SWA_CACHE, dict):
|
||||
_SWA_CACHE = {}
|
||||
|
|
@ -632,7 +633,7 @@ def _fetch_swa_entry_from_hf(repo_id: str) -> Optional[object]:
|
|||
repo_type = "model",
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
with open(cfg_path, encoding = "utf-8") as f:
|
||||
with open(cfg_path, encoding = "utf-8-sig") as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -3087,6 +3088,7 @@ class LlamaCppBackend:
|
|||
[bin_path, "--help"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 10,
|
||||
check = False,
|
||||
|
|
@ -3692,6 +3694,8 @@ class LlamaCppBackend:
|
|||
],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 10,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
|
|
@ -3806,7 +3810,7 @@ class LlamaCppBackend:
|
|||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 15,
|
||||
env = env,
|
||||
env = utf8_child_env(env),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
|
|
@ -5556,7 +5560,9 @@ class LlamaCppBackend:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = env,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = utf8_child_env(env),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
**_child_popen_kwargs(),
|
||||
)
|
||||
|
|
@ -6770,6 +6776,8 @@ class LlamaCppBackend:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = env,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
**_child_popen_kwargs(),
|
||||
|
|
@ -8841,6 +8849,8 @@ class LlamaCppBackend:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = env,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
**_child_popen_kwargs(),
|
||||
|
|
@ -10357,6 +10367,8 @@ class LlamaCppBackend:
|
|||
["pgrep", "-a", "-f", "llama-server"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 5,
|
||||
env = child_env_without_native_path_secret(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def _resolve_lora_4bit(mc, load_in_4bit: bool) -> bool:
|
|||
import json
|
||||
|
||||
try:
|
||||
with open(adapter_cfg_path, encoding = "utf-8") as f:
|
||||
with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
|
||||
adapter_cfg = json.load(f)
|
||||
training_method = adapter_cfg.get("unsloth_training_method")
|
||||
if training_method == "lora" and load_in_4bit:
|
||||
|
|
@ -963,7 +963,7 @@ def run_inference_process(
|
|||
if _local_adapter_cfg.is_file():
|
||||
try:
|
||||
_lora_base = (
|
||||
_json.loads(_local_adapter_cfg.read_text(encoding = "utf-8")).get(
|
||||
_json.loads(_local_adapter_cfg.read_text(encoding = "utf-8-sig")).get(
|
||||
"base_model_name_or_path"
|
||||
)
|
||||
or None
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ class LlamaServerBackend:
|
|||
[binary, "--help"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 30,
|
||||
**windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
|
@ -331,6 +333,8 @@ class LlamaServerBackend:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = env,
|
||||
**windows_hidden_subprocess_kwargs(),
|
||||
**child_popen_kwargs(),
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
|
|||
path = Path(normalize_path(name)).expanduser() / "modules.json"
|
||||
if not path.is_file():
|
||||
return ()
|
||||
data = json.loads(path.read_text(encoding = "utf-8"))
|
||||
data = json.loads(path.read_text(encoding = "utf-8-sig"))
|
||||
else:
|
||||
from huggingface_hub import hf_hub_download
|
||||
from huggingface_hub.utils import EntryNotFoundError
|
||||
|
|
@ -115,7 +115,7 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
|
|||
)
|
||||
except EntryNotFoundError:
|
||||
return ()
|
||||
data = json.loads(open(local, encoding = "utf-8").read())
|
||||
data = json.loads(open(local, encoding = "utf-8-sig").read())
|
||||
subdirs = []
|
||||
for module in data or ():
|
||||
sub = str((module or {}).get("path", "")).strip().strip("/")
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ if sys.platform.startswith("linux") and "HSA_ENABLE_DXG_DETECTION" not in os.env
|
|||
pass
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from utils.child_stdio import utf8_child_env
|
||||
from utils.hardware import apply_gpu_ids
|
||||
from utils.training_runs import build_default_output_dir_name
|
||||
from utils.wheel_utils import (
|
||||
|
|
@ -385,6 +386,10 @@ def _install_package_wheel_first(
|
|||
"stdout": _sp.PIPE,
|
||||
"stderr": _sp.STDOUT,
|
||||
"text": True,
|
||||
"encoding": "utf-8",
|
||||
"errors": "replace",
|
||||
# Make the Python child emit the UTF-8 we decode above.
|
||||
"env": utf8_child_env(),
|
||||
}
|
||||
if is_hip:
|
||||
_run_kwargs["timeout"] = 1800
|
||||
|
|
@ -606,6 +611,9 @@ def _ensure_flash_linear_attention_unconditional(event_queue: Any) -> bool:
|
|||
stdout = _sp.PIPE,
|
||||
stderr = _sp.STDOUT,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = utf8_child_env(),
|
||||
timeout = _TILELANG_INSTALL_TIMEOUT_S,
|
||||
)
|
||||
except _sp.TimeoutExpired:
|
||||
|
|
@ -849,6 +857,9 @@ def _run_pip(cmd: list[str], event_queue: Any, label: str) -> bool:
|
|||
stdout = _sp.PIPE,
|
||||
stderr = _sp.STDOUT,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = utf8_child_env(),
|
||||
timeout = _TILELANG_INSTALL_TIMEOUT_S,
|
||||
)
|
||||
except _sp.TimeoutExpired:
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ def _ollama_model_info_from_manifest(
|
|||
return None
|
||||
|
||||
try:
|
||||
manifest = json.loads(tag_file.read_text(encoding = "utf-8"))
|
||||
manifest = json.loads(tag_file.read_text(encoding = "utf-8-sig"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
||||
logger.debug("Skipping unreadable/invalid Ollama manifest %s: %s", tag_file, e)
|
||||
return None
|
||||
|
|
@ -228,7 +228,7 @@ def _ollama_model_info_from_manifest(
|
|||
config_blob = _ollama_blob_path(blobs_dir, config_digest)
|
||||
if config_blob is not None and _safe_is_file(config_blob):
|
||||
try:
|
||||
cfg = json.loads(config_blob.read_text(encoding = "utf-8"))
|
||||
cfg = json.loads(config_blob.read_text(encoding = "utf-8-sig"))
|
||||
model_type = cfg.get("model_type", "")
|
||||
file_type = cfg.get("file_type", "")
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
||||
|
|
|
|||
|
|
@ -464,6 +464,8 @@ def _read_marker_value(marker: Path) -> Optional[str]:
|
|||
return None
|
||||
value = marker.read_text(encoding = "utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# UnicodeDecodeError is a ValueError, so it would escape and abort
|
||||
# prepare_cache_for_transport. An unknown value just purges and restarts.
|
||||
return None
|
||||
return value if value in VALID_TRANSPORTS else None
|
||||
|
||||
|
|
|
|||
|
|
@ -42,8 +42,12 @@ class LogConfig:
|
|||
log_level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||
|
||||
if sys.platform == "win32":
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
# Non-ASCII on a non-UTF-8 stream raises UnicodeEncodeError (Windows,
|
||||
# LANG=C), so key off the stream, not the platform.
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if getattr(stream, "encoding", "") and not str(stream.encoding).lower().replace(
|
||||
"-", ""
|
||||
).startswith("utf8"):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
try:
|
||||
stream.reconfigure(encoding = "utf-8", errors = "replace")
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ from utils.update_status import (
|
|||
get_studio_install_source_status,
|
||||
get_studio_update_status,
|
||||
)
|
||||
from utils.changelog import get_release_notes, is_supported_version_query
|
||||
from utils.studio_version import get_studio_version
|
||||
from utils.api_errors import install_api_error_handlers
|
||||
|
||||
|
|
@ -1154,6 +1155,18 @@ def studio_update_status(_current_subject: str = Depends(get_current_subject)):
|
|||
return get_studio_update_status(UNSLOTH_VERSION)
|
||||
|
||||
|
||||
@app.get("/api/studio/release-notes")
|
||||
def studio_release_notes(
|
||||
version: str = Query(..., max_length = 64),
|
||||
refresh: bool = Query(False),
|
||||
_current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Return CHANGELOG.md notes for exactly `version` (never a nearby one)."""
|
||||
if not is_supported_version_query(version):
|
||||
raise HTTPException(status_code = 422, detail = "Invalid version.")
|
||||
return get_release_notes(version, refresh = refresh)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/studio/download-transport-capabilities",
|
||||
response_model = TransportCapabilities,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, NamedTuple
|
||||
|
||||
|
||||
def _locale_encoding() -> str:
|
||||
"""The codepage a pre-UTF-8 release here would have written, or "".
|
||||
|
||||
Empty on a UTF-8 host, where there is no codepage to attribute the file to.
|
||||
"""
|
||||
try:
|
||||
preferred = locale.getencoding()
|
||||
except AttributeError: # Python < 3.11
|
||||
preferred = locale.getpreferredencoding(False)
|
||||
if preferred.lower().replace("-", "").replace("_", "") == "utf8":
|
||||
return ""
|
||||
return preferred
|
||||
|
||||
|
||||
# Trail bytes can land on JSON punctuation, so a single-byte fallback misreads these.
|
||||
_DOUBLE_BYTE_ENCODINGS = ("cp932", "cp936", "cp949", "cp950")
|
||||
|
||||
|
||||
def _parse(raw: bytes, encoding: str) -> Any:
|
||||
"""Parse one JSON document under *encoding*, or None if it does not.
|
||||
|
||||
RecursionError is a RuntimeError, so nesting json.loads will not descend is
|
||||
the one parse failure the other three miss. Both callers run this outside
|
||||
any further handler, so it has to answer None here or a single damaged
|
||||
record aborts the scraper at startup instead of being skipped.
|
||||
"""
|
||||
try:
|
||||
return json.loads(raw.decode(encoding))
|
||||
except (UnicodeDecodeError, LookupError, ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
|
||||
class _Reading(NamedTuple):
|
||||
as_utf8: Any
|
||||
as_legacy: Any
|
||||
|
||||
|
||||
def _read_line(raw: bytes, codepage: str) -> _Reading:
|
||||
"""Read one line as UTF-8 and as a codepage, for dedup keys only.
|
||||
|
||||
Requiring valid JSON, not merely a successful decode, is what separates a
|
||||
genuine legacy record from a half-written UTF-8 one: a torn multibyte
|
||||
character decodes under cp1252 but leaves the JSON unterminated. Some byte
|
||||
strings parse both ways, e.g. cp1251 ``Р°`` is ``D0 B0``, which is also
|
||||
UTF-8 ``а``.
|
||||
|
||||
The codepage reading is never authoritative, because the file's own encoding
|
||||
cannot be recovered from its bytes. Reading a cp1251 shard on a cp1252
|
||||
machine turns ``Привет`` into ``Ïðèâåò`` and every byte of it decodes
|
||||
cleanly, so a successful decode proves nothing about who wrote it. It is
|
||||
used only to recover the dedup keys, which are ASCII ids and come back the
|
||||
same under any of these, so the first reading that parses will do.
|
||||
|
||||
That is also why several are tried. latin-1 alone mangles the double-byte
|
||||
codepages: cp932 ``表`` is ``95 5C``, and latin-1 turns the trail byte into
|
||||
a JSON backslash, so the record fails to parse and its id is forgotten.
|
||||
"""
|
||||
as_utf8 = _parse(raw, "utf-8")
|
||||
# A record that reads as UTF-8 needs no second reading: re-parsing cost 2.8x on a
|
||||
# 76 MB shard, and these reach gigabytes. Only a dict, since key lookup falls
|
||||
# through to the codepage when UTF-8 yields none.
|
||||
if isinstance(as_utf8, dict):
|
||||
return _Reading(as_utf8, None)
|
||||
for encoding in (codepage, "latin-1", *_DOUBLE_BYTE_ENCODINGS):
|
||||
if not encoding:
|
||||
continue
|
||||
as_legacy = _parse(raw, encoding)
|
||||
if as_legacy is not None:
|
||||
return _Reading(as_utf8, as_legacy)
|
||||
return _Reading(as_utf8, None)
|
||||
|
||||
|
||||
class _Scan(NamedTuple):
|
||||
"""What a pass over an existing shard established about it."""
|
||||
|
||||
legacy: bool # enough evidence to trust the codepage reading's keys
|
||||
readable: bool
|
||||
saw_non_ascii: bool # some line's meaning depends on the encoding
|
||||
utf8_keys: set # keys from lines UTF-8 could read
|
||||
legacy_keys: set # keys only the codepage reading yields
|
||||
|
||||
|
||||
class StateStore:
|
||||
|
|
@ -18,12 +101,19 @@ class StateStore:
|
|||
self.path.parent.mkdir(parents = True, exist_ok = True)
|
||||
self._lock = threading.Lock()
|
||||
self._data: Dict[str, Any] = {}
|
||||
# Read whole, and UTF-8 only unlike the shards below: a checkpoint holds
|
||||
# nothing but base64 cursors and booleans, so a codepage retry could only ever
|
||||
# add non-ASCII. That would resume on a mojibaked cursor, which GitHub rejects
|
||||
# with INVALID_CURSOR_ARGUMENTS, and the empty page it returns marks the stream
|
||||
# done and skips the rest for good. Dropping a damaged checkpoint re-scrapes
|
||||
# from the first page, which the writers dedup.
|
||||
if self.path.exists():
|
||||
try:
|
||||
with self.path.open(encoding = "utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
except Exception:
|
||||
self._data = {}
|
||||
raw = self.path.read_bytes()
|
||||
except OSError:
|
||||
raw = b""
|
||||
data = _parse(raw, "utf-8")
|
||||
self._data = data if isinstance(data, dict) else {}
|
||||
|
||||
def get(
|
||||
self,
|
||||
|
|
@ -63,24 +153,83 @@ class JsonlWriter:
|
|||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents = True, exist_ok = True)
|
||||
self._lock = threading.Lock()
|
||||
self._fh = self.path.open("a", buffering = 1, encoding = "utf-8")
|
||||
self._count_seen_keys: set[str] = set()
|
||||
# Preload seen keys for dedup across resumes
|
||||
self._codepage = _locale_encoding()
|
||||
self._ensure_ascii = False
|
||||
encoding = "utf-8"
|
||||
if self.path.exists() and self.path.stat().st_size > 0:
|
||||
try:
|
||||
# No guess is safe for a file an older build wrote in the
|
||||
# operator's locale, so read past whatever will not decode.
|
||||
with self.path.open(encoding = "utf-8", errors = "replace") as f:
|
||||
for line in f:
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
k = self._key(obj)
|
||||
if k is not None:
|
||||
self._count_seen_keys.add(k)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
scan = self._scan_existing()
|
||||
self._count_seen_keys = scan.utf8_keys
|
||||
if scan.legacy:
|
||||
self._count_seen_keys |= scan.legacy_keys
|
||||
if scan.saw_non_ascii or not scan.readable:
|
||||
# Never convert: the writing encoding is unrecoverable and guessing
|
||||
# mojibakes the records. Pure ASCII appends store identically under
|
||||
# every codepage, and json.loads turns the \uXXXX escapes back.
|
||||
encoding = "ascii"
|
||||
self._ensure_ascii = True
|
||||
self._fh = self.path.open("a", buffering = 1, encoding = encoding, errors = "strict")
|
||||
|
||||
def _scan_existing(self) -> _Scan:
|
||||
"""Read the shard once to recover dedup keys and judge its encoding.
|
||||
|
||||
Line by line: these shards reach gigabytes on a large scrape, so neither
|
||||
the bytes nor the decoded text are held whole.
|
||||
|
||||
The verdict weighs the whole file. Each line with non-ASCII bytes votes:
|
||||
one that parses only under the codepage is evidence of a legacy shard,
|
||||
one that parses as UTF-8 is evidence against, since arbitrary codepage
|
||||
text almost never forms valid multibyte UTF-8. A single corrupt byte in
|
||||
a healthy shard therefore cannot outvote the records around it, and a
|
||||
genuinely legacy shard has a legacy vote on every line that carries an
|
||||
umlaut.
|
||||
|
||||
More than one such line is required, because a single one is genuinely
|
||||
undecidable: a legacy record holding one accented character and an ASCII
|
||||
record holding one stray byte are the same shape. Reading it as damage
|
||||
risks a duplicate; reading it as legacy marks an unreadable record seen
|
||||
and blocks the retry that would replace it, losing it for good. Only one
|
||||
of those is recoverable.
|
||||
|
||||
The verdict only picks which reading supplies the dedup keys. The file
|
||||
itself is never rewritten either way, so a wrong answer costs at most a
|
||||
duplicate, never a corrupted record.
|
||||
"""
|
||||
legacy_votes = 0
|
||||
utf8_votes = 0
|
||||
saw_non_ascii = False
|
||||
utf8_keys: set[str] = set()
|
||||
legacy_keys: set[str] = set()
|
||||
try:
|
||||
with self.path.open("rb") as handle:
|
||||
for raw in handle:
|
||||
line = raw.strip()
|
||||
reading = _read_line(line, self._codepage)
|
||||
# ASCII reads the same everywhere: no vote, no constraint.
|
||||
if not line.isascii():
|
||||
saw_non_ascii = True
|
||||
if reading.as_utf8 is None and reading.as_legacy is not None:
|
||||
legacy_votes += 1
|
||||
elif reading.as_utf8 is not None:
|
||||
utf8_votes += 1
|
||||
# Kept apart so a damaged line does not block its own retry.
|
||||
if isinstance(reading.as_utf8, dict):
|
||||
key = self._key(reading.as_utf8)
|
||||
if key is not None:
|
||||
utf8_keys.add(key)
|
||||
elif isinstance(reading.as_legacy, dict):
|
||||
key = self._key(reading.as_legacy)
|
||||
if key is not None:
|
||||
legacy_keys.add(key)
|
||||
except OSError:
|
||||
return _Scan(False, False, False, utf8_keys, legacy_keys)
|
||||
return _Scan(
|
||||
legacy_votes > 1 and legacy_votes > utf8_votes,
|
||||
True,
|
||||
saw_non_ascii,
|
||||
utf8_keys,
|
||||
legacy_keys,
|
||||
)
|
||||
|
||||
def _key(self, obj: dict) -> str | None:
|
||||
for k in ("id", "node_id", "number", "sha", "url"):
|
||||
|
|
@ -99,7 +248,7 @@ class JsonlWriter:
|
|||
return False
|
||||
if k is not None:
|
||||
self._count_seen_keys.add(k)
|
||||
self._fh.write(json.dumps(obj, default = str, ensure_ascii = False))
|
||||
self._fh.write(json.dumps(obj, default = str, ensure_ascii = self._ensure_ascii))
|
||||
self._fh.write("\n")
|
||||
self._fh.flush()
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ class UnstructuredSeedReader(SeedReader[UnstructuredSeedSource]):
|
|||
meta = json_mod.loads(meta_path.read_text(encoding = "utf-8"))
|
||||
orig_name = meta.get("original_filename", path_obj.name)
|
||||
except (json_mod.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
# Undecodable metadata is as malformed as invalid JSON, so
|
||||
# fall back to the file's own name rather than abort the seed.
|
||||
pass
|
||||
file_entries.append((path_obj, orig_name))
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from auth import storage, hashing
|
|||
from auth.authentication import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_current_credential,
|
||||
get_current_subject,
|
||||
get_current_subject_allow_password_change,
|
||||
refresh_access_token,
|
||||
|
|
@ -399,7 +400,7 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
|
|||
detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}",
|
||||
)
|
||||
|
||||
salt, pwd_hash, _jwt_secret, must_change_password = record
|
||||
salt, pwd_hash, jwt_secret, must_change_password = record
|
||||
if not hashing.verify_password(payload.password, salt, pwd_hash):
|
||||
_record_login_failure(key)
|
||||
raise HTTPException(
|
||||
|
|
@ -409,8 +410,10 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
|
|||
|
||||
_clear_login_bucket(key)
|
||||
_clear_login_bucket(unknown_key)
|
||||
access_token = create_access_token(subject = payload.username)
|
||||
refresh_token = create_refresh_token(subject = payload.username)
|
||||
# Issue against the credential version just verified, not whatever is in the DB
|
||||
# now: a concurrent reset-password must not hand this login a post-reset session.
|
||||
access_token = create_access_token(subject = payload.username, secret = jwt_secret)
|
||||
refresh_token = create_refresh_token(subject = payload.username, secret = jwt_secret)
|
||||
return Token(
|
||||
access_token = access_token,
|
||||
refresh_token = refresh_token,
|
||||
|
|
@ -438,16 +441,17 @@ async def logout(
|
|||
@router.post("/desktop-login", response_model = Token)
|
||||
async def desktop_login(payload: DesktopLoginRequest) -> Token:
|
||||
"""Exchange a local desktop secret for normal admin-subject tokens."""
|
||||
username = storage.validate_desktop_secret(payload.secret)
|
||||
if username is None:
|
||||
verified = storage.validate_desktop_secret_with_credential(payload.secret)
|
||||
if verified is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Desktop authentication failed",
|
||||
)
|
||||
username, jwt_secret = verified
|
||||
|
||||
return Token(
|
||||
access_token = create_access_token(subject = username, desktop = True),
|
||||
refresh_token = create_refresh_token(subject = username, desktop = True),
|
||||
access_token = create_access_token(subject = username, desktop = True, secret = jwt_secret),
|
||||
refresh_token = create_refresh_token(subject = username, desktop = True, secret = jwt_secret),
|
||||
token_type = "bearer",
|
||||
must_change_password = False,
|
||||
)
|
||||
|
|
@ -462,9 +466,11 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
|
|||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid or expired refresh token",
|
||||
)
|
||||
username, is_desktop = consumed
|
||||
new_access_token = create_access_token(subject = username, desktop = is_desktop)
|
||||
new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop)
|
||||
username, is_desktop, jwt_secret = consumed
|
||||
new_access_token = create_access_token(subject = username, desktop = is_desktop, secret = jwt_secret)
|
||||
new_refresh_token = create_refresh_token(
|
||||
subject = username, desktop = is_desktop, secret = jwt_secret
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token = new_access_token,
|
||||
|
|
@ -507,13 +513,25 @@ async def change_password(
|
|||
|
||||
# Single transaction: a separate refresh-token purge could fail after the
|
||||
# password commit, leaving pre-change tokens able to mint access tokens.
|
||||
storage.update_password(current_subject, payload.new_password, revoke_refresh_tokens = True)
|
||||
# Conditional on the hash just verified: a reset-password that landed while
|
||||
# this request was in flight must not be overwritten by it.
|
||||
new_secret = storage.update_password(
|
||||
current_subject,
|
||||
payload.new_password,
|
||||
revoke_refresh_tokens = True,
|
||||
expect_password_hash = pwd_hash,
|
||||
)
|
||||
if new_secret is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_409_CONFLICT,
|
||||
detail = "The password changed while this request was in flight. Sign in again.",
|
||||
)
|
||||
try:
|
||||
request.app.state.bootstrap_password = None
|
||||
except AttributeError:
|
||||
pass
|
||||
access_token = create_access_token(subject = current_subject)
|
||||
refresh_token = create_refresh_token(subject = current_subject)
|
||||
access_token = create_access_token(subject = current_subject, secret = new_secret)
|
||||
refresh_token = create_refresh_token(subject = current_subject, secret = new_secret)
|
||||
return Token(
|
||||
access_token = access_token,
|
||||
refresh_token = refresh_token,
|
||||
|
|
@ -541,20 +559,28 @@ def _row_to_api_key_response(row: dict) -> ApiKeyResponse:
|
|||
|
||||
@router.post("/api-keys", response_model = CreateApiKeyResponse)
|
||||
async def create_api_key(
|
||||
payload: CreateApiKeyRequest, current_subject: str = Depends(get_current_subject)
|
||||
payload: CreateApiKeyRequest, credential: tuple = Depends(get_current_credential)
|
||||
) -> CreateApiKeyResponse:
|
||||
"""Create a new API key. The raw key is returned once and cannot be retrieved later."""
|
||||
current_subject, generation = credential
|
||||
expires_at = None
|
||||
if payload.expires_in_days is not None:
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days)
|
||||
).isoformat()
|
||||
|
||||
raw_key, row = storage.create_api_key(
|
||||
username = current_subject,
|
||||
name = payload.name,
|
||||
expires_at = expires_at,
|
||||
)
|
||||
try:
|
||||
raw_key, row = storage.create_api_key(
|
||||
username = current_subject,
|
||||
name = payload.name,
|
||||
expires_at = expires_at,
|
||||
expect_gen = generation,
|
||||
)
|
||||
except storage.CredentialRotated:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid or expired token",
|
||||
)
|
||||
return CreateApiKeyResponse(
|
||||
key = raw_key,
|
||||
api_key = _row_to_api_key_response(row),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ from datetime import datetime, timedelta, timezone
|
|||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from auth.authentication import get_current_credential
|
||||
from auth.storage import CredentialRotated
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
|
@ -257,7 +260,11 @@ def _inject_local_structured_response_format(
|
|||
model_configs.extend(new_configs)
|
||||
|
||||
|
||||
def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optional[int]:
|
||||
def _inject_local_providers(
|
||||
recipe: dict[str, Any],
|
||||
request: Request,
|
||||
expect_gen: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""Mutate recipe in-place: point is_local providers at this server and mint
|
||||
a short-lived internal sk-unsloth-* key for workflow auth.
|
||||
|
||||
|
|
@ -313,6 +320,7 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
|
|||
name = "data-recipe workflow",
|
||||
expires_at = expires_at,
|
||||
internal = True,
|
||||
expect_gen = expect_gen,
|
||||
)
|
||||
internal_key_id = int(row["id"])
|
||||
|
||||
|
|
@ -375,7 +383,11 @@ def _normalize_run_name(value: Any) -> str | None:
|
|||
|
||||
|
||||
@router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse)
|
||||
def create_job(payload: RecipePayload, request: Request):
|
||||
def create_job(
|
||||
payload: RecipePayload,
|
||||
request: Request,
|
||||
credential: tuple = Depends(get_current_credential),
|
||||
):
|
||||
recipe = payload.recipe
|
||||
if not recipe.get("columns"):
|
||||
raise HTTPException(status_code = 400, detail = "Recipe must include columns.")
|
||||
|
|
@ -406,7 +418,11 @@ def create_job(payload: RecipePayload, request: Request):
|
|||
) from exc
|
||||
|
||||
try:
|
||||
internal_api_key_id = _inject_local_providers(recipe, request)
|
||||
internal_api_key_id = _inject_local_providers(recipe, request, credential[1])
|
||||
except CredentialRotated as exc:
|
||||
# A reset-password landed after this request authenticated; the workflow key
|
||||
# is refused, so answer like any other revoked credential rather than 500.
|
||||
raise HTTPException(status_code = 401, detail = "Invalid or expired token") from exc
|
||||
except ValueError as exc:
|
||||
raise log_and_http_error(
|
||||
exc,
|
||||
|
|
|
|||
|
|
@ -4451,7 +4451,7 @@ def _effective_load_in_4bit(config: ModelConfig, requested: bool) -> bool:
|
|||
if not adapter_cfg_path.exists():
|
||||
return load_in_4bit
|
||||
try:
|
||||
with open(adapter_cfg_path, encoding = "utf-8") as f:
|
||||
with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
|
||||
adapter_cfg = json.load(f)
|
||||
if not isinstance(adapter_cfg, dict): # malformed -> keep requested
|
||||
return load_in_4bit
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
|
|||
stem_hash = hashlib.sha256(manifest_key.encode()).hexdigest()[:10]
|
||||
|
||||
try:
|
||||
manifest = json.loads(tag_file.read_text(encoding = "utf-8"))
|
||||
manifest = json.loads(tag_file.read_text(encoding = "utf-8-sig"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
||||
logger.debug(
|
||||
"Skipping unreadable/invalid Ollama manifest %s: %s",
|
||||
|
|
@ -738,7 +738,7 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
|
|||
config_blob = blobs_dir / config_digest.replace(":", "-")
|
||||
if config_blob.is_file():
|
||||
try:
|
||||
cfg = json.loads(config_blob.read_text(encoding = "utf-8"))
|
||||
cfg = json.loads(config_blob.read_text(encoding = "utf-8-sig"))
|
||||
model_type = cfg.get("model_type", "")
|
||||
file_type = cfg.get("file_type", "")
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
||||
|
|
@ -1042,7 +1042,7 @@ def _dir_has_downloaded_model(directory: Path, max_entries: int = 4000) -> bool:
|
|||
if not m.is_file():
|
||||
continue
|
||||
try:
|
||||
manifest = json.loads(m.read_text(encoding = "utf-8"))
|
||||
manifest = json.loads(m.read_text(encoding = "utf-8-sig"))
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
continue
|
||||
for layer in manifest.get("layers") or []:
|
||||
|
|
@ -3360,6 +3360,8 @@ def _wsl_reveal_in_explorer(path: Path) -> bool:
|
|||
["wslpath", "-w", str(path)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
check = True,
|
||||
timeout = 10,
|
||||
).stdout.strip()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import os
|
|||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from typing import NoReturn, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
def _fix_torch_cuda_ld_path():
|
||||
|
|
@ -689,6 +689,33 @@ def _get_pid_on_port(port: int) -> "tuple[int, str] | None":
|
|||
return None
|
||||
|
||||
|
||||
def _bind_addresses(host: str, port: int) -> "set[str]":
|
||||
"""Every address *host* resolves to. `localhost` is both 127.0.0.1 and ::1, and
|
||||
recording only the first lets a later launch on the other one miss us."""
|
||||
import socket
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return {host}
|
||||
return {info[4][0] for info in infos} or {host}
|
||||
|
||||
|
||||
def _addresses_collide(recorded: "str | None", host: str, port: int) -> bool:
|
||||
"""Would a server bound to *recorded* block a bind to *host*?
|
||||
|
||||
*recorded* may list several addresses. Unknown or wildcard on either side
|
||||
collides: refusing with a clear message beats silently starting a duplicate.
|
||||
"""
|
||||
wildcards = ("0.0.0.0", "::", "")
|
||||
if not recorded or host in wildcards:
|
||||
return True
|
||||
listed = {a.strip() for a in recorded.split(",") if a.strip()}
|
||||
if not listed or listed & set(wildcards):
|
||||
return True
|
||||
return bool(listed & _bind_addresses(host, port))
|
||||
|
||||
|
||||
def _is_port_free(host: str, port: int) -> bool:
|
||||
"""Check if a port is available for binding.
|
||||
|
||||
|
|
@ -733,18 +760,213 @@ def _find_free_port(
|
|||
host: str,
|
||||
start: int,
|
||||
max_attempts: int = 20,
|
||||
avoid_own_studio: bool = False,
|
||||
) -> int:
|
||||
"""Find a free port from `start`, trying up to max_attempts ports."""
|
||||
"""Find a free port from `start`, trying up to max_attempts ports.
|
||||
|
||||
``avoid_own_studio`` aborts rather than skipping past one of our own servers
|
||||
in the fallback range, which would start a duplicate on a later port.
|
||||
"""
|
||||
for offset in range(max_attempts):
|
||||
candidate = start + offset
|
||||
if _is_port_free(host, candidate):
|
||||
return candidate
|
||||
if avoid_own_studio:
|
||||
own = _own_studio_on_port(candidate, host)
|
||||
if own is not None:
|
||||
_abort_already_running(own, candidate)
|
||||
raise RuntimeError(f"Could not find a free port in range {start}-{start + max_attempts - 1}")
|
||||
|
||||
|
||||
from utils.paths.storage_roots import studio_root as _studio_root
|
||||
|
||||
# Legacy single-instance file; still read so `stop` finds an older build's server.
|
||||
_PID_FILE = _studio_root() / "studio.pid"
|
||||
PID_FILE_GLOB = "studio-*.pid"
|
||||
|
||||
|
||||
def _pid_file_for_port(port: int) -> Path:
|
||||
# PID in the name: 127.0.0.1 and ::1 can share a port, and one file per port
|
||||
# would let the second bind overwrite the first.
|
||||
return _studio_root() / f"studio-{port}-{os.getpid()}.pid"
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
import psutil
|
||||
return psutil.pid_exists(pid)
|
||||
except ImportError:
|
||||
pass
|
||||
if sys.platform == "win32":
|
||||
# os.kill(pid, 0) raises OSError for every pid on Windows, so tasklist is
|
||||
# the only usable probe here.
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {int(pid)}", "/NH", "/FO", "CSV"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 10,
|
||||
).stdout
|
||||
except Exception:
|
||||
# Unconfirmed means keep, matching the CLI's _pid_alive. Pruning a
|
||||
# live server's record is what lets the next launch fall back past it
|
||||
# and strand it, which is the bug this file exists to fix. A stale
|
||||
# record instead costs one clear "already running" message.
|
||||
return True
|
||||
return f'"{int(pid)}"' in out
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _process_create_time(pid: int) -> "float | None":
|
||||
try:
|
||||
import psutil
|
||||
return psutil.Process(pid).create_time()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_pid_record(path: Path) -> "tuple[int, float | None, str | None] | None":
|
||||
"""Parse ``pid`` / optional ``create_time`` / optional bind address."""
|
||||
try:
|
||||
lines = path.read_text(encoding = "utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
if not lines or not lines[0].strip().isdigit():
|
||||
return None
|
||||
try:
|
||||
# isdigit() is not enough: a superscript two passes it but int() rejects it.
|
||||
pid = int(lines[0].strip())
|
||||
except ValueError:
|
||||
return None
|
||||
# kill(0) signals our whole process group; kill(1) is init. Never either.
|
||||
if pid < 2:
|
||||
return None
|
||||
created = None
|
||||
if len(lines) > 1:
|
||||
try:
|
||||
created = float(lines[1].strip())
|
||||
except ValueError:
|
||||
created = None
|
||||
address = lines[2].strip() if len(lines) > 2 and lines[2].strip() else None
|
||||
return pid, created, address
|
||||
|
||||
|
||||
def _pid_is_studio_backend(pid: int, created_times: "Sequence[float | None]" = ()) -> bool:
|
||||
"""False only when a recorded start time proves this PID is a different process.
|
||||
|
||||
Any recorded time matching is enough -- a stale record must not veto a live
|
||||
server that reused the PID. Untimed records cannot be checked at all, so they
|
||||
are trusted: a legacy `python run.py` has no telltale argv, and guessing from
|
||||
the command line rejected real servers.
|
||||
"""
|
||||
known = [c for c in created_times if c is not None]
|
||||
if not known:
|
||||
return True
|
||||
actual = _process_create_time(pid)
|
||||
if actual is None:
|
||||
return True
|
||||
return any(abs(actual - c) < 1.0 for c in known)
|
||||
|
||||
|
||||
def _own_studio_on_port(port: int, host: str) -> "int | None":
|
||||
"""PID of one of our own servers already bound to *port* for *host*.
|
||||
|
||||
Reads our own records rather than enumerating listeners: psutil is optional,
|
||||
and without it a listener scan finds nothing and we silently start a duplicate.
|
||||
"""
|
||||
try:
|
||||
paths = list(_studio_root().glob(f"studio-{port}-*.pid"))
|
||||
except OSError:
|
||||
return None
|
||||
for path in paths:
|
||||
record = _read_pid_record(path)
|
||||
if record is None:
|
||||
continue
|
||||
pid, created, address = record
|
||||
if not _pid_alive(pid):
|
||||
# Pruning is a courtesy; an undeletable record must not abort startup.
|
||||
try:
|
||||
path.unlink(missing_ok = True)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if not _addresses_collide(address, host, port):
|
||||
continue
|
||||
if _pid_is_studio_backend(pid, [created]):
|
||||
return pid
|
||||
return _legacy_studio_on_port(port)
|
||||
|
||||
|
||||
def _legacy_studio_on_port(port: int) -> "int | None":
|
||||
"""A pre-upgrade server recorded only its PID, so match it to the listener.
|
||||
|
||||
Falling back past one leaves it running while `_write_pid_file` overwrites the
|
||||
only record of it. When the listener is unknowable, assume it is ours.
|
||||
"""
|
||||
record = _read_pid_record(_PID_FILE)
|
||||
if record is None:
|
||||
return None
|
||||
pid, created, _address = record
|
||||
if not _pid_alive(pid):
|
||||
return None
|
||||
# A current build writes a per-port file too, so its port is already known --
|
||||
# and this port's records were just checked. Only count a record that still
|
||||
# matches the live process: a stale one may just share a reused PID.
|
||||
for other in _per_port_records():
|
||||
if other and other[0] == pid and _pid_is_studio_backend(pid, [other[1]]):
|
||||
return None
|
||||
blocker = _get_pid_on_port(port)
|
||||
if blocker is not None and blocker[0] != pid:
|
||||
return None
|
||||
if not _pid_is_studio_backend(pid, [created]):
|
||||
return None
|
||||
return pid
|
||||
|
||||
|
||||
def _per_port_records() -> "list[tuple[int, float | None, str | None] | None]":
|
||||
try:
|
||||
return [_read_pid_record(p) for p in _studio_root().glob(PID_FILE_GLOB)]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _resolve_port(
|
||||
host: str,
|
||||
port: int,
|
||||
avoid_own_studio: bool = True,
|
||||
) -> int:
|
||||
"""The requested port, or the next free one.
|
||||
|
||||
With ``avoid_own_studio`` this aborts rather than falling back past one of our
|
||||
own servers, on *port* itself or anywhere in the fallback range: skipping one
|
||||
is what strands it. Callers that read the bound port back pass False and keep
|
||||
the plain fallback.
|
||||
"""
|
||||
if _is_port_free(host, port):
|
||||
return port
|
||||
if avoid_own_studio:
|
||||
own = _own_studio_on_port(port, host)
|
||||
if own is not None:
|
||||
_abort_already_running(own, port)
|
||||
return _find_free_port(host, port + 1, avoid_own_studio = avoid_own_studio)
|
||||
|
||||
|
||||
def _abort_already_running(pid: int, port: int) -> "NoReturn":
|
||||
print(
|
||||
f"Error: Unsloth Studio is already running on port {port} (PID {pid}). Run "
|
||||
"`unsloth studio stop` first, or start this one on a different --port.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Direct backend launches bypass the CLI's env re-export; do it here for
|
||||
# real custom roots so unsloth-zoo's import-time LLAMA_CPP_DEFAULT_DIR
|
||||
|
|
@ -770,23 +992,101 @@ if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
|
|||
os.environ.setdefault("UNSLOTH_IS_PRESENT", "1")
|
||||
|
||||
|
||||
def _write_pid_file():
|
||||
"""Write the current process PID to the studio PID file."""
|
||||
_OWN_PID_FILE: "Path | None" = None
|
||||
|
||||
|
||||
def _write_pid_file(port: int, host: str = ""):
|
||||
"""Record this PID under its own port so `stop` can find every server."""
|
||||
global _OWN_PID_FILE
|
||||
path = _pid_file_for_port(port)
|
||||
try:
|
||||
_PID_FILE.parent.mkdir(parents = True, exist_ok = True)
|
||||
_PID_FILE.write_text(str(os.getpid()), encoding = "utf-8")
|
||||
path.parent.mkdir(parents = True, exist_ok = True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
# Start time pins the record to this process; the bind address tells a
|
||||
# later launch whether this server would actually block it.
|
||||
created = _process_create_time(os.getpid())
|
||||
address = ",".join(sorted(_bind_addresses(host, port))) if host else ""
|
||||
body = f"{os.getpid()}\n{'' if created is None else repr(created)}\n{address}"
|
||||
# Write-then-rename: `stop` reads these concurrently, and a reader that
|
||||
# catches the truncate window sees a corrupt record and deletes it.
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
try:
|
||||
tmp.write_text(body, encoding = "utf-8")
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
# A failed replace would otherwise leave the scratch file behind. It
|
||||
# does not end in .pid, so no glob picks it up either way.
|
||||
tmp.unlink(missing_ok = True)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
_OWN_PID_FILE = path
|
||||
# An older CLI's `stop` only reads this one, and expects a bare PID. Written
|
||||
# independently of the per-port record: if that one failed, this is the only
|
||||
# thing keeping the server stoppable at all.
|
||||
try:
|
||||
# Never take it from a server that is still running. A pre-upgrade server
|
||||
# is recorded here and nowhere else, so overwriting its entry is exactly
|
||||
# what strands it -- the orphan this file exists to prevent.
|
||||
prior = _read_pid_record(_PID_FILE) if _PID_FILE.is_file() else None
|
||||
if prior is None or prior[0] == os.getpid() or not _pid_alive(prior[0]):
|
||||
_PID_FILE.write_text(str(os.getpid()), encoding = "utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _remove_pid_file():
|
||||
"""Remove the PID file if it belongs to this process."""
|
||||
def _legacy_heir() -> "int | None":
|
||||
"""Another live server's PID, to hand the legacy studio.pid over to.
|
||||
|
||||
Only one server owns studio.pid at a time, so its exit would otherwise drop
|
||||
the single record an older CLI can read, stranding any sibling that is still
|
||||
serving.
|
||||
"""
|
||||
try:
|
||||
if _PID_FILE.is_file():
|
||||
stored = _PID_FILE.read_text(encoding = "utf-8").strip()
|
||||
if stored == str(os.getpid()):
|
||||
paths = sorted(_studio_root().glob(PID_FILE_GLOB))
|
||||
except OSError:
|
||||
return None
|
||||
for path in paths:
|
||||
if _OWN_PID_FILE is not None and path == _OWN_PID_FILE:
|
||||
continue
|
||||
record = _read_pid_record(path)
|
||||
if record is None or record[0] == os.getpid():
|
||||
continue
|
||||
if _pid_alive(record[0]) and _pid_is_studio_backend(record[0], [record[1]]):
|
||||
return record[0]
|
||||
return None
|
||||
|
||||
|
||||
def _remove_pid_file():
|
||||
"""Remove the PID files that belong to this process.
|
||||
|
||||
_PID_FILE is checked even when the per-port record was never written, since
|
||||
_write_pid_file writes the two independently.
|
||||
"""
|
||||
# Nothing here may raise: _graceful_shutdown calls this at the end, and an
|
||||
# unreadable or undeletable record must not abandon the rest of the exit
|
||||
# path. _read_pid_record already swallows OSError/UnicodeDecodeError.
|
||||
if _OWN_PID_FILE is not None:
|
||||
try:
|
||||
record = _read_pid_record(_OWN_PID_FILE) if _OWN_PID_FILE.is_file() else None
|
||||
if record is not None and record[0] == os.getpid():
|
||||
_OWN_PID_FILE.unlink(missing_ok = True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
record = _read_pid_record(_PID_FILE) if _PID_FILE.is_file() else None
|
||||
if record is not None and record[0] == os.getpid():
|
||||
# Hand the pointer to a live sibling rather than deleting it. An
|
||||
# older CLI reads only this file, so dropping it while another
|
||||
# server is still up leaves that server unstoppable.
|
||||
heir = _legacy_heir()
|
||||
if heir is None:
|
||||
_PID_FILE.unlink(missing_ok = True)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
else:
|
||||
_PID_FILE.write_text(str(heir), encoding = "utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -796,7 +1096,6 @@ def _graceful_shutdown(server = None):
|
|||
Called from signal handlers to clean up children before exit. Critical on
|
||||
Windows where atexit handlers are unreliable after Ctrl+C.
|
||||
"""
|
||||
_remove_pid_file()
|
||||
logger.info("Graceful shutdown initiated -- cleaning up subprocesses...")
|
||||
|
||||
# 1. Shut down uvicorn (releases the listening socket).
|
||||
|
|
@ -849,6 +1148,9 @@ def _graceful_shutdown(server = None):
|
|||
except Exception as e:
|
||||
logger.warning("Error in process-lifetime sweep: %s", e)
|
||||
|
||||
# Last: while cleanup runs the server is still alive, and dropping the record
|
||||
# early leaves a retried `stop` or a new launch unable to find it.
|
||||
_remove_pid_file()
|
||||
logger.info("All subprocesses cleaned up")
|
||||
|
||||
|
||||
|
|
@ -1326,7 +1628,8 @@ def _apply_supplied_password(password_value: "Optional[str]") -> None:
|
|||
if not _auth_storage.requires_password_change(_admin):
|
||||
print(
|
||||
"Error: an Unsloth admin password is already set; --password only sets "
|
||||
"the initial password. Run `unsloth studio reset-password` first.",
|
||||
"the initial password. Change it in the UI, or run `unsloth studio "
|
||||
"reset-password` for a new one.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
|
|
@ -1397,6 +1700,7 @@ def run_server(
|
|||
enable_tools: "Optional[bool]" = None,
|
||||
password: "Optional[str]" = None,
|
||||
emit_tauri_port: bool = True,
|
||||
abort_if_own_studio: "Optional[bool]" = None,
|
||||
):
|
||||
"""
|
||||
Start the FastAPI server.
|
||||
|
|
@ -1530,10 +1834,16 @@ def run_server(
|
|||
)
|
||||
|
||||
# Auto-find a free port if the requested one is in use.
|
||||
if not _is_port_free(host, port):
|
||||
original_port = port
|
||||
blocker = _get_pid_on_port(port)
|
||||
port = _find_free_port(host, port + 1)
|
||||
original_port = port
|
||||
# Refusing rather than falling back is for callers that cannot follow us to
|
||||
# the new port. `studio run` reads app.state.server_port back and the desktop
|
||||
# app reads TAURI_PORT, so both should keep the plain fallback; only the
|
||||
# bare launch, which has nothing but the banner, benefits from the refusal.
|
||||
if abort_if_own_studio is None:
|
||||
abort_if_own_studio = not api_only
|
||||
port = _resolve_port(host, port, avoid_own_studio = abort_if_own_studio)
|
||||
if port != original_port:
|
||||
blocker = _get_pid_on_port(original_port)
|
||||
if not silent:
|
||||
print("")
|
||||
print("=" * 50)
|
||||
|
|
@ -1731,7 +2041,7 @@ def run_server(
|
|||
(time.perf_counter() - boot_started) * 1000,
|
||||
)
|
||||
|
||||
_write_pid_file()
|
||||
_write_pid_file(port, host)
|
||||
import atexit
|
||||
|
||||
atexit.register(_remove_pid_file)
|
||||
|
|
|
|||
|
|
@ -67,9 +67,11 @@ def test_rejects_password_containing_spaces(_user):
|
|||
|
||||
|
||||
def test_allows_password_without_spaces(_user, monkeypatch):
|
||||
monkeypatch.setattr(auth_routes.storage, "update_password", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(auth_routes, "create_access_token", lambda subject: "at")
|
||||
monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject: "rt")
|
||||
monkeypatch.setattr(
|
||||
auth_routes.storage, "update_password", lambda *args, **kwargs: "rotated-secret"
|
||||
)
|
||||
monkeypatch.setattr(auth_routes, "create_access_token", lambda subject, **kwargs: "at")
|
||||
monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject, **kwargs: "rt")
|
||||
token = _change("correct-horse-battery")
|
||||
assert token.access_token == "at"
|
||||
assert token.must_change_password is False
|
||||
|
|
|
|||
195
studio/backend/tests/test_chat_text_encoding.py
Normal file
195
studio/backend/tests/test_chat_text_encoding.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Model text stays intact when it carries non-ASCII.
|
||||
|
||||
``open()`` and ``Path.read_text()`` fall back to ``locale.getencoding()`` when
|
||||
no ``encoding`` is passed. On Windows that is the ANSI codepage, not UTF-8, so
|
||||
a chat template or model config holding ``ä ö ü → 世`` mojibakes or raises
|
||||
``UnicodeDecodeError``. These files are UTF-8, so the reads must say so.
|
||||
|
||||
Each fixture writes raw UTF-8 (``ensure_ascii = False``), matching what
|
||||
Hugging Face actually ships, rather than ASCII ``\\uXXXX`` escapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_config_json_round_trips_non_ascii(tmp_path: Path) -> None:
|
||||
from utils import transformers_version
|
||||
|
||||
name = "Modell für Grüße 世界"
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"model_type": "llama", "_name_or_path": name}, ensure_ascii = False),
|
||||
encoding = "utf-8",
|
||||
)
|
||||
transformers_version._config_json_cache.clear()
|
||||
|
||||
cfg = transformers_version._load_config_json(str(tmp_path))
|
||||
|
||||
assert cfg is not None
|
||||
assert cfg["_name_or_path"] == name
|
||||
|
||||
|
||||
def test_tokenizer_config_round_trips_non_ascii_chat_template(tmp_path: Path) -> None:
|
||||
"""Chat templates commonly hold ``→`` and smart quotes, which cp1252 mangles."""
|
||||
from utils import transformers_version
|
||||
|
||||
template = "{{ '→ Grüße 世界' }}"
|
||||
(tmp_path / "tokenizer_config.json").write_text(
|
||||
json.dumps(
|
||||
{"tokenizer_class": "TokenizersBackend", "chat_template": template},
|
||||
ensure_ascii = False,
|
||||
),
|
||||
encoding = "utf-8",
|
||||
)
|
||||
transformers_version._tokenizer_class_cache.clear()
|
||||
|
||||
assert transformers_version._check_tokenizer_config_needs_v5(str(tmp_path)) is True
|
||||
|
||||
|
||||
def test_config_json_survives_a_utf8_bom(tmp_path: Path) -> None:
|
||||
"""Notepad wrote "UTF-8 with BOM" by default for years, so hand-edited
|
||||
configs on Windows carry one. Plain utf-8 keeps the BOM and json.load then
|
||||
fails on it; utf-8-sig strips it and is identical otherwise."""
|
||||
from utils import transformers_version
|
||||
|
||||
name = "Grüße 世界"
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"model_type": "llama", "_name_or_path": name}, ensure_ascii = False),
|
||||
encoding = "utf-8-sig",
|
||||
)
|
||||
transformers_version._config_json_cache.clear()
|
||||
|
||||
cfg = transformers_version._load_config_json(str(tmp_path))
|
||||
|
||||
assert cfg is not None
|
||||
assert cfg["_name_or_path"] == name
|
||||
|
||||
|
||||
def test_remote_code_scan_reads_non_ascii_sources(tmp_path: Path) -> None:
|
||||
"""A German Windows profile also puts umlauts in the model sources scanned."""
|
||||
from utils.security import remote_code_scan
|
||||
|
||||
source = "# Grüße über Öl\nVALUE = '世界'\n"
|
||||
# newline = "" pins the bytes on disk, so Windows line end translation cannot make the
|
||||
# read back differ by \r. open() because Path.write_text() only grew newline in 3.10.
|
||||
with open(
|
||||
tmp_path / "modeling_custom.py",
|
||||
"w",
|
||||
encoding = "utf-8",
|
||||
newline = "",
|
||||
) as handle:
|
||||
handle.write(source)
|
||||
|
||||
files = remote_code_scan.repo_remote_code_files(str(tmp_path))
|
||||
|
||||
assert files["modeling_custom.py"] == source
|
||||
|
||||
|
||||
def test_model_config_reads_do_not_rely_on_the_locale_encoding(tmp_path: Path) -> None:
|
||||
"""The reads above pass anywhere the locale is already UTF-8, which hides
|
||||
the Windows bug on Linux and macOS. ``-X warn_default_encoding`` makes
|
||||
CPython flag any text I/O that falls back to the locale, so this fails on
|
||||
every platform if an ``encoding`` argument goes missing again."""
|
||||
# The readers swallow exceptions, so record the warnings instead of raising.
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
import sys, warnings
|
||||
sys.path.insert(0, {str(BACKEND_ROOT)!r})
|
||||
from utils import transformers_version
|
||||
|
||||
target = {str(tmp_path)!r}
|
||||
with warnings.catch_warnings(record = True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
transformers_version._config_json_cache.clear()
|
||||
transformers_version._tokenizer_class_cache.clear()
|
||||
assert transformers_version._load_config_json(target) is not None
|
||||
assert transformers_version._check_tokenizer_config_needs_v5(target) is True
|
||||
|
||||
missing = [str(w.message) for w in caught if w.category is EncodingWarning]
|
||||
if missing:
|
||||
sys.exit("text I/O fell back to the locale encoding: " + "; ".join(missing))
|
||||
"""
|
||||
)
|
||||
for name, payload in (
|
||||
("config.json", {"model_type": "llama", "_name_or_path": "Grüße"}),
|
||||
("tokenizer_config.json", {"tokenizer_class": "TokenizersBackend"}),
|
||||
):
|
||||
(tmp_path / name).write_text(json.dumps(payload, ensure_ascii = False), encoding = "utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-X", "warn_default_encoding", "-c", script],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 120,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_utf8_child_env_round_trips_non_ascii(tmp_path: Path) -> None:
|
||||
"""A Python child encodes stdout with its locale unless told otherwise, so
|
||||
reading its pipe as utf-8 needs the child told to emit utf-8."""
|
||||
from utils.child_stdio import utf8_child_env
|
||||
|
||||
payload = "Grüße über Öl → 世界"
|
||||
child = tmp_path / "child.py"
|
||||
child.write_text("import sys\nsys.stdout.write(" + repr(payload) + ")\n", encoding = "utf-8")
|
||||
|
||||
env = utf8_child_env()
|
||||
assert env["PYTHONIOENCODING"] == "utf-8"
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(child)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = env,
|
||||
timeout = 120,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert proc.stdout == payload
|
||||
|
||||
|
||||
def test_python_children_are_told_to_emit_utf8() -> None:
|
||||
"""Any child we decode as utf-8 must also be told to write utf-8, or a
|
||||
cp1252 console silently mangles what it prints."""
|
||||
import ast
|
||||
|
||||
offenders: list[str] = []
|
||||
for path in sorted(BACKEND_ROOT.rglob("*.py")):
|
||||
parts = path.relative_to(BACKEND_ROOT).parts
|
||||
if any(p in ("tests", "node_modules", "plugins", "__pycache__") for p in parts):
|
||||
continue
|
||||
source = path.read_text(encoding = "utf-8")
|
||||
for node in ast.walk(ast.parse(source, filename = str(path))):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not (isinstance(func, ast.Attribute) and func.attr in ("run", "Popen")):
|
||||
continue
|
||||
segment = ast.get_source_segment(source, node) or ""
|
||||
if "sys.executable" not in segment or 'encoding = "utf-8"' not in segment:
|
||||
continue
|
||||
if "utf8_child_env" in segment or "PYTHONIOENCODING" in segment:
|
||||
continue
|
||||
offenders.append(f"{path.name}:{node.lineno}")
|
||||
|
||||
assert not offenders, (
|
||||
"these spawn a Python child and decode it as utf-8 without setting the "
|
||||
"child's own stdio encoding; wrap env in utf8_child_env():\n " + "\n ".join(offenders)
|
||||
)
|
||||
255
studio/backend/tests/test_credential_rotation_race.py
Normal file
255
studio/backend/tests/test_credential_rotation_race.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""A password rotation must not leave a session minted from the replaced credential.
|
||||
|
||||
`unsloth studio reset-password` rotates in place against a live server, so a login
|
||||
can verify the old password, have the rotation land, and only then mint its tokens.
|
||||
Issuance is bound to the credential version that was verified, so such a login gets
|
||||
tokens that are already dead rather than a session that outlives the reset.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from auth import hashing, storage
|
||||
from auth.authentication import ALGORITHM, create_access_token, create_refresh_token
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def isolated_auth_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
||||
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
||||
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin():
|
||||
storage.create_initial_user(
|
||||
username = storage.DEFAULT_ADMIN_USERNAME,
|
||||
password = "old-password-123",
|
||||
jwt_secret = secrets.token_urlsafe(64),
|
||||
)
|
||||
return storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
def _verified_secret(username):
|
||||
return storage.get_user_and_secret(username)[2]
|
||||
|
||||
|
||||
def test_access_token_from_the_replaced_credential_is_rejected(admin):
|
||||
secret = _verified_secret(admin)
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
token = create_access_token(subject = admin, secret = secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(token, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
|
||||
|
||||
def test_refresh_token_from_the_replaced_credential_is_rejected(admin):
|
||||
secret = _verified_secret(admin)
|
||||
|
||||
# Inserted AFTER the rotation's DELETE, so revocation alone cannot catch it.
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
token = create_refresh_token(subject = admin, secret = secret)
|
||||
|
||||
assert storage.verify_refresh_token(token) is None
|
||||
assert storage.consume_refresh_token(token) is None
|
||||
|
||||
|
||||
def test_a_rejected_refresh_token_is_dropped(admin):
|
||||
secret = _verified_secret(admin)
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
token = create_refresh_token(subject = admin, secret = secret)
|
||||
|
||||
storage.verify_refresh_token(token)
|
||||
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
assert conn.execute("SELECT COUNT(*) AS c FROM refresh_tokens").fetchone()["c"] == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_tokens_from_the_current_credential_still_work(admin):
|
||||
secret = _verified_secret(admin)
|
||||
|
||||
access = create_access_token(subject = admin, secret = secret)
|
||||
refresh = create_refresh_token(subject = admin, secret = secret)
|
||||
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) == (admin, False)
|
||||
|
||||
|
||||
def test_refresh_cannot_outlive_a_rotation_it_raced(admin):
|
||||
# /refresh consumes, then mints. A rotation landing in between must not let
|
||||
# the replacement pair be signed with the credential that just replaced it.
|
||||
secret = _verified_secret(admin)
|
||||
token = create_refresh_token(subject = admin, secret = secret)
|
||||
consumed = storage.consume_refresh_token(token)
|
||||
assert consumed is not None
|
||||
_username, _is_desktop, consumed_secret = consumed
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, secret = consumed_secret)
|
||||
refresh = create_refresh_token(subject = admin, secret = consumed_secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) is None
|
||||
|
||||
|
||||
def test_desktop_login_cannot_outlive_a_rotation_it_raced(admin):
|
||||
# The reset deletes the desktop secret, so a desktop-login that validated it
|
||||
# just beforehand must not mint a session that survives.
|
||||
raw = storage.create_desktop_secret()
|
||||
verified = storage.validate_desktop_secret_with_credential(raw)
|
||||
assert verified is not None
|
||||
_username, verified_secret = verified
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, desktop = True, secret = verified_secret)
|
||||
refresh = create_refresh_token(subject = admin, desktop = True, secret = verified_secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) is None
|
||||
|
||||
|
||||
def test_change_password_cannot_overwrite_a_rotation_it_raced(admin):
|
||||
# A change-password that verified the old hash must not clobber a reset that
|
||||
# committed while it was in flight.
|
||||
_salt, verified_hash, _secret, _must_change = storage.get_user_and_secret(admin)
|
||||
|
||||
storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True)
|
||||
|
||||
assert not storage.update_password(
|
||||
admin,
|
||||
"attacker-chosen-000",
|
||||
revoke_refresh_tokens = True,
|
||||
expect_password_hash = verified_hash,
|
||||
)
|
||||
salt, pwd_hash, _s, _m = storage.get_user_and_secret(admin)
|
||||
assert hashing.verify_password("reset-by-the-cli-789", salt, pwd_hash)
|
||||
|
||||
|
||||
def test_api_key_creation_from_a_revoked_credential_is_refused(admin):
|
||||
generation = storage.credential_generation(_verified_secret(admin))
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
storage.create_api_key(username = admin, name = "k", expect_gen = generation)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
assert conn.execute("SELECT COUNT(*) AS c FROM api_keys").fetchone()["c"] == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_api_key_creation_under_the_current_credential_still_works(admin):
|
||||
generation = storage.credential_generation(_verified_secret(admin))
|
||||
|
||||
raw_key, _row = storage.create_api_key(username = admin, name = "k", expect_gen = generation)
|
||||
|
||||
assert storage.validate_api_key(raw_key) == admin
|
||||
|
||||
|
||||
def test_change_password_tokens_are_bound_to_its_own_write(admin):
|
||||
# The tokens returned to a successful change-password must be signed with the
|
||||
# secret that write produced, not whatever a later reset put in the DB.
|
||||
_salt, verified_hash, _secret, _must = storage.get_user_and_secret(admin)
|
||||
new_secret = storage.update_password(
|
||||
admin,
|
||||
"chosen-by-the-user",
|
||||
revoke_refresh_tokens = True,
|
||||
expect_password_hash = verified_hash,
|
||||
)
|
||||
assert new_secret is not None
|
||||
|
||||
storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, secret = new_secret)
|
||||
refresh = create_refresh_token(subject = admin, secret = new_secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) is None
|
||||
|
||||
|
||||
def test_internal_api_key_minting_honours_the_request_generation(admin):
|
||||
generation = storage.credential_generation(_verified_secret(admin))
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
storage.create_api_key(
|
||||
username = admin,
|
||||
name = "data-recipe workflow",
|
||||
internal = True,
|
||||
expect_gen = generation,
|
||||
)
|
||||
|
||||
|
||||
def test_api_key_auth_reports_the_version_the_key_was_valid_under(admin):
|
||||
# The generation must come from the same transaction as the key check, or a
|
||||
# revoked key could hand a route the post-reset generation and mint again.
|
||||
raw, _row = storage.create_api_key(username = admin, name = "agent")
|
||||
verified = storage.validate_api_key_with_credential(raw)
|
||||
assert verified is not None
|
||||
_user, secret = verified
|
||||
generation = storage.credential_generation(secret)
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM api_keys")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert storage.validate_api_key(raw) is None
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
storage.create_api_key(username = admin, name = "after", expect_gen = generation)
|
||||
|
||||
|
||||
def test_consuming_a_legacy_token_reports_the_pre_reset_credential(admin):
|
||||
# An unstamped row has no generation to compare, so consume must read the
|
||||
# credential inside the delete transaction rather than after committing it.
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat()
|
||||
storage.save_refresh_token(token, admin, expires_at, secret_gen = None)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
conn.execute("UPDATE refresh_tokens SET secret_gen = NULL")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
consumed = storage.consume_refresh_token(token)
|
||||
assert consumed is not None
|
||||
_username, _is_desktop, consumed_secret = consumed
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, secret = consumed_secret)
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
|
||||
|
||||
def test_unstamped_legacy_tokens_still_verify(admin):
|
||||
# Rows written before the secret_gen column existed must not log users out.
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat()
|
||||
storage.save_refresh_token(token, admin, expires_at, secret_gen = None)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
conn.execute("UPDATE refresh_tokens SET secret_gen = NULL")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert storage.verify_refresh_token(token) == (admin, False)
|
||||
|
|
@ -134,6 +134,218 @@ def test_ensure_default_admin_loads_existing_bootstrap_after_restart(monkeypatch
|
|||
assert storage.get_bootstrap_password() == bootstrap_pw
|
||||
|
||||
|
||||
def test_bootstrap_password_file_ends_with_a_newline():
|
||||
# Otherwise `cat` welds the passphrase onto the shell prompt.
|
||||
storage.ensure_default_admin()
|
||||
|
||||
# Bytes: read_text would decode CRLF back to "\n" and hide a CR.
|
||||
raw = storage._BOOTSTRAP_PW_PATH.read_bytes()
|
||||
|
||||
assert raw == storage.get_bootstrap_password().encode("utf-8") + b"\n"
|
||||
|
||||
|
||||
def test_bootstrap_password_round_trips_across_a_restart_with_the_newline():
|
||||
storage.ensure_default_admin()
|
||||
original = storage.get_bootstrap_password()
|
||||
|
||||
storage._bootstrap_password = None
|
||||
|
||||
assert storage.generate_bootstrap_password() == original
|
||||
|
||||
|
||||
def test_upgrade_normalises_the_bootstrap_file():
|
||||
# Upgrade path: the admin row exists, so generate_bootstrap_password() never runs.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"other",
|
||||
[
|
||||
b"legacy-bootstrap-secret\r\n", # only an unreleased build wrote this
|
||||
b"legacy-bootstrap-secret\r",
|
||||
b"legacy-bootstrap-secret ",
|
||||
],
|
||||
)
|
||||
def test_only_an_exactly_unterminated_bootstrap_file_is_touched(other):
|
||||
# Appending is safe only because it is restricted to the one released shape.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(other)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == other
|
||||
|
||||
|
||||
def test_upgrade_normalises_when_the_admin_row_is_missing():
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
assert storage.generate_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
|
||||
|
||||
|
||||
def test_a_well_formed_bootstrap_file_is_not_rewritten():
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret\n")
|
||||
mtime = storage._BOOTSTRAP_PW_PATH.stat().st_mtime_ns
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.stat().st_mtime_ns == mtime
|
||||
|
||||
|
||||
def test_migration_failure_does_not_break_startup(monkeypatch):
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def refuse(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
raise PermissionError("read-only auth dir")
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", refuse)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret"
|
||||
|
||||
|
||||
def test_normalising_never_recreates_a_cleared_bootstrap_file(monkeypatch):
|
||||
# A rename would resurrect revoked plaintext if the password changed after the read.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def clear_then_open(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
storage._BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", clear_then_open)
|
||||
|
||||
assert storage._read_persisted_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert not storage._BOOTSTRAP_PW_PATH.exists()
|
||||
|
||||
|
||||
def test_normalising_does_not_overwrite_a_rotated_bootstrap_file(monkeypatch):
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def rotate_then_open(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"brand-new-secret\n")
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", rotate_then_open)
|
||||
|
||||
storage._read_persisted_bootstrap_password()
|
||||
|
||||
# The append may add a second newline; the rotated credential must survive.
|
||||
raw = storage._BOOTSTRAP_PW_PATH.read_bytes()
|
||||
assert raw.strip() == b"brand-new-secret"
|
||||
storage._bootstrap_password = None
|
||||
assert storage._load_bootstrap_password() == "brand-new-secret"
|
||||
|
||||
|
||||
def test_leading_whitespace_bootstrap_file_is_left_alone(monkeypatch):
|
||||
# An in-place rewrite is not atomic, so only the exact unterminated shape is touched.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b" legacy-bootstrap-secret ")
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b" legacy-bootstrap-secret "
|
||||
|
||||
|
||||
def test_normalising_opens_the_file_in_binary_mode(monkeypatch):
|
||||
# Without O_BINARY, Windows text mode turns the written LF back into CRLF.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
monkeypatch.setattr(storage.os, "O_BINARY", 0x8000, raising = False)
|
||||
seen = []
|
||||
real_open = storage.os.open
|
||||
|
||||
def spy(path, flags, *args, **kwargs):
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
seen.append(flags)
|
||||
return real_open(path, flags & ~0x8000, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", spy)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert seen and all(f & 0x8000 for f in seen), seen
|
||||
|
||||
|
||||
def test_clearing_by_truncation_mid_normalisation_is_not_undone(monkeypatch):
|
||||
# clear_bootstrap_password() truncates through its own descriptor when the unlink
|
||||
# fails (Windows, while ours is open); the append must not restore the plaintext.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
|
||||
real_open = storage.os.open
|
||||
|
||||
def truncate_then_open(path, flags, *args, **kwargs):
|
||||
fd = real_open(path, flags, *args, **kwargs)
|
||||
if str(path) == str(storage._BOOTSTRAP_PW_PATH):
|
||||
storage._BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8")
|
||||
return fd
|
||||
|
||||
monkeypatch.setattr(storage.os, "open", truncate_then_open)
|
||||
|
||||
storage._read_persisted_bootstrap_password()
|
||||
|
||||
# A lone newline over a cleared file still reads back as no password.
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes().strip() == b""
|
||||
storage._bootstrap_password = None
|
||||
assert storage._load_bootstrap_password() is None
|
||||
|
||||
|
||||
def test_normalising_works_without_fchmod(monkeypatch):
|
||||
# os.fchmod only reached Windows in 3.13; its absence must not raise.
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_bytes(b"legacy-bootstrap-secret")
|
||||
monkeypatch.delattr(storage.os, "fchmod", raising = False)
|
||||
|
||||
storage.ensure_default_admin()
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"legacy-bootstrap-secret\n"
|
||||
assert storage.get_bootstrap_password() == "legacy-bootstrap-secret"
|
||||
|
||||
|
||||
def test_persisting_the_bootstrap_password_is_atomic(monkeypatch, tmp_path):
|
||||
# A partial write would destroy the only plaintext recovery credential.
|
||||
storage._persist_bootstrap_password("original-secret")
|
||||
|
||||
def boom(src, dst):
|
||||
raise OSError("crash before replace")
|
||||
|
||||
monkeypatch.setattr(storage.os, "replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
storage._persist_bootstrap_password("new-secret")
|
||||
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_bytes() == b"original-secret\n"
|
||||
leftovers = [
|
||||
p.name
|
||||
for p in storage._BOOTSTRAP_PW_PATH.parent.iterdir()
|
||||
if "bootstrap_password." in p.name
|
||||
]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_ensure_default_admin_does_not_generate_for_empty_existing_bootstrap():
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_text(" \n", encoding = "utf-8")
|
||||
|
|
@ -233,7 +445,7 @@ def test_consume_refresh_token_second_call_returns_none():
|
|||
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
|
||||
|
||||
first = storage.consume_refresh_token(raw)
|
||||
assert first == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
assert first[:2] == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
second = storage.consume_refresh_token(raw)
|
||||
assert second is None
|
||||
|
||||
|
|
@ -262,7 +474,7 @@ def test_consume_refresh_token_concurrent_only_one_succeeds(tmp_path, monkeypatc
|
|||
|
||||
successes = [r for r in results if r is not None]
|
||||
assert len(successes) == 1, f"expected exactly one consumer to win, got {len(successes)}"
|
||||
assert successes[0] == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
assert successes[0][:2] == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
|
||||
|
||||
def test_consume_refresh_token_expired_returns_none():
|
||||
|
|
@ -336,6 +548,28 @@ def test_local_recipe_token_authenticates_as_admin_for_web_user(loaded_local_mod
|
|||
assert asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
def test_rotated_credential_job_start_is_401_not_500(loaded_local_model):
|
||||
# A reset-password landing mid-request makes the workflow-key mint refuse.
|
||||
# That must reach the client as a revoked credential, not an unhandled error.
|
||||
from fastapi import HTTPException
|
||||
|
||||
seed_user()
|
||||
jobs_route = data_recipe_jobs_module()
|
||||
stale_gen = storage.credential_generation(secrets.token_urlsafe(64))
|
||||
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
jobs_route._inject_local_providers(local_recipe(), local_recipe_request("t"), stale_gen)
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise storage.CredentialRotated("revoked")
|
||||
|
||||
jobs_route._inject_local_providers = _boom
|
||||
payload = SimpleNamespace(recipe = local_recipe(), run = {})
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
jobs_route.create_job(payload, local_recipe_request("t"), ("unsloth", stale_gen))
|
||||
assert excinfo.value.status_code == 401
|
||||
|
||||
|
||||
def test_desktop_login_rejects_invalid_secret():
|
||||
seed_user(must_change_password = False)
|
||||
client = auth_client()
|
||||
|
|
@ -358,7 +592,7 @@ def test_write_desktop_secret_file_is_0600_on_unix(tmp_path):
|
|||
|
||||
studio_cli._write_auth_secret(path, "desktop-secret")
|
||||
|
||||
assert path.read_text() == "desktop-secret"
|
||||
assert path.read_bytes() == b"desktop-secret\n"
|
||||
if platform.system() != "Windows":
|
||||
assert oct(path.stat().st_mode & 0o777) == "0o600"
|
||||
|
||||
|
|
@ -368,18 +602,31 @@ def test_reset_password_removes_desktop_secret_files(tmp_path, monkeypatch):
|
|||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
||||
auth_dir = tmp_path / "auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / "auth.db").write_text("db")
|
||||
(auth_dir / ".bootstrap_password").write_text("boot")
|
||||
(auth_dir / ".desktop_secret").write_text("new")
|
||||
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
||||
secret = studio_cli._create_desktop_secret_in_cli()
|
||||
studio_cli._write_auth_secret(auth_dir / studio_cli.DESKTOP_SECRET_FILE, secret)
|
||||
(auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).write_text("boot")
|
||||
|
||||
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (auth_dir / "auth.db").exists()
|
||||
assert not (auth_dir / ".bootstrap_password").exists()
|
||||
assert not (auth_dir / ".desktop_secret").exists()
|
||||
assert result.exit_code == 0, result.output
|
||||
# The DB survives on purpose: a running server keeps serving from its admin row.
|
||||
assert (auth_dir / "auth.db").exists()
|
||||
assert not (auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).exists()
|
||||
assert not (auth_dir / studio_cli.DESKTOP_SECRET_FILE).exists()
|
||||
|
||||
conn = studio_cli._connect_auth_db()
|
||||
try:
|
||||
surviving = conn.execute(
|
||||
"SELECT COUNT(*) FROM app_secrets WHERE key IN (?, ?)",
|
||||
(
|
||||
studio_cli.DESKTOP_SECRET_HASH_KEY,
|
||||
studio_cli.DESKTOP_SECRET_CREATED_AT_KEY,
|
||||
),
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
assert surviving == 0
|
||||
|
||||
|
||||
def test_reset_password_removes_desktop_secret_files_without_db(tmp_path, monkeypatch):
|
||||
|
|
@ -525,7 +772,8 @@ if result.exit_code != 0:
|
|||
capture_output = True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
secret = (auth_dir / ".desktop_secret").read_text()
|
||||
# Strip like the src-tauri readers do.
|
||||
secret = (auth_dir / ".desktop_secret").read_text().strip()
|
||||
assert secret.startswith("desktop-")
|
||||
|
||||
conn = sqlite3.connect(auth_dir / "auth.db")
|
||||
|
|
@ -633,7 +881,7 @@ def test_update_password_clears_desktop_secret():
|
|||
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
changed = storage.update_password(storage.DEFAULT_ADMIN_USERNAME, "new-admin-password")
|
||||
assert changed is True
|
||||
assert changed
|
||||
assert storage.validate_desktop_secret(raw) is None
|
||||
|
||||
|
||||
|
|
@ -642,7 +890,7 @@ def test_update_password_on_unknown_user_leaves_desktop_secret_intact():
|
|||
raw = storage.create_desktop_secret()
|
||||
|
||||
changed = storage.update_password("not-a-user", "irrelevant")
|
||||
assert changed is False
|
||||
assert not changed
|
||||
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ def test_lifespan_honors_bootstrap_suppression_in_source():
|
|||
def test_clear_bootstrap_password_truncates_when_unlink_fails(monkeypatch, tmp_path):
|
||||
# If the file cannot be unlinked (Windows AV / read-only auth dir), clear must
|
||||
# truncate it so its stale plaintext cannot be re-seeded by
|
||||
# generate_bootstrap_password() after a later reset-password deletes auth.db,
|
||||
# which would re-validate the revoked bootstrap password.
|
||||
# generate_bootstrap_password() if auth.db is ever recreated, which would
|
||||
# re-validate the revoked bootstrap password.
|
||||
import pathlib
|
||||
|
||||
pw_path = tmp_path / ".bootstrap_password"
|
||||
|
|
|
|||
|
|
@ -45,8 +45,20 @@ def _build_structlog_stub():
|
|||
_maybe_stub("loggers", _build_loggers_stub)
|
||||
_maybe_stub("structlog", _build_structlog_stub)
|
||||
|
||||
import pytest
|
||||
|
||||
import utils.hardware.hardware as hw # noqa: E402
|
||||
|
||||
# The DRM/KFD readers below are Linux-only in production: _rocm_linux_amdgpu_cards and
|
||||
# _rocm_linux_sysfs_vram_by_pci_gb return early unless platform.system() is "Linux", and
|
||||
# _rocm_kfd_gpu_pci_ids only ever globs /sys/class/kfd. Their fake sysfs tree needs PCI
|
||||
# addresses like "0000:00:02.0" as directory names and POSIX separators in the paths the
|
||||
# readers match; Windows permits neither, so the tree cannot be represented there.
|
||||
linux_only = pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason = "covers Linux-only DRM/KFD sysfs parsing driven by a fake /sys tree",
|
||||
)
|
||||
|
||||
|
||||
def _device(
|
||||
index,
|
||||
|
|
@ -99,6 +111,7 @@ def _fake_drm(tmp_path, monkeypatch, cards):
|
|||
return card_paths
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_linux_vram_keyed_by_pci_excludes_foreign_adapters(monkeypatch, tmp_path):
|
||||
# Foreign (non-amdgpu) adapters contribute no entry, so they cannot shift ordinals.
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
|
|
@ -117,6 +130,7 @@ def test_linux_vram_keyed_by_pci_excludes_foreign_adapters(monkeypatch, tmp_path
|
|||
}
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_linux_vram_omits_bad_cards_without_shifting(monkeypatch, tmp_path):
|
||||
# A zero-total card has no entry; identity keying means its absence renumbers nothing.
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
|
|
@ -131,6 +145,7 @@ def test_linux_vram_omits_bad_cards_without_shifting(monkeypatch, tmp_path):
|
|||
assert hw._rocm_linux_sysfs_vram_by_pci_gb() == {"0000:41:00.0": (2.0, 16.0)}
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_linux_vram_omits_amd_card_without_vram_files(monkeypatch, tmp_path):
|
||||
# An APU with no mem_info_vram_* files has no entry; the discrete card keeps its address.
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
|
|
@ -174,6 +189,7 @@ def _fake_kfd(tmp_path, monkeypatch, nodes):
|
|||
return node_paths
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_kfd_lists_gpu_nodes_in_device_order(monkeypatch, tmp_path):
|
||||
# The CPU node (simd_count 0) takes no ordinal; GPU nodes in node-id order are HIP's order.
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
|
|
@ -189,12 +205,14 @@ def test_kfd_lists_gpu_nodes_in_device_order(monkeypatch, tmp_path):
|
|||
assert hw._rocm_kfd_gpu_pci_ids() == ["0000:03:00.0", "0000:41:00.0"]
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_kfd_decodes_domain_device_and_function(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
_fake_kfd(tmp_path, monkeypatch, [(1, 64, (0xC1 << 8) | (0x1F << 3) | 5, 0x1234, _AMD)])
|
||||
assert hw._rocm_kfd_gpu_pci_ids() == ["1234:c1:1f.5"]
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_kfd_skips_non_amd_gpu_nodes(monkeypatch, tmp_path):
|
||||
# An NVIDIA KFD node is not a HIP device: it must take no ordinal, else it
|
||||
# shifts every AMD GPU and ROCm device 1 resolves to AMD GPU 0.
|
||||
|
|
@ -212,6 +230,7 @@ def test_kfd_skips_non_amd_gpu_nodes(monkeypatch, tmp_path):
|
|||
assert hw._rocm_kfd_gpu_pci_ids() == ["0000:03:00.0", "0000:41:00.0"]
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_kfd_fails_closed_when_a_gpu_has_no_location(monkeypatch, tmp_path):
|
||||
# Dropping an unplaceable AMD GPU shifts later ordinals; fail closed for the whole map.
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
|
|
@ -226,6 +245,7 @@ def test_kfd_fails_closed_when_a_gpu_has_no_location(monkeypatch, tmp_path):
|
|||
assert hw._rocm_kfd_gpu_pci_ids() == []
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_kfd_fails_closed_when_a_node_is_unreadable(monkeypatch, tmp_path):
|
||||
# An unreadable node could be a GPU; assuming otherwise would shift ordinals.
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
|
|
@ -241,6 +261,23 @@ def test_kfd_fails_closed_when_a_node_is_unreadable(monkeypatch, tmp_path):
|
|||
assert hw._rocm_kfd_gpu_pci_ids() == []
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_kfd_fails_closed_when_a_node_does_not_decode(monkeypatch, tmp_path):
|
||||
# UnicodeDecodeError is a ValueError, so it slips past `except OSError` and
|
||||
# would shift every later HIP ordinal.
|
||||
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
|
||||
paths = _fake_kfd(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
[
|
||||
(1, 304, (0x03 << 8) | 0, 0, _AMD),
|
||||
(2, 304, (0x41 << 8) | 0, 0, _AMD),
|
||||
],
|
||||
)
|
||||
(Path(paths[0]) / "properties").write_bytes(b"simd_count 304\nvendor_id \x80\xff\n")
|
||||
assert hw._rocm_kfd_gpu_pci_ids() == []
|
||||
|
||||
|
||||
def test_kfd_absent_yields_no_device_order(monkeypatch):
|
||||
monkeypatch.setattr(hw.glob, "glob", lambda pattern: [])
|
||||
assert hw._rocm_kfd_gpu_pci_ids() == []
|
||||
|
|
@ -422,6 +459,10 @@ def test_visible_utilization_rocm_fallback_overlays(monkeypatch):
|
|||
):
|
||||
monkeypatch.delenv(_var, raising = False)
|
||||
monkeypatch.setattr(hw, "IS_ROCM", True)
|
||||
# No AMD adapter data on this host. On Windows this branch runs ahead of the torch
|
||||
# fallback under test, and probing it imports torch, which the CI runner does not
|
||||
# install. Off Windows the real function is never reached, so this changes nothing.
|
||||
monkeypatch.setattr(hw, "_rocm_windows_per_device_vram", lambda ids: [])
|
||||
monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA)
|
||||
monkeypatch.setattr(hw, "_smi_query", lambda *a, **k: None) # amd-smi unavailable
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -450,6 +491,10 @@ def test_visible_utilization_rocm_fallback_overlays(monkeypatch):
|
|||
def test_visible_utilization_relative_index_skips_overlay(monkeypatch):
|
||||
# UUID/MIG mask gives relative indices; the overlay matches physical index, so it must not run.
|
||||
monkeypatch.setattr(hw, "IS_ROCM", True)
|
||||
# No AMD adapter data on this host. On Windows this branch runs ahead of the torch
|
||||
# fallback under test, and probing it imports torch, which the CI runner does not
|
||||
# install. Off Windows the real function is never reached, so this changes nothing.
|
||||
monkeypatch.setattr(hw, "_rocm_windows_per_device_vram", lambda ids: [])
|
||||
monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA)
|
||||
monkeypatch.setattr(hw, "_smi_query", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
|
|
|
|||
568
studio/backend/tests/test_studio_pid_files.py
Normal file
568
studio/backend/tests/test_studio_pid_files.py
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Per-port PID files, so `unsloth studio stop` can find every server.
|
||||
|
||||
Imports run.py directly, so run under the Unsloth venv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
_BACKEND = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND))
|
||||
|
||||
import run # noqa: E402
|
||||
|
||||
# Captured before the autouse fixture stubs them, for the tests that exercise them.
|
||||
_REAL_IS_STUDIO_BACKEND = run._pid_is_studio_backend
|
||||
_REAL_PID_ALIVE = run._pid_alive
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def isolated_root(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(run, "_studio_root", lambda: tmp_path)
|
||||
monkeypatch.setattr(run, "_PID_FILE", tmp_path / "studio.pid")
|
||||
monkeypatch.setattr(run, "_OWN_PID_FILE", None)
|
||||
monkeypatch.setattr(run, "_pid_alive", lambda pid: True)
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): True)
|
||||
yield
|
||||
|
||||
|
||||
def _files(tmp_path):
|
||||
return sorted(p.name for p in tmp_path.glob("studio-*.pid"))
|
||||
|
||||
|
||||
def _pid_of(path):
|
||||
return path.read_text(encoding = "utf-8").splitlines()[0]
|
||||
|
||||
|
||||
def test_write_pid_file_records_port_and_pid(tmp_path):
|
||||
run._write_pid_file(8901)
|
||||
|
||||
assert _files(tmp_path) == [f"studio-8901-{os.getpid()}.pid"]
|
||||
assert _pid_of(tmp_path / f"studio-8901-{os.getpid()}.pid") == str(os.getpid())
|
||||
|
||||
|
||||
def test_write_pid_file_records_the_start_time(tmp_path):
|
||||
# Pins the record to this process, so a reused PID isn't mistaken for it.
|
||||
run._write_pid_file(8901)
|
||||
|
||||
record = run._read_pid_record(tmp_path / f"studio-8901-{os.getpid()}.pid")
|
||||
|
||||
assert record[0] == os.getpid()
|
||||
assert record[1] == pytest.approx(run._process_create_time(os.getpid()))
|
||||
|
||||
|
||||
def test_write_pid_file_keeps_the_legacy_file_a_bare_pid(tmp_path):
|
||||
# An older CLI's `stop` reads studio.pid and expects only digits.
|
||||
run._write_pid_file(8901)
|
||||
|
||||
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())
|
||||
|
||||
|
||||
def test_second_port_does_not_clobber_the_first(tmp_path):
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
run._write_pid_file(8902)
|
||||
|
||||
assert _pid_of(tmp_path / "studio-8901-8550.pid") == "8550"
|
||||
assert (tmp_path / f"studio-8902-{os.getpid()}.pid").exists()
|
||||
|
||||
|
||||
def test_same_port_on_two_binds_does_not_clobber(tmp_path):
|
||||
# 127.0.0.1:8888 and ::1:8888 can both listen; one file per port would lose one.
|
||||
(tmp_path / "studio-8888-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
run._write_pid_file(8888)
|
||||
|
||||
assert len(_files(tmp_path)) == 2
|
||||
|
||||
|
||||
def test_remove_pid_file_only_removes_our_own(tmp_path, monkeypatch):
|
||||
run._write_pid_file(8901)
|
||||
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
|
||||
# Nothing to hand the legacy pointer to, so it goes away with us.
|
||||
monkeypatch.setattr(run, "_pid_alive", lambda pid: pid == os.getpid())
|
||||
|
||||
run._remove_pid_file()
|
||||
|
||||
assert _files(tmp_path) == ["studio-8902-8600.pid"]
|
||||
assert not (tmp_path / "studio.pid").exists()
|
||||
|
||||
|
||||
def test_the_legacy_pointer_moves_to_a_live_sibling(tmp_path):
|
||||
# Only one server owns studio.pid. Deleting it on our way out would leave an
|
||||
# older CLI, which reads nothing else, unable to stop the sibling still up.
|
||||
run._write_pid_file(8901)
|
||||
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
|
||||
|
||||
run._remove_pid_file()
|
||||
|
||||
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8").strip() == "8600"
|
||||
|
||||
|
||||
def test_the_legacy_pointer_is_not_handed_to_a_dead_sibling(tmp_path, monkeypatch):
|
||||
run._write_pid_file(8901)
|
||||
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): False)
|
||||
|
||||
run._remove_pid_file()
|
||||
|
||||
assert not (tmp_path / "studio.pid").exists()
|
||||
|
||||
|
||||
def test_remove_pid_file_leaves_a_reused_entry_alone(tmp_path):
|
||||
run._write_pid_file(8901)
|
||||
own = tmp_path / f"studio-8901-{os.getpid()}.pid"
|
||||
own.write_text("999999", encoding = "utf-8")
|
||||
|
||||
run._remove_pid_file()
|
||||
|
||||
assert own.read_text(encoding = "utf-8") == "999999"
|
||||
|
||||
|
||||
def test_windows_liveness_does_not_call_every_pid_alive(monkeypatch):
|
||||
# os.kill(pid, 0) raises OSError for every pid on Windows, so without the
|
||||
# tasklist fallback a stale record would block its port forever.
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(run, "_pid_alive", _REAL_PID_ALIVE)
|
||||
monkeypatch.setitem(sys.modules, "psutil", None)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run", lambda *a, **k: SimpleNamespace(stdout = '"python.exe","8550",...')
|
||||
)
|
||||
|
||||
assert run._pid_alive(8550) is True
|
||||
assert run._pid_alive(9999) is False
|
||||
|
||||
|
||||
def test_windows_liveness_keeps_the_record_when_tasklist_fails(monkeypatch):
|
||||
# Unconfirmed must mean keep, matching the CLI's _pid_alive. Pruning a live
|
||||
# server's record lets the next launch fall back past it and strand it, which
|
||||
# is the bug this file exists to fix; a stale record costs one clear abort.
|
||||
import subprocess
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise OSError("tasklist missing")
|
||||
|
||||
monkeypatch.setattr(run, "_pid_alive", _REAL_PID_ALIVE)
|
||||
monkeypatch.setitem(sys.modules, "psutil", None)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setattr(subprocess, "run", _boom)
|
||||
|
||||
assert run._pid_alive(8550) is True
|
||||
|
||||
|
||||
def test_read_pid_record_parses_pid_time_and_address(tmp_path):
|
||||
(tmp_path / "r.pid").write_text("8550\n111.5\n127.0.0.1", encoding = "utf-8")
|
||||
|
||||
assert run._read_pid_record(tmp_path / "r.pid") == (8550, 111.5, "127.0.0.1")
|
||||
|
||||
|
||||
def test_read_pid_record_tolerates_a_bare_pid(tmp_path):
|
||||
(tmp_path / "r.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._read_pid_record(tmp_path / "r.pid") == (8550, None, None)
|
||||
|
||||
|
||||
def test_read_pid_record_rejects_pid_zero_and_init(tmp_path):
|
||||
# kill(0) signals our whole process group.
|
||||
(tmp_path / "zero.pid").write_text("0", encoding = "utf-8")
|
||||
(tmp_path / "init.pid").write_text("1", encoding = "utf-8")
|
||||
|
||||
assert run._read_pid_record(tmp_path / "zero.pid") is None
|
||||
assert run._read_pid_record(tmp_path / "init.pid") is None
|
||||
|
||||
|
||||
def test_read_pid_record_rejects_a_corrupt_file(tmp_path):
|
||||
(tmp_path / "r.pid").write_text("not-a-pid", encoding = "utf-8")
|
||||
|
||||
assert run._read_pid_record(tmp_path / "r.pid") is None
|
||||
|
||||
|
||||
def test_graceful_shutdown_drops_the_record_last(monkeypatch):
|
||||
# Cleanup can take seconds while the server is still alive. Dropping the record
|
||||
# first leaves a retried `stop` or a new launch unable to find it.
|
||||
order = []
|
||||
monkeypatch.setattr(run, "_remove_pid_file", lambda: order.append("remove_record"))
|
||||
|
||||
class _Server:
|
||||
def __setattr__(self, name, value):
|
||||
order.append("release_socket")
|
||||
|
||||
run._graceful_shutdown(_Server())
|
||||
|
||||
assert order == ["release_socket", "remove_record"]
|
||||
|
||||
|
||||
def test_own_studio_on_port_is_found_without_psutil(tmp_path, monkeypatch):
|
||||
# psutil is optional; a listener scan finds nothing without it, so detection
|
||||
# must come from our own records or we silently start a duplicate.
|
||||
monkeypatch.setitem(sys.modules, "psutil", None)
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
|
||||
|
||||
|
||||
def test_no_record_for_the_port_means_no_own_studio(tmp_path):
|
||||
# jupyter-lab on 8888 must keep the fallback, not abort the launch.
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8888, "127.0.0.1") is None
|
||||
|
||||
|
||||
def test_own_studio_on_port_prunes_a_dead_record(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") is None
|
||||
assert not (tmp_path / "studio-8901-8550.pid").exists()
|
||||
|
||||
|
||||
def test_a_reused_pid_is_not_treated_as_our_studio(tmp_path, monkeypatch):
|
||||
# Stale record + the OS handing that PID to something else must not abort.
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): False)
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") is None
|
||||
|
||||
|
||||
def test_an_unverifiable_record_still_blocks_a_duplicate(tmp_path, monkeypatch):
|
||||
# Can't tell: refusing with a clear message beats a silent second instance.
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): True)
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
|
||||
|
||||
|
||||
def test_start_time_mismatch_rejects_a_reused_pid(monkeypatch):
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
|
||||
|
||||
assert run._pid_is_studio_backend(8550, [111.5]) is False
|
||||
assert run._pid_is_studio_backend(8550, [999.0]) is True
|
||||
|
||||
|
||||
def test_a_stale_record_does_not_veto_a_live_server_sharing_the_pid(monkeypatch):
|
||||
# Crash leaves studio-8888-1234.pid, the OS reuses 1234 for a new server on
|
||||
# another port. Keeping only the first timestamp would reject the live one.
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
|
||||
|
||||
assert run._pid_is_studio_backend(1234, [111.5, 999.0]) is True
|
||||
assert run._pid_is_studio_backend(1234, [111.5, 222.5]) is False
|
||||
|
||||
|
||||
def test_a_stale_record_on_another_port_does_not_hide_a_live_server(tmp_path, monkeypatch):
|
||||
# 1234 was reused: the stale 8888 record must not stop us seeing 9000.
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
|
||||
(tmp_path / "studio-8888-1234.pid").write_text("1234\n111.5\n", encoding = "utf-8")
|
||||
(tmp_path / "studio-9000-1234.pid").write_text("1234\n999.0\n", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8888, "127.0.0.1") is None
|
||||
assert run._own_studio_on_port(9000, "127.0.0.1") == 1234
|
||||
|
||||
|
||||
def test_a_start_time_is_the_only_thing_that_disproves_a_record(monkeypatch):
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
|
||||
|
||||
assert run._pid_is_studio_backend(8550, [999.0]) is True
|
||||
assert run._pid_is_studio_backend(8550, [111.5]) is False
|
||||
|
||||
|
||||
def test_a_bare_run_py_command_line_is_not_rejected(monkeypatch):
|
||||
# `cd studio/backend && python run.py --port 8901` has no "studio" or "unsloth"
|
||||
# in argv. Guessing from the command line called that "not ours".
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
|
||||
class _FakeProcess:
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
|
||||
def cmdline(self):
|
||||
return ["python", "run.py", "--port", "8901"]
|
||||
|
||||
def create_time(self):
|
||||
return 111.5
|
||||
|
||||
monkeypatch.setitem(sys.modules, "psutil", SimpleNamespace(Process = _FakeProcess))
|
||||
|
||||
assert run._pid_is_studio_backend(8550) is True
|
||||
|
||||
|
||||
def test_an_untimed_legacy_record_is_trusted(monkeypatch):
|
||||
# `python run.py --port 8901` has no telltale argv, so guessing from the
|
||||
# command line rejected real servers. Only a start time can disprove one.
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
|
||||
|
||||
assert run._pid_is_studio_backend(8550) is True
|
||||
assert run._pid_is_studio_backend(8550, [None]) is True
|
||||
|
||||
|
||||
def test_the_untimed_legacy_record_does_not_cancel_a_timed_one(monkeypatch):
|
||||
# Mirrors _pid_is_studio_server in the CLI. An untimed record carries no
|
||||
# information, so it must not overrule a start time that says "not ours" --
|
||||
# every current server writes one of each, which made the check inert.
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
|
||||
|
||||
assert run._pid_is_studio_backend(8550, [111.5, None]) is False
|
||||
assert run._pid_is_studio_backend(8550, [111.5, 999.0]) is True
|
||||
|
||||
|
||||
def test_a_legacy_server_on_the_port_is_recognised(tmp_path, monkeypatch):
|
||||
# Pre-upgrade servers wrote only studio.pid. Falling back past one strands it
|
||||
# and then overwrites its record.
|
||||
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
|
||||
|
||||
|
||||
def test_a_legacy_record_for_a_different_listener_falls_back(tmp_path, monkeypatch):
|
||||
# jupyter holds the port; the legacy server is elsewhere. Keep falling back.
|
||||
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (117, "jupyter-lab"))
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") is None
|
||||
|
||||
|
||||
def test_an_unknowable_listener_treats_the_legacy_record_as_ours(tmp_path, monkeypatch):
|
||||
# No psutil: _get_pid_on_port can't say. Refusing beats a silent duplicate.
|
||||
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
|
||||
|
||||
|
||||
def test_a_dead_legacy_record_falls_back(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
|
||||
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") is None
|
||||
|
||||
|
||||
def test_a_stale_per_port_record_does_not_mask_a_legacy_server(tmp_path, monkeypatch):
|
||||
# Crashed current build left studio-8901-8550.pid; 8550 was then reused by a
|
||||
# pre-upgrade server recorded only in studio.pid. The stale record must not
|
||||
# count as "port already known" and send us falling back past the live one.
|
||||
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
|
||||
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
|
||||
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550\n111.5\n127.0.0.1", encoding = "utf-8")
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
|
||||
|
||||
|
||||
def test_a_current_server_elsewhere_does_not_block_a_foreign_port(tmp_path, monkeypatch):
|
||||
# Current builds write studio.pid too. Without psutil the legacy check can't
|
||||
# see the listener, so it must not claim our 8901 server holds jupyter's 8888.
|
||||
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
|
||||
(tmp_path / "studio-8901-5000.pid").write_text("5000\n\n127.0.0.1", encoding = "utf-8")
|
||||
(tmp_path / "studio.pid").write_text("5000", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8888, "127.0.0.1") is None
|
||||
|
||||
|
||||
def test_a_per_port_record_is_preferred_over_the_legacy_one(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
|
||||
(tmp_path / "studio-8901-8600.pid").write_text("8600\n\n127.0.0.1", encoding = "utf-8")
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8901, "127.0.0.1") == 8600
|
||||
|
||||
|
||||
def test_our_studio_on_another_bind_address_does_not_abort(tmp_path):
|
||||
# Our server holds ::1:8889; binding 127.0.0.1:8889 is not a conflict with us,
|
||||
# so fall through to the next port instead of refusing.
|
||||
(tmp_path / "studio-8889-8550.pid").write_text("8550\n\n::1", encoding = "utf-8")
|
||||
|
||||
assert run._own_studio_on_port(8889, "127.0.0.1") is None
|
||||
assert run._own_studio_on_port(8889, "::1") == 8550
|
||||
|
||||
|
||||
def test_address_matching(tmp_path):
|
||||
assert run._addresses_collide("0.0.0.0", "127.0.0.1", 8889) is True
|
||||
assert run._addresses_collide("127.0.0.1", "0.0.0.0", 8889) is True
|
||||
assert run._addresses_collide("127.0.0.1", "127.0.0.1", 8889) is True
|
||||
assert run._addresses_collide("::1", "127.0.0.1", 8889) is False
|
||||
# An unrecorded address is unknown, so assume a conflict.
|
||||
assert run._addresses_collide(None, "127.0.0.1", 8889) is True
|
||||
|
||||
|
||||
def test_a_hostname_resolves_the_same_way_the_bind_does(tmp_path):
|
||||
# `localhost` and the address _is_port_free actually binds must agree, or a
|
||||
# recorded server is missed and a duplicate starts.
|
||||
recorded = ",".join(sorted(run._bind_addresses("localhost", 8889)))
|
||||
|
||||
assert run._addresses_collide(recorded, "localhost", 8889) is True
|
||||
|
||||
|
||||
def test_a_hostname_records_every_address_it_resolves_to(tmp_path):
|
||||
# `localhost` binds 127.0.0.1 AND ::1. Recording only the first lets a later
|
||||
# launch on the other literal miss us and start a duplicate.
|
||||
addrs = run._bind_addresses("localhost", 8889)
|
||||
recorded = ",".join(sorted(addrs))
|
||||
|
||||
for literal in addrs:
|
||||
assert run._addresses_collide(recorded, literal, 8889) is True
|
||||
|
||||
|
||||
def test_a_multi_address_record_matches_either_literal(tmp_path):
|
||||
recorded = "127.0.0.1,::1"
|
||||
|
||||
assert run._addresses_collide(recorded, "127.0.0.1", 8889) is True
|
||||
assert run._addresses_collide(recorded, "::1", 8889) is True
|
||||
assert run._addresses_collide("127.0.0.1", "::1", 8889) is False
|
||||
|
||||
|
||||
def test_fallback_aborts_on_our_own_server_further_up_the_range(tmp_path, monkeypatch):
|
||||
# jupyter holds 8888, our server holds 8889: skipping to 8890 is the duplicate.
|
||||
(tmp_path / "studio-8889-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
|
||||
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p >= 8890)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
run._find_free_port("127.0.0.1", 8889, avoid_own_studio = True)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
|
||||
|
||||
def test_fallback_still_skips_foreign_processes(tmp_path, monkeypatch):
|
||||
# No record for 8889, so the blocker is not ours: keep falling back.
|
||||
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p >= 8890)
|
||||
|
||||
assert run._find_free_port("127.0.0.1", 8889, avoid_own_studio = True) == 8890
|
||||
|
||||
|
||||
def test_the_requested_port_is_kept_when_it_is_free(monkeypatch):
|
||||
monkeypatch.setattr(run, "_is_port_free", lambda host, p: True)
|
||||
|
||||
assert run._resolve_port("127.0.0.1", 8888) == 8888
|
||||
|
||||
|
||||
def test_our_own_server_on_the_requested_port_aborts_rather_than_falling_back(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
# The reported bug: 8888 is ours, so falling back to 8889 is the duplicate
|
||||
# that leaves 8888 serving with nothing recording it.
|
||||
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
|
||||
(tmp_path / "studio-8888-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
run._resolve_port("127.0.0.1", 8888)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
|
||||
|
||||
def test_a_foreign_process_on_the_requested_port_still_falls_back(monkeypatch):
|
||||
# jupyter-lab on 8888 must not stop Unsloth starting on 8889.
|
||||
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
|
||||
|
||||
assert run._resolve_port("127.0.0.1", 8888) == 8889
|
||||
|
||||
|
||||
def test_a_caller_that_reads_the_port_back_keeps_the_plain_fallback(tmp_path, monkeypatch):
|
||||
# api-only callers (the desktop app via TAURI_PORT, `studio run` via
|
||||
# app.state.server_port) follow us to the new port, so aborting there only
|
||||
# turns a working launch into a crash the desktop app reports as "stopped
|
||||
# unexpectedly". Both servers are still recorded, so `stop` finds them.
|
||||
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
|
||||
(tmp_path / "studio-8888-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
|
||||
|
||||
assert run._resolve_port("127.0.0.1", 8888, avoid_own_studio = False) == 8889
|
||||
|
||||
|
||||
def test_the_recorded_address_is_every_address_the_bind_resolves_to(tmp_path):
|
||||
# The only test that runs the writer with a real host. Recording `host`
|
||||
# verbatim, or dropping the line, passes every other test here and silently
|
||||
# stops matching a launch that spells the same interface differently.
|
||||
run._write_pid_file(8901, "localhost")
|
||||
|
||||
record = run._read_pid_record(tmp_path / f"studio-8901-{os.getpid()}.pid")
|
||||
|
||||
assert record[2] is not None, "no bind address recorded"
|
||||
assert set(record[2].split(",")) == run._bind_addresses("localhost", 8901)
|
||||
|
||||
|
||||
def test_a_server_started_on_a_hostname_is_found_again_by_ip(tmp_path):
|
||||
run._write_pid_file(8901, "localhost")
|
||||
|
||||
for literal in run._bind_addresses("localhost", 8901):
|
||||
assert run._own_studio_on_port(8901, literal) == os.getpid()
|
||||
|
||||
|
||||
def test_bind_addresses_keeps_every_family_a_hostname_resolves_to(monkeypatch):
|
||||
# Independent oracle: the sibling test derives its expectation from this
|
||||
# function's own output, so dropping a family would pass it.
|
||||
import socket
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
"getaddrinfo",
|
||||
lambda *a, **k: [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 8889)),
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 8889, 0, 0)),
|
||||
],
|
||||
)
|
||||
|
||||
assert run._bind_addresses("localhost", 8889) == {"127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def test_the_legacy_file_is_written_even_when_the_per_port_record_fails(tmp_path, monkeypatch):
|
||||
# A studio root that cannot take a new entry used to leave the server
|
||||
# recorded nowhere at all, so the CLI could not stop it. studio.pid is an
|
||||
# overwrite of an existing path, so it can still succeed and must be tried.
|
||||
blocked = tmp_path / "not-a-directory"
|
||||
blocked.write_text("", encoding = "utf-8")
|
||||
monkeypatch.setattr(
|
||||
run, "_pid_file_for_port", lambda port: blocked / f"studio-{port}-{os.getpid()}.pid"
|
||||
)
|
||||
|
||||
run._write_pid_file(8901, "127.0.0.1")
|
||||
|
||||
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())
|
||||
assert run._OWN_PID_FILE is None
|
||||
|
||||
|
||||
def test_a_record_whose_pid_is_not_ascii_digits_is_discarded(tmp_path):
|
||||
# A superscript two passes isdigit() but int() rejects it, so that gate alone
|
||||
# let a ValueError escape into every caller of _read_pid_record.
|
||||
(tmp_path / "r.pid").write_text("²", encoding = "utf-8")
|
||||
|
||||
assert run._read_pid_record(tmp_path / "r.pid") is None
|
||||
|
||||
|
||||
def test_the_legacy_file_is_not_taken_from_a_live_server(tmp_path):
|
||||
# A pre-upgrade server is recorded in studio.pid and nowhere else, so a
|
||||
# second launch overwriting it is exactly what strands it. That is the
|
||||
# orphan this file exists to prevent, reached from the other direction.
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
run._write_pid_file(8902, "127.0.0.1")
|
||||
|
||||
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == "8550"
|
||||
assert (tmp_path / f"studio-8902-{os.getpid()}.pid").exists()
|
||||
|
||||
|
||||
def test_the_legacy_file_is_taken_over_from_a_dead_server(tmp_path, monkeypatch):
|
||||
# A stale record must not keep the pointer forever, or an older CLI could
|
||||
# never stop anything again.
|
||||
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
|
||||
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
run._write_pid_file(8902, "127.0.0.1")
|
||||
|
||||
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())
|
||||
809
studio/backend/tests/test_text_io_encoding.py
Normal file
809
studio/backend/tests/test_text_io_encoding.py
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Text I/O must name its encoding, or Windows silently uses the ANSI codepage.
|
||||
|
||||
``open()``, ``Path.read_text()`` and ``subprocess(text = True)`` fall back to
|
||||
``locale.getencoding()`` when no ``encoding`` is passed. On Windows that is
|
||||
cp1252 (or cp932, cp1251, ... by system locale), not UTF-8, so a chat template,
|
||||
model config or path containing ``ä ö ü → 世`` mojibakes or raises
|
||||
``UnicodeDecodeError`` mid-load. Studio's files are UTF-8, so say so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Not runtime source. Shipped plugins under plugins/*/src are, so only builds are skipped.
|
||||
_SKIPPED_DIRS = ("node_modules", "build", "tests", "__pycache__")
|
||||
|
||||
# Path.open()'s signature is what tells it apart from other libraries' open(),
|
||||
# e.g. fitz.open(stream=...) and av.open(..., metadata_errors=...).
|
||||
_FILE_MODE_CHARS = set("rwxabt+")
|
||||
_PATH_OPEN_ARGS = ("mode", "buffering", "encoding", "errors", "newline")
|
||||
_PATH_OPEN_KWARGS = set(_PATH_OPEN_ARGS)
|
||||
_PATH_OPEN_ENCODING_ARG = _PATH_OPEN_ARGS.index("encoding")
|
||||
|
||||
_SUBPROCESS_CALLS = {"run", "Popen", "check_output", "check_call", "call"}
|
||||
|
||||
# open(file, mode, buffering, encoding, ...), and os.fdopen forwards the same
|
||||
# signature with a descriptor in place of the path.
|
||||
_OPEN_ENCODING_ARG = 3
|
||||
|
||||
|
||||
def _studio_sources() -> list[Path]:
|
||||
return [
|
||||
path
|
||||
for path in sorted(BACKEND_ROOT.rglob("*.py"))
|
||||
if not any(part in _SKIPPED_DIRS for part in path.relative_to(BACKEND_ROOT).parts)
|
||||
]
|
||||
|
||||
|
||||
def _has_keyword(node: ast.Call, name: str) -> bool:
|
||||
return any(keyword.arg == name for keyword in node.keywords)
|
||||
|
||||
|
||||
def _mode_is_binary(node: ast.Call) -> bool:
|
||||
mode: str | None = None
|
||||
if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
|
||||
value = node.args[1].value
|
||||
mode = value if isinstance(value, str) else None
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant):
|
||||
value = keyword.value.value
|
||||
if isinstance(value, str):
|
||||
mode = value
|
||||
return bool(mode and "b" in mode)
|
||||
|
||||
|
||||
def _open_has_encoding(node: ast.Call) -> bool:
|
||||
"""open()/os.fdopen() also take encoding positionally: open(p, "w", 1, "utf-8")."""
|
||||
return _has_keyword(node, "encoding") or len(node.args) > _OPEN_ENCODING_ARG
|
||||
|
||||
|
||||
def _path_open_mode(node: ast.Call) -> str | None:
|
||||
if node.args and isinstance(node.args[0], ast.Constant):
|
||||
value = node.args[0].value
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant):
|
||||
value = keyword.value.value
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _is_path_open(node: ast.Call) -> bool:
|
||||
"""True only for calls matching ``Path.open``'s signature."""
|
||||
if len(node.args) > len(_PATH_OPEN_ARGS):
|
||||
return False
|
||||
if any(k.arg not in _PATH_OPEN_KWARGS for k in node.keywords):
|
||||
return False
|
||||
mode = _path_open_mode(node)
|
||||
if mode is not None:
|
||||
return bool(mode) and set(mode) <= _FILE_MODE_CHARS
|
||||
return not node.args
|
||||
|
||||
|
||||
def _path_open_has_encoding(node: ast.Call) -> bool:
|
||||
"""Path.open() also takes encoding positionally: open("w", 1, "utf-8")."""
|
||||
return _has_keyword(node, "encoding") or len(node.args) > _PATH_OPEN_ENCODING_ARG
|
||||
|
||||
|
||||
def _call_name(node: ast.Call) -> str | None:
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id
|
||||
if isinstance(func, ast.Attribute):
|
||||
return func.attr
|
||||
return None
|
||||
|
||||
|
||||
def _subprocess_names(tree: ast.AST) -> set[str]:
|
||||
"""Names subprocess is reachable under here, e.g. `import subprocess as _sp`."""
|
||||
names = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name == "subprocess":
|
||||
names.add(alias.asname or alias.name)
|
||||
return names
|
||||
|
||||
|
||||
def _subprocess_aliases(tree: ast.AST, names: set[str]) -> set[str]:
|
||||
"""Plain names bound to a subprocess callable, called without the module.
|
||||
|
||||
``install_wheel(run = subprocess.run)`` calls its injected ``run`` as a bare
|
||||
name, so matching only the attribute form leaves those installer calls
|
||||
unguarded. Imports, assignments and parameter defaults all bind one.
|
||||
"""
|
||||
|
||||
def _is_bound(value: ast.expr | None) -> bool:
|
||||
return (
|
||||
isinstance(value, ast.Attribute)
|
||||
and value.attr in _SUBPROCESS_CALLS
|
||||
and isinstance(value.value, ast.Name)
|
||||
and value.value.id in names
|
||||
)
|
||||
|
||||
aliases: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "subprocess":
|
||||
aliases.update(a.asname or a.name for a in node.names if a.name in _SUBPROCESS_CALLS)
|
||||
elif isinstance(node, ast.Assign) and _is_bound(node.value):
|
||||
aliases.update(t.id for t in node.targets if isinstance(t, ast.Name))
|
||||
elif isinstance(node, ast.AnnAssign) and _is_bound(node.value):
|
||||
if isinstance(node.target, ast.Name):
|
||||
aliases.add(node.target.id)
|
||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
args = node.args
|
||||
positional = args.posonlyargs + args.args
|
||||
# Defaults cover the tail of the positional parameters; kw_defaults
|
||||
# is aligned with kwonlyargs already, holding None where absent.
|
||||
padded = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
|
||||
pairs = list(zip(positional, padded)) + list(zip(args.kwonlyargs, args.kw_defaults))
|
||||
aliases.update(arg.arg for arg, default in pairs if _is_bound(default))
|
||||
return aliases
|
||||
|
||||
|
||||
def _is_subprocess_call(node: ast.Call, names: set[str], aliases: set[str]) -> bool:
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id in aliases
|
||||
if not isinstance(func, ast.Attribute) or func.attr not in _SUBPROCESS_CALLS:
|
||||
return False
|
||||
value = func.value
|
||||
return isinstance(value, ast.Name) and value.id in names
|
||||
|
||||
|
||||
def _text_mode_subprocess(node: ast.Call) -> bool:
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg not in ("text", "universal_newlines"):
|
||||
continue
|
||||
if isinstance(keyword.value, ast.Constant) and keyword.value.value is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _text_mode_dict(node: ast.Dict) -> bool:
|
||||
"""A ``{"text": True, ...}`` literal with no "encoding" key."""
|
||||
keys = [k.value for k in node.keys if isinstance(k, ast.Constant)]
|
||||
if "encoding" in keys:
|
||||
return False
|
||||
for key, value in zip(node.keys, node.values):
|
||||
if not isinstance(key, ast.Constant) or key.value not in (
|
||||
"text",
|
||||
"universal_newlines",
|
||||
):
|
||||
continue
|
||||
if isinstance(value, ast.Constant) and value.value is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _splatted_names(tree: ast.AST) -> set[str]:
|
||||
"""Names handed to a call as ``**name``."""
|
||||
names = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg is None and isinstance(keyword.value, ast.Name):
|
||||
names.add(keyword.value.id)
|
||||
return names
|
||||
|
||||
|
||||
def _encoding_assigned_later(tree: ast.AST, name: str) -> bool:
|
||||
"""``name["encoding"] = ...`` somewhere, so the literal need not carry it."""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Subscript) or not isinstance(node.ctx, ast.Store):
|
||||
continue
|
||||
target, key = node.value, node.slice
|
||||
if isinstance(target, ast.Name) and target.id == name:
|
||||
if isinstance(key, ast.Constant) and key.value == "encoding":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _splatted_kwargs_offenders(tree: ast.AST) -> list[ast.Dict]:
|
||||
"""Text-mode kwargs built in a dict and splatted into a call.
|
||||
|
||||
Kwargs are collected in a dict and splatted (``run(cmd, **run_kwargs)``)
|
||||
where a branch has to add a timeout or an env, and the call is often through
|
||||
a helper, so neither the callee nor the keywords are visible at the call
|
||||
site. Only dicts that reach a call this way are judged: an unrelated payload
|
||||
that happens to carry ``"text": True`` is not subprocess configuration.
|
||||
"""
|
||||
found = []
|
||||
# ``run(cmd, **{...})``: the literal is at the call already.
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg is None and isinstance(keyword.value, ast.Dict):
|
||||
if _text_mode_dict(keyword.value):
|
||||
found.append(keyword.value)
|
||||
splatted = _splatted_names(tree)
|
||||
if not splatted:
|
||||
return found
|
||||
for node in ast.walk(tree):
|
||||
targets = []
|
||||
if isinstance(node, ast.Assign):
|
||||
targets = [t for t in node.targets if isinstance(t, ast.Name)]
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
targets = [node.target]
|
||||
if not targets or not isinstance(node.value, ast.Dict):
|
||||
continue
|
||||
if not _text_mode_dict(node.value):
|
||||
continue
|
||||
for target in targets:
|
||||
if target.id in splatted and not _encoding_assigned_later(tree, target.id):
|
||||
found.append(node.value)
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def _offenders(path: Path) -> list[str]:
|
||||
source = path.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source, filename = str(path))
|
||||
subprocess_names = _subprocess_names(tree)
|
||||
subprocess_aliases = _subprocess_aliases(tree, subprocess_names)
|
||||
found: list[str] = []
|
||||
for node in _splatted_kwargs_offenders(tree):
|
||||
found.append(
|
||||
f"{path.name}:{node.lineno}: subprocess kwargs with text = True and no encoding"
|
||||
)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
name = _call_name(node)
|
||||
|
||||
if _is_subprocess_call(node, subprocess_names, subprocess_aliases):
|
||||
if _text_mode_subprocess(node) and not _has_keyword(node, "encoding"):
|
||||
found.append(f"{path.name}:{node.lineno}: subprocess(text = True) without encoding")
|
||||
continue
|
||||
|
||||
if name == "open" and isinstance(node.func, ast.Name):
|
||||
if _mode_is_binary(node) or _open_has_encoding(node):
|
||||
continue
|
||||
found.append(f"{path.name}:{node.lineno}: open() without encoding")
|
||||
continue
|
||||
|
||||
# os.fdopen(fd, "w") is open() on a descriptor, so text mode takes the
|
||||
# same locale default. Its mode defaults to "r", i.e. text, like open's.
|
||||
if name == "fdopen":
|
||||
if _mode_is_binary(node) or _open_has_encoding(node):
|
||||
continue
|
||||
found.append(f"{path.name}:{node.lineno}: os.fdopen() without encoding")
|
||||
continue
|
||||
|
||||
if name == "open" and isinstance(node.func, ast.Attribute):
|
||||
if not _is_path_open(node) or _path_open_has_encoding(node):
|
||||
continue
|
||||
if _path_open_mode(node) and "b" in _path_open_mode(node):
|
||||
continue
|
||||
found.append(f"{path.name}:{node.lineno}: Path.open() without encoding")
|
||||
continue
|
||||
|
||||
if name in ("read_text", "write_text") and isinstance(node.func, ast.Attribute):
|
||||
if _has_keyword(node, "encoding"):
|
||||
continue
|
||||
# importlib.metadata Distribution.read_text() takes no encoding kwarg.
|
||||
if isinstance(node.func.value, ast.Name) and node.func.value.id == "dist":
|
||||
continue
|
||||
found.append(f"{path.name}:{node.lineno}: {name}() without encoding")
|
||||
return found
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", _studio_sources(), ids = lambda p: str(p.name))
|
||||
def test_text_io_names_its_encoding(path: Path) -> None:
|
||||
offenders = _offenders(path)
|
||||
assert not offenders, (
|
||||
"Text I/O without an explicit encoding falls back to the Windows ANSI "
|
||||
'codepage and corrupts non-ASCII (ä ö ü → 世). Pass encoding = "utf-8":\n '
|
||||
+ "\n ".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
_STATE_STORE = (
|
||||
BACKEND_ROOT
|
||||
/ "plugins/data-designer-github-repo-seed/src"
|
||||
/ "data_designer_github_repo_seed/scraper_impl/state_store.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_state_store(codepage: str):
|
||||
"""Load state_store with the writing machine's codepage pinned."""
|
||||
spec = importlib.util.spec_from_file_location(f"state_store_{codepage}", _STATE_STORE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
module.locale = SimpleNamespace(
|
||||
getencoding = lambda: codepage,
|
||||
getpreferredencoding = lambda _ = True: codepage,
|
||||
)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("codepage", "name"), [("cp1252", "Jürgen"), ("cp1251", "Юрий"), ("cp932", "田中")]
|
||||
)
|
||||
def test_resuming_a_legacy_jsonl_keeps_one_encoding(
|
||||
tmp_path: Path, codepage: str, name: str
|
||||
) -> None:
|
||||
"""A scrape written before UTF-8 was explicit must resume, not duplicate."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
records = [{"id": 1, "author": name}, {"id": 2, "author": name}]
|
||||
body = "".join(json.dumps(r, ensure_ascii = False) + "\n" for r in records)
|
||||
path.write_bytes(body.encode(codepage))
|
||||
before = path.read_bytes()
|
||||
|
||||
writer = _load_state_store(codepage).JsonlWriter(path)
|
||||
try:
|
||||
# Seen keys survive the resume, so a repeat is refused, not appended.
|
||||
assert writer.has("id:1") and writer.has("id:2")
|
||||
assert writer.write(records[0]) is False
|
||||
assert writer.write({"id": 3, "author": name}) is True
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
# Never converted, so it still reads in its own codepage; the append is ASCII.
|
||||
blob = path.read_bytes()
|
||||
assert blob.startswith(before)
|
||||
assert blob[len(before) :].isascii()
|
||||
lines = [json.loads(x) for x in blob.decode(codepage).splitlines() if x.strip()]
|
||||
assert len(lines) == 3
|
||||
assert [line["author"] for line in lines] == [name] * 3
|
||||
|
||||
|
||||
def test_a_coincidentally_utf8_legacy_line_is_left_alone(tmp_path: Path) -> None:
|
||||
"""cp1251 `Р°` is D0 B0, which is also UTF-8 `а`, and nothing can tell them apart."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
ambiguous = "Р°"
|
||||
assert ambiguous.encode("cp1251").decode("utf-8") == "а" # the trap
|
||||
authors = ["Привет", "Здравствуйте", "Москва", ambiguous]
|
||||
path.write_bytes(
|
||||
b"".join(
|
||||
json.dumps({"id": i, "author": a}, ensure_ascii = False).encode("cp1251") + b"\n"
|
||||
for i, a in enumerate(authors)
|
||||
)
|
||||
)
|
||||
before = path.read_bytes()
|
||||
|
||||
_load_state_store("cp1251").JsonlWriter(path).close()
|
||||
|
||||
# Untouched, so the ambiguity never had to be resolved.
|
||||
assert path.read_bytes() == before
|
||||
rows = [json.loads(x) for x in path.read_text(encoding = "cp1251").splitlines() if x.strip()]
|
||||
assert [row["author"] for row in rows] == authors
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("codepage", "word"), [("cp1251", "Привет"), ("cp932", "こんにちは"), ("cp1252", "Jürgen")]
|
||||
)
|
||||
def test_a_moved_shard_is_not_rewritten_by_guesswork(
|
||||
tmp_path: Path, codepage: str, word: str
|
||||
) -> None:
|
||||
"""Off the writing machine there is no codepage to attribute the file to."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
# Two records: a lone non-UTF-8 line would count as damage, not legacy.
|
||||
path.write_bytes(
|
||||
b"".join(
|
||||
json.dumps({"id": i, "author": word}, ensure_ascii = False).encode(codepage) + b"\n"
|
||||
for i in (1, 4)
|
||||
)
|
||||
)
|
||||
before = path.read_bytes()
|
||||
|
||||
# A UTF-8 host: latin-1 would read cp1251 `Привет` back as `Ïðèâåò`.
|
||||
writer = _load_state_store("utf-8").JsonlWriter(path)
|
||||
try:
|
||||
assert writer.has("id:1") # ASCII keys still recover
|
||||
assert writer.write({"id": 2, "author": "Grüße"}) is True
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
blob = path.read_bytes()
|
||||
assert blob.startswith(before) # never rewritten
|
||||
assert blob[len(before) :].isascii() # appended as \uXXXX, so no second encoding
|
||||
rows = [json.loads(x) for x in blob.decode(codepage).splitlines() if x.strip()]
|
||||
assert [row["author"] for row in rows] == [word, word, "Grüße"]
|
||||
|
||||
|
||||
def test_an_all_ambiguous_shard_still_gets_ascii_appends(tmp_path: Path) -> None:
|
||||
"""Every line valid under both readings still means the append must not pick one."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
ambiguous = "Р°" # cp1251 D0 B0, also valid UTF-8 for "а"
|
||||
path.write_bytes(
|
||||
b"".join(
|
||||
json.dumps({"id": i, "a": ambiguous}, ensure_ascii = False).encode("cp1251") + b"\n"
|
||||
for i in range(3)
|
||||
)
|
||||
)
|
||||
before = path.read_bytes()
|
||||
|
||||
writer = _load_state_store("cp1251").JsonlWriter(path)
|
||||
try:
|
||||
assert writer.write({"id": 9, "a": "世界"}) is True
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
blob = path.read_bytes()
|
||||
assert blob.startswith(before)
|
||||
# ASCII, so the appended record survives whichever reading is chosen.
|
||||
assert blob[len(before) :].isascii()
|
||||
for codec in ("cp1251", "utf-8"):
|
||||
rows = [json.loads(x) for x in blob.decode(codec).splitlines() if x.strip()]
|
||||
assert rows[-1]["a"] == "世界"
|
||||
|
||||
|
||||
def test_a_damaged_line_in_an_ascii_shard_does_not_block_its_retry(tmp_path: Path) -> None:
|
||||
"""With no non-ASCII records to outvote it, one damaged line is still damage."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
path.write_bytes(
|
||||
b'{"id": 1, "author": "alice"}\n'
|
||||
+ b'{"id": 99, "author": "bad \x96 byte"}\n'
|
||||
+ b'{"id": 2, "author": "bob"}\n'
|
||||
)
|
||||
|
||||
writer = _load_state_store("cp1252").JsonlWriter(path)
|
||||
try:
|
||||
assert writer.has("id:1") and writer.has("id:2")
|
||||
assert not writer.has("id:99")
|
||||
assert writer.write({"id": 99, "author": "good byte"}) is True
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_a_damaged_line_does_not_block_its_own_retry(tmp_path: Path) -> None:
|
||||
"""Its key comes from the codepage reading, which a UTF-8 shard did not pick."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
path.write_bytes(
|
||||
json.dumps({"id": 1, "author": "Jürgen"}, ensure_ascii = False).encode()
|
||||
+ b"\n"
|
||||
+ b'{"id": 99, "author": "bad \x96 byte"}\n'
|
||||
)
|
||||
|
||||
writer = _load_state_store("cp1252").JsonlWriter(path)
|
||||
try:
|
||||
assert writer.has("id:1")
|
||||
assert not writer.has("id:99")
|
||||
assert writer.write({"id": 99, "author": "good byte"}) is True
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_one_damaged_byte_does_not_relabel_a_utf8_shard(tmp_path: Path) -> None:
|
||||
"""A complete JSON line with a stray 0x96 parses as cp1252, but is only one vote."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
healthy = ["Jürgen", "Grüße", "Björn"]
|
||||
path.write_bytes(
|
||||
json.dumps({"id": 0, "author": healthy[0]}, ensure_ascii = False).encode()
|
||||
+ b"\n"
|
||||
+ b'{"id": 99, "author": "bad \x96 byte"}\n'
|
||||
+ b"".join(
|
||||
json.dumps({"id": i, "author": a}, ensure_ascii = False).encode() + b"\n"
|
||||
for i, a in enumerate(healthy[1:], start = 1)
|
||||
)
|
||||
)
|
||||
before = path.read_bytes()
|
||||
|
||||
_load_state_store("cp1252").JsonlWriter(path).close()
|
||||
|
||||
# Untouched, so the healthy records were never re-read as cp1252.
|
||||
assert path.read_bytes() == before
|
||||
rows = []
|
||||
for line in path.read_bytes().splitlines():
|
||||
try:
|
||||
rows.append(json.loads(line.decode()))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
continue
|
||||
assert [row["author"] for row in rows] == healthy
|
||||
|
||||
|
||||
def test_a_torn_line_does_not_relabel_a_utf8_shard(tmp_path: Path) -> None:
|
||||
"""One interrupted append must not get the whole shard read as cp1252."""
|
||||
path = tmp_path / "out.jsonl"
|
||||
good = [{"id": 1, "author": "Jürgen"}, {"id": 3, "author": "Grüße"}]
|
||||
torn = '{"id": 2, "author": "Jürgen"}'.encode()[:-6] # cut mid-character
|
||||
path.write_bytes(
|
||||
json.dumps(good[0], ensure_ascii = False).encode()
|
||||
+ b"\n"
|
||||
+ torn
|
||||
+ b"\n"
|
||||
+ json.dumps(good[1], ensure_ascii = False).encode()
|
||||
+ b"\n"
|
||||
)
|
||||
before = path.read_bytes()
|
||||
|
||||
writer = _load_state_store("cp1252").JsonlWriter(path)
|
||||
try:
|
||||
assert writer.has("id:1") and writer.has("id:3")
|
||||
assert not writer.has("id:2") # torn line yields no key
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
# Untouched: no rewrite, so no record was re-encoded into mojibake.
|
||||
after = path.read_bytes()
|
||||
assert after.startswith(before)
|
||||
assert "Jürgen".encode() in after
|
||||
assert "Jürgen".encode("utf-8").decode("cp1252").encode() not in after
|
||||
|
||||
|
||||
def test_an_undecodable_transport_marker_reads_as_unknown(tmp_path: Path) -> None:
|
||||
"""Pinning the decode turns an undecodable marker into UnicodeDecodeError,
|
||||
which is a ValueError and so is not an OSError. Before the pin those bytes
|
||||
simply read as an unknown value and the caller safely purged and restarted
|
||||
the partial download; letting the error escape aborts the transfer instead.
|
||||
"""
|
||||
import sys
|
||||
|
||||
backend = str(Path(__file__).resolve().parent.parent)
|
||||
if backend not in sys.path:
|
||||
sys.path.insert(0, backend)
|
||||
from hub.utils import download_registry as registry
|
||||
|
||||
marker = tmp_path / ".transport"
|
||||
marker.write_bytes(b"\x80\xffnative\n")
|
||||
assert registry._read_marker_value(marker) is None
|
||||
# A readable but unknown value takes the same path (the behaviour restored).
|
||||
marker.write_text("something-else\n", encoding = "utf-8")
|
||||
assert registry._read_marker_value(marker) is None
|
||||
|
||||
|
||||
def test_a_torn_cache_ref_reads_as_not_cached(tmp_path: Path, monkeypatch) -> None:
|
||||
"""hf_cache_snapshot_dir answers "is this model already on disk", and the
|
||||
offline embedding checks turn a raise into a 500. A refs/main holding a byte
|
||||
the codepage used to decode into a nonsense commit simply missed the snapshot
|
||||
dir before the pin; it has to keep missing it."""
|
||||
import sys
|
||||
|
||||
backend = str(Path(__file__).resolve().parent.parent)
|
||||
if backend not in sys.path:
|
||||
sys.path.insert(0, backend)
|
||||
from utils import utils as backend_utils
|
||||
|
||||
good_root = tmp_path / "good"
|
||||
torn_root = tmp_path / "torn"
|
||||
for root, ref_bytes in ((torn_root, b"\x80\xff\n"), (good_root, b"abc123\n")):
|
||||
repo = root / "models--Org--Model"
|
||||
(repo / "refs").mkdir(parents = True)
|
||||
(repo / "refs" / "main").write_bytes(ref_bytes)
|
||||
(good_root / "models--Org--Model" / "snapshots" / "abc123").mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(backend_utils, "_hf_cache_roots", lambda: [torn_root])
|
||||
assert backend_utils.hf_cache_snapshot_dir("Org/Model") is None
|
||||
# The torn root is skipped, not fatal: a healthy second root still answers.
|
||||
monkeypatch.setattr(backend_utils, "_hf_cache_roots", lambda: [torn_root, good_root])
|
||||
found = backend_utils.hf_cache_snapshot_dir("Org/Model")
|
||||
assert found is not None and found.name == "abc123"
|
||||
|
||||
|
||||
def test_a_corrupt_pid_file_does_not_abort_shutdown(tmp_path: Path, monkeypatch) -> None:
|
||||
"""_remove_pid_file runs first in _graceful_shutdown, so a raise there leaves
|
||||
the inference, export, training and tunnel children alive."""
|
||||
import sys
|
||||
|
||||
backend = str(Path(__file__).resolve().parent.parent)
|
||||
if backend not in sys.path:
|
||||
sys.path.insert(0, backend)
|
||||
import run as studio_run
|
||||
|
||||
pid_file = tmp_path / "studio.pid"
|
||||
pid_file.write_bytes(b"\x80\xff")
|
||||
monkeypatch.setattr(studio_run, "_PID_FILE", pid_file)
|
||||
studio_run._remove_pid_file()
|
||||
# Not this process's PID, so the file stays; the point is that it returned.
|
||||
assert pid_file.exists()
|
||||
|
||||
pid_file.write_text(str(os.getpid()), encoding = "utf-8")
|
||||
studio_run._remove_pid_file()
|
||||
assert not pid_file.exists()
|
||||
|
||||
|
||||
def test_the_kwargs_guard_only_judges_dicts_that_reach_a_call(tmp_path: Path) -> None:
|
||||
"""Only a dict splatted into a call is subprocess configuration. An unrelated
|
||||
payload that happens to carry "text": True is not, and neither is one whose
|
||||
encoding is filled in on a later line."""
|
||||
cases = {
|
||||
"offender.py": 'kw = {"text": True}\nrun(cmd, **kw)\n',
|
||||
"annotated.py": 'kw: dict = {"universal_newlines": True}\nrun(cmd, **kw)\n',
|
||||
"payload.py": 'payload = {"text": True}\nrequests.post(url, json = payload)\n',
|
||||
"inline.py": 'run(cmd, **{"text": True})\n',
|
||||
"later.py": 'kw = {"text": True}\nkw["encoding"] = "utf-8"\nrun(cmd, **kw)\n',
|
||||
"carried.py": 'kw = {"text": True, "encoding": "utf-8"}\nrun(cmd, **kw)\n',
|
||||
}
|
||||
flagged = set()
|
||||
for name, source in cases.items():
|
||||
path = tmp_path / name
|
||||
path.write_text(source, encoding = "utf-8")
|
||||
if any("subprocess kwargs" in line for line in _offenders(path)):
|
||||
flagged.add(name)
|
||||
assert flagged == {"offender.py", "annotated.py", "inline.py"}, flagged
|
||||
|
||||
|
||||
def test_the_guard_follows_subprocess_through_an_alias(tmp_path: Path) -> None:
|
||||
"""install_wheel() takes ``run = subprocess.run`` and calls it as a bare
|
||||
name, so an attribute-only match let both of its installer calls drop their
|
||||
encoding unnoticed. A name bound to something else is still not subprocess."""
|
||||
cases = {
|
||||
"param_default.py": (
|
||||
"import subprocess\n"
|
||||
"def install(*, run = subprocess.run):\n"
|
||||
" run(cmd, text = True)\n"
|
||||
),
|
||||
"assigned.py": "import subprocess\n_run = subprocess.run\n_run(cmd, text = True)\n",
|
||||
"imported.py": "from subprocess import check_output\ncheck_output(cmd, text = True)\n",
|
||||
"renamed.py": "from subprocess import run as _r\n_r(cmd, universal_newlines = True)\n",
|
||||
"encoded.py": (
|
||||
"import subprocess\n"
|
||||
"def install(*, run = subprocess.run):\n"
|
||||
' run(cmd, text = True, encoding = "utf-8")\n'
|
||||
),
|
||||
"unrelated.py": "def run(cmd, text = False):\n pass\nrun(cmd, text = True)\n",
|
||||
}
|
||||
flagged = set()
|
||||
for name, source in cases.items():
|
||||
path = tmp_path / name
|
||||
path.write_text(source, encoding = "utf-8")
|
||||
if any("subprocess(text = True)" in line for line in _offenders(path)):
|
||||
flagged.add(name)
|
||||
assert flagged == {"param_default.py", "assigned.py", "imported.py", "renamed.py"}, flagged
|
||||
|
||||
|
||||
def test_the_guard_sees_os_fdopen(tmp_path: Path) -> None:
|
||||
"""os.fdopen(fd, mode) is open() on a descriptor and takes the same locale
|
||||
default in text mode, so leaving it out let the swap lock file keep the
|
||||
codepage on the write side while its reader was pinned to UTF-8."""
|
||||
cases = {
|
||||
"text.py": 'import os\nos.fdopen(fd, "w")\n',
|
||||
"default_mode.py": "import os\nos.fdopen(fd)\n", # defaults to "r", still text
|
||||
"binary.py": 'import os\nos.fdopen(fd, "wb")\n',
|
||||
"keyword.py": 'import os\nos.fdopen(fd, "w", encoding = "utf-8")\n',
|
||||
"positional.py": 'import os\nos.fdopen(fd, "w", 1, "utf-8")\n',
|
||||
}
|
||||
flagged = set()
|
||||
for name, source in cases.items():
|
||||
path = tmp_path / name
|
||||
path.write_text(source, encoding = "utf-8")
|
||||
if any("fdopen" in line for line in _offenders(path)):
|
||||
flagged.add(name)
|
||||
assert flagged == {"text.py", "default_mode.py"}, flagged
|
||||
|
||||
|
||||
def test_an_undecodable_bootstrap_password_does_not_stop_startup(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""ensure_default_admin calls _load_bootstrap_password for every existing
|
||||
admin and the lifespan calls that with no handler, so a raise here takes the
|
||||
whole backend down instead of ignoring an unusable file."""
|
||||
import sys
|
||||
|
||||
backend = str(Path(__file__).resolve().parent.parent)
|
||||
if backend not in sys.path:
|
||||
sys.path.insert(0, backend)
|
||||
from auth import storage
|
||||
|
||||
pw_file = tmp_path / ".bootstrap_password"
|
||||
pw_file.write_bytes(b"\x80\xffnot-utf8\n")
|
||||
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", pw_file)
|
||||
assert storage._load_bootstrap_password() is None
|
||||
|
||||
# A readable one still loads, so this is a narrowing of failure, not of function.
|
||||
pw_file.write_text("correct horse battery staple\n", encoding = "utf-8")
|
||||
assert storage._load_bootstrap_password() == "correct horse battery staple"
|
||||
|
||||
|
||||
def test_a_damaged_checkpoint_resets_instead_of_resuming_on_a_broken_cursor(tmp_path: Path) -> None:
|
||||
"""A checkpoint holds only base64 cursors and booleans, so a codepage reading
|
||||
can only ever add non-ASCII, never recover any. Resuming on a mojibaked cursor
|
||||
sends GitHub one it answers with INVALID_CURSOR_ARGUMENTS, and the empty page
|
||||
that comes back marks the stream done and skips the rest of it for good.
|
||||
Dropping the checkpoint only replays pages the writers already dedup."""
|
||||
module = _load_state_store("cp1252")
|
||||
cursor = "Y3Vyc29yOnYyOpK0MjAxMi0wMi0xNlQwNjo1Mzo0MVrOADGL_A=="
|
||||
healthy = json.dumps({"issues_cursor": cursor, "issues_done": False}, indent = 2)
|
||||
path = tmp_path / "octocat__Hello-World.json"
|
||||
|
||||
path.write_text(healthy, encoding = "utf-8")
|
||||
assert module.StateStore(path).get("issues_cursor") == cursor
|
||||
|
||||
# Written by a pre-UTF-8 release in the operator's codepage. Nothing is lost
|
||||
# by reading UTF-8 only, because an all-ASCII document is the same bytes.
|
||||
path.write_bytes(healthy.encode("cp1252"))
|
||||
assert module.StateStore(path).get("issues_cursor") == cursor
|
||||
|
||||
# One damaged byte inside the cursor: still a whole JSON document under a
|
||||
# single-byte codepage, so only refusing that reading resets the checkpoint.
|
||||
raw = healthy.encode()
|
||||
at = raw.index(b"MjAxMi0wMi0xNlQ") + 3
|
||||
path.write_bytes(raw[:at] + b"\x96" + raw[at + 1 :])
|
||||
assert json.loads(path.read_bytes().decode("latin-1"))["issues_cursor"] != cursor
|
||||
store = module.StateStore(path)
|
||||
assert store.all() == {}
|
||||
assert store.get("issues_cursor") is None
|
||||
|
||||
|
||||
def test_a_utf8_record_is_not_parsed_a_second_time(tmp_path: Path) -> None:
|
||||
"""These shards reach gigabytes and every resume reads all of one, so a
|
||||
record that already read as UTF-8 must not be decoded and parsed again under
|
||||
the codepage. The legacy reading exists only to recover keys UTF-8 could not."""
|
||||
module = _load_state_store("cp1252")
|
||||
calls: list[str] = []
|
||||
real_parse = module._parse
|
||||
|
||||
def counting_parse(raw, encoding):
|
||||
calls.append(encoding)
|
||||
return real_parse(raw, encoding)
|
||||
|
||||
module._parse = counting_parse
|
||||
try:
|
||||
healthy = json.dumps({"id": 1, "author": "Jürgen"}).encode("utf-8")
|
||||
reading = module._read_line(healthy, "cp1252")
|
||||
assert reading.as_utf8 == {"id": 1, "author": "Jürgen"}
|
||||
assert calls == ["utf-8"], calls
|
||||
|
||||
# A line UTF-8 cannot read still falls through to the codepage, the whole point.
|
||||
calls.clear()
|
||||
legacy = json.dumps({"id": 2, "author": "Jürgen"}, ensure_ascii = False).encode("cp1252")
|
||||
reading = module._read_line(legacy, "cp1252")
|
||||
assert reading.as_utf8 is None
|
||||
assert reading.as_legacy == {"id": 2, "author": "Jürgen"}
|
||||
assert calls == ["utf-8", "cp1252"], calls
|
||||
finally:
|
||||
module._parse = real_parse
|
||||
|
||||
|
||||
def _too_deeply_nested_json() -> str:
|
||||
"""A JSON document nested past what this interpreter will descend into.
|
||||
|
||||
Probed rather than hardcoded: the depth json.loads gives up at is bounded by
|
||||
sys.getrecursionlimit() up to 3.11 and by the C recursion limit from 3.12,
|
||||
which sys.setrecursionlimit no longer moves and which varies by micro
|
||||
version. That is ~995 on 3.9 and ~9999 on 3.13.
|
||||
"""
|
||||
depth = 1
|
||||
while depth <= 1 << 17:
|
||||
document = "[" * depth + "]" * depth
|
||||
try:
|
||||
json.loads(document)
|
||||
except RecursionError:
|
||||
return document
|
||||
depth *= 2
|
||||
pytest.skip("this interpreter parses arbitrarily nested JSON")
|
||||
|
||||
|
||||
def test_an_unparseably_nested_document_is_discarded_not_raised(tmp_path: Path) -> None:
|
||||
"""json.loads answers nesting it cannot descend with RecursionError, which is
|
||||
a RuntimeError and so is neither a ValueError nor a UnicodeDecodeError.
|
||||
_parse is called outside any other handler in both StateStore.__init__ and
|
||||
JsonlWriter._scan_existing, so letting it escape aborts the scraper at
|
||||
startup on a file the catch-all it replaced simply discarded."""
|
||||
module = _load_state_store("cp1252")
|
||||
nested = _too_deeply_nested_json()
|
||||
|
||||
checkpoint = tmp_path / "octocat__Hello-World.json"
|
||||
checkpoint.write_text(nested, encoding = "utf-8")
|
||||
assert module.StateStore(checkpoint).all() == {} # reset, not raised
|
||||
|
||||
shard = tmp_path / "out.jsonl"
|
||||
shard.write_text(
|
||||
nested + "\n" + json.dumps({"id": 1}) + "\n" + json.dumps({"id": 2}) + "\n",
|
||||
encoding = "utf-8",
|
||||
)
|
||||
writer = module.JsonlWriter(shard)
|
||||
try:
|
||||
# Skipped like any other unreadable line, so its neighbours still yield the dedup
|
||||
# keys that keep the resume from re-fetching them.
|
||||
assert writer.has("id:1") and writer.has("id:2")
|
||||
finally:
|
||||
writer.close()
|
||||
|
|
@ -9,8 +9,28 @@ import sys
|
|||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.training import worker
|
||||
|
||||
# The runtime install is Linux-only, so elsewhere these return before any status.
|
||||
linux_only = pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"),
|
||||
reason = "the runtime flash-attn install is gated to Linux",
|
||||
)
|
||||
|
||||
# causal-conv1d and flash-linear-attention are NOT Linux-gated: both installers bail out
|
||||
# on `sys.platform == "win32"` alone (no prebuilt wheel for Windows) and run everywhere
|
||||
# else, macOS included. linux_only here would skip cases that legitimately pass off Linux.
|
||||
not_on_windows = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason = (
|
||||
"mirrors the sys.platform == 'win32' bail-out in "
|
||||
"_ensure_flash_linear_attention_unconditional and "
|
||||
"_ensure_causal_conv1d_fast_path"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _missing_flash_attn_import():
|
||||
real_import = builtins.__import__
|
||||
|
|
@ -55,6 +75,7 @@ def test_should_try_runtime_flash_attn_install_threshold_and_skip(monkeypatch):
|
|||
assert worker._should_try_runtime_flash_attn_install(32768) is False
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
|
||||
statuses: list[str] = []
|
||||
|
||||
|
|
@ -82,6 +103,7 @@ def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
|
|||
assert statuses == ["Installing flash-attn for faster training..."]
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
|
||||
calls: list[list[str]] = []
|
||||
statuses: list[str] = []
|
||||
|
|
@ -113,12 +135,7 @@ def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
|
|||
)
|
||||
monkeypatch.setattr(worker, "install_wheel", mock.Mock())
|
||||
|
||||
def fake_run(
|
||||
cmd,
|
||||
stdout = None,
|
||||
stderr = None,
|
||||
text = None,
|
||||
):
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(list(cmd))
|
||||
return subprocess.CompletedProcess(cmd, 0, "")
|
||||
|
||||
|
|
@ -139,6 +156,7 @@ def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
|
|||
worker._sp.run.assert_not_called()
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
|
||||
install_mock = mock.Mock(return_value = True)
|
||||
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
|
||||
|
|
@ -160,6 +178,7 @@ def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch)
|
|||
)
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_causal_conv1d_fast_path_includes_qwen3_6_variants(monkeypatch):
|
||||
install_mock = mock.Mock(return_value = True)
|
||||
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
|
||||
|
|
@ -225,6 +244,7 @@ def _pin_fla_model_types(monkeypatch):
|
|||
)
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_flash_linear_attention_installs_pinned_pair_for_qwen3_5(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
||||
|
|
@ -277,6 +297,7 @@ def test_flash_linear_attention_skips_for_ssm_only_models(monkeypatch):
|
|||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_flash_linear_attention_matches_full_qwen3_family(monkeypatch):
|
||||
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
||||
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
||||
|
|
@ -331,6 +352,7 @@ def test_flash_linear_attention_skipped_via_env(monkeypatch):
|
|||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_flash_linear_attention_skipped_below_torch_2_7(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
|
||||
|
|
@ -349,6 +371,7 @@ def test_flash_linear_attention_skipped_below_torch_2_7(monkeypatch):
|
|||
assert any("torch>=" in s for s in statuses)
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_flash_linear_attention_install_includes_einops(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
|
||||
|
|
@ -375,6 +398,7 @@ def test_flash_linear_attention_install_includes_einops(monkeypatch):
|
|||
assert f"fla-core=={worker._FLA_CORE_PACKAGE_VERSION}" in args
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_flash_linear_attention_logs_post_install_import_failure(monkeypatch):
|
||||
"""pip exits 0 but `import fla.modules` still fails (missing transitive)."""
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
|
|
@ -421,6 +445,7 @@ def test_tilelang_backend_skipped_on_unsupported_linux_arch(monkeypatch):
|
|||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_tilelang_backend_pins_only_binary(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
||||
|
|
@ -462,6 +487,7 @@ def _force_missing_tilelang_imports(monkeypatch):
|
|||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_tilelang_backend_installs_pinned_pair_for_qwen3_5(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
||||
|
|
@ -486,6 +512,7 @@ def test_tilelang_backend_installs_pinned_pair_for_qwen3_5(monkeypatch):
|
|||
assert any("Installing TileLang" in s for s in statuses)
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_tilelang_backend_reinstalls_when_tvm_ffi_is_broken(monkeypatch):
|
||||
"""Repair path issues TWO pip calls:
|
||||
|
||||
|
|
@ -555,6 +582,7 @@ def test_tilelang_backend_skipped_on_windows(monkeypatch):
|
|||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_tilelang_backend_swallows_install_timeout(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
||||
|
|
@ -609,6 +637,7 @@ def test_tilelang_backend_skipped_via_env(monkeypatch):
|
|||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_tilelang_backend_swallows_install_failure(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
||||
|
|
@ -673,6 +702,7 @@ def _patch_iu_gates(monkeypatch, fla_gate, conv_gate):
|
|||
monkeypatch.setattr(_iu, "is_causal_conv1d_available", conv_gate)
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_hook_installs_when_gate_returns_false(monkeypatch):
|
||||
_pin_fla_model_types(monkeypatch)
|
||||
fla_gate = _make_fake_gate(initial_return = False)
|
||||
|
|
@ -976,6 +1006,7 @@ def test_hook_does_install_tilelang_for_qwen35(monkeypatch):
|
|||
tile_install.assert_called_once()
|
||||
|
||||
|
||||
@linux_only
|
||||
def test_tilelang_repair_does_not_touch_torch_cuda_stack(monkeypatch):
|
||||
"""Finding #2: the broken-tvm-ffi repair must use --no-deps on the
|
||||
forced step so --force-reinstall doesn't cascade through
|
||||
|
|
@ -1119,6 +1150,7 @@ def test_hook_runs_tilelang_repair_when_fla_already_true(monkeypatch):
|
|||
tile_install.assert_called_once()
|
||||
|
||||
|
||||
@not_on_windows
|
||||
def test_fla_installer_force_reinstalls_when_older_version_present(monkeypatch):
|
||||
"""Finding #8: an older `flash-linear-attention` that is importable
|
||||
but below the pin must force a reinstall (not no-op).
|
||||
|
|
@ -1583,15 +1615,10 @@ def test_install_respects_user_gcc_install_dir(monkeypatch):
|
|||
)
|
||||
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
|
||||
|
||||
captured: dict[str, str] | None = {"_called": "no"}
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
env = kwargs.get("env")
|
||||
if env is not None:
|
||||
captured.clear()
|
||||
captured.update(env)
|
||||
else:
|
||||
captured["_called"] = "yes_no_env"
|
||||
captured.update(kwargs.get("env") or {})
|
||||
return subprocess.CompletedProcess(cmd, 0, "")
|
||||
|
||||
monkeypatch.setattr(worker._sp, "run", fake_run)
|
||||
|
|
@ -1607,14 +1634,11 @@ def test_install_respects_user_gcc_install_dir(monkeypatch):
|
|||
release_base_url = "https://example.com",
|
||||
)
|
||||
|
||||
# subprocess.run invoked without env override (user already set
|
||||
# HIPCC_COMPILE_FLAGS_APPEND with --gcc-install-dir, so we left the
|
||||
# env alone — the existing value is inherited).
|
||||
assert captured == {"_called": "yes_no_env"}
|
||||
assert captured["HIPCC_COMPILE_FLAGS_APPEND"] == "--gcc-install-dir=/opt/custom/gcc-13"
|
||||
|
||||
|
||||
def test_install_does_not_inject_env_on_cuda(monkeypatch):
|
||||
"""CUDA path (no hip_version in env) → no env override at all."""
|
||||
"""CUDA path (no hip_version in env) → no HIP flag injected."""
|
||||
monkeypatch.delenv("HIPCC_COMPILE_FLAGS_APPEND", raising = False)
|
||||
monkeypatch.setattr(builtins, "__import__", _missing_module_import("causal_conv1d"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -1641,7 +1665,7 @@ def test_install_does_not_inject_env_on_cuda(monkeypatch):
|
|||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["env_in_kwargs"] = "env" in kwargs
|
||||
captured.update(kwargs.get("env") or {})
|
||||
return subprocess.CompletedProcess(cmd, 0, "")
|
||||
|
||||
monkeypatch.setattr(worker._sp, "run", fake_run)
|
||||
|
|
@ -1657,5 +1681,5 @@ def test_install_does_not_inject_env_on_cuda(monkeypatch):
|
|||
release_base_url = "https://example.com",
|
||||
)
|
||||
|
||||
# CUDA branch never sets the env, never invokes the gcc helper.
|
||||
assert captured.get("env_in_kwargs") is False
|
||||
# env is always passed (to force UTF-8), but never the HIP flag.
|
||||
assert "HIPCC_COMPILE_FLAGS_APPEND" not in captured
|
||||
|
|
|
|||
1056
studio/backend/utils/changelog.py
Normal file
1056
studio/backend/utils/changelog.py
Normal file
File diff suppressed because it is too large
Load diff
22
studio/backend/utils/child_stdio.py
Normal file
22
studio/backend/utils/child_stdio.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Make a Python child agree with the parent that its pipes are UTF-8.
|
||||
|
||||
A child's ``sys.stdout`` uses ``locale.getpreferredencoding()``, which on
|
||||
Windows is the ANSI code page. Reading that pipe as UTF-8 would then mangle any
|
||||
non-ASCII the child prints, so the child has to be told which encoding to emit.
|
||||
Only needed for Python children; llama.cpp and node already emit UTF-8.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Mapping, Optional
|
||||
|
||||
|
||||
def utf8_child_env(env: Optional[Mapping[str, str]] = None) -> dict[str, str]:
|
||||
"""Copy *env* (or the current environment) with UTF-8 stdio forced."""
|
||||
child = dict(os.environ if env is None else env)
|
||||
child["PYTHONIOENCODING"] = "utf-8"
|
||||
return child
|
||||
|
|
@ -144,6 +144,8 @@ def _run_amd_smi(*args: str, timeout: int = _AMD_SMI_DEFAULT_TIMEOUT) -> Optiona
|
|||
["amd-smi", *args, "--json"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = timeout,
|
||||
env = _amd_env,
|
||||
**windows_hidden_subprocess_kwargs(),
|
||||
|
|
|
|||
|
|
@ -830,6 +830,8 @@ def _rocm_windows_perf_counter_gpu_util_pct() -> Optional[float]:
|
|||
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 5,
|
||||
)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
|
|
@ -1027,6 +1029,8 @@ def _rocm_windows_perf_counter_vram_by_adapter() -> Optional[list[tuple[str, flo
|
|||
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 5,
|
||||
)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ def get_physical_gpu_count() -> Optional[int]:
|
|||
["nvidia-smi", "-L"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 5,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
|
|
@ -81,6 +83,8 @@ def get_primary_gpu_utilization() -> dict[str, Any]:
|
|||
],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 5,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
|
|
@ -131,6 +135,8 @@ def get_visible_gpu_utilization(
|
|||
],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 5,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
|
|
@ -215,6 +221,8 @@ def get_backend_visible_gpu_info(
|
|||
],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 10,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
|
|
|
|||
|
|
@ -121,7 +121,14 @@ def _installed_build_number(binary: Optional[str]) -> Optional[int]:
|
|||
if not binary:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run([binary, "--version"], capture_output = True, text = True, timeout = 20)
|
||||
proc = subprocess.run(
|
||||
[binary, "--version"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 20,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
m = re.search(r"version:\s*(\d+)", (proc.stderr or "") + (proc.stdout or ""))
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ def _transformers_constraint_args() -> tuple[list[str], str | None]:
|
|||
except Exception:
|
||||
return [], None
|
||||
fd, path = tempfile.mkstemp(prefix = "mlx_repair_", suffix = ".txt")
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
with os.fdopen(fd, "w", encoding = "utf-8") as fh:
|
||||
fh.write(f"transformers=={transformers_version}\n")
|
||||
return ["--constraint", path], path
|
||||
|
||||
|
|
@ -290,6 +290,8 @@ def attempt_mlx_repair(*, timeout: int = _REPAIR_TIMEOUT_S) -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]:
|
|||
if not trainer_state.exists():
|
||||
return None
|
||||
try:
|
||||
with open(trainer_state, encoding = "utf-8") as f:
|
||||
with open(trainer_state, encoding = "utf-8-sig") as f:
|
||||
state = json.load(f)
|
||||
log_history = state.get("log_history", [])
|
||||
if log_history:
|
||||
|
|
@ -174,18 +174,18 @@ def scan_checkpoints(
|
|||
metadata: dict = {}
|
||||
try:
|
||||
if adapter_config.exists():
|
||||
cfg = json.loads(adapter_config.read_text(encoding = "utf-8"))
|
||||
cfg = json.loads(adapter_config.read_text(encoding = "utf-8-sig"))
|
||||
metadata["base_model"] = cfg.get("base_model_name_or_path")
|
||||
metadata["peft_type"] = cfg.get("peft_type")
|
||||
metadata["lora_rank"] = cfg.get("r")
|
||||
elif config_file.exists():
|
||||
cfg = json.loads(config_file.read_text(encoding = "utf-8"))
|
||||
cfg = json.loads(config_file.read_text(encoding = "utf-8-sig"))
|
||||
metadata["base_model"] = cfg.get("_name_or_path")
|
||||
|
||||
# Detect BNB quantization from config.json
|
||||
if config_file.exists():
|
||||
if "cfg" not in dir():
|
||||
cfg = json.loads(config_file.read_text(encoding = "utf-8"))
|
||||
cfg = json.loads(config_file.read_text(encoding = "utf-8-sig"))
|
||||
quant_cfg = cfg.get("quantization_config")
|
||||
if (
|
||||
isinstance(quant_cfg, dict)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import yaml
|
|||
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.child_stdio import utf8_child_env
|
||||
from utils.hf_cache_settings import active_hf_hub_cache, get_hf_cache_paths
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
|
|
@ -631,7 +632,7 @@ def _raw_config_has_vision_config(
|
|||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
)
|
||||
config = json.loads(config_path.read_text(encoding = "utf-8"))
|
||||
config = json.loads(config_path.read_text(encoding = "utf-8-sig"))
|
||||
architectures = config.get("architectures") or []
|
||||
model_type = config.get("model_type")
|
||||
explicit_vision = (
|
||||
|
|
@ -774,8 +775,12 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
|
|||
],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 60,
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
env = utf8_child_env(
|
||||
get_hf_cache_paths().child_env(child_env_without_native_path_secret())
|
||||
),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
||||
|
|
@ -1083,7 +1088,7 @@ def _detect_audio_from_tokenizer(
|
|||
]:
|
||||
tok_file = snapshot / tok_path
|
||||
if tok_file.exists():
|
||||
tok_config = json.loads(tok_file.read_text(encoding = "utf-8"))
|
||||
tok_config = json.loads(tok_file.read_text(encoding = "utf-8-sig"))
|
||||
read_any = True
|
||||
result = _check_token_patterns(tok_config)
|
||||
if result:
|
||||
|
|
@ -2283,7 +2288,7 @@ def scan_exported_models(
|
|||
export_meta = run_dir / "export_metadata.json"
|
||||
try:
|
||||
if export_meta.exists():
|
||||
meta = json.loads(export_meta.read_text(encoding = "utf-8"))
|
||||
meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
|
||||
base_model = meta.get("base_model")
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -2312,7 +2317,7 @@ def scan_exported_models(
|
|||
if adapter_config.exists():
|
||||
export_type = "lora"
|
||||
try:
|
||||
cfg = json.loads(adapter_config.read_text(encoding = "utf-8"))
|
||||
cfg = json.loads(adapter_config.read_text(encoding = "utf-8-sig"))
|
||||
base_model = cfg.get("base_model_name_or_path")
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -2321,7 +2326,7 @@ def scan_exported_models(
|
|||
export_meta = checkpoint_dir / "export_metadata.json"
|
||||
try:
|
||||
if export_meta.exists():
|
||||
meta = json.loads(export_meta.read_text(encoding = "utf-8"))
|
||||
meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
|
||||
base_model = meta.get("base_model")
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -2334,7 +2339,7 @@ def scan_exported_models(
|
|||
export_meta = meta_dir / "export_metadata.json"
|
||||
try:
|
||||
if export_meta.exists():
|
||||
meta = json.loads(export_meta.read_text(encoding = "utf-8"))
|
||||
meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
|
||||
base_model = meta.get("base_model")
|
||||
if base_model:
|
||||
break
|
||||
|
|
@ -2354,7 +2359,7 @@ def scan_exported_models(
|
|||
outputs_adapter_cfg = resolve_output_dir(run_dir.name) / "adapter_config.json"
|
||||
try:
|
||||
if outputs_adapter_cfg.exists():
|
||||
cfg = json.loads(outputs_adapter_cfg.read_text(encoding = "utf-8"))
|
||||
cfg = json.loads(outputs_adapter_cfg.read_text(encoding = "utf-8-sig"))
|
||||
base_model = cfg.get("base_model_name_or_path")
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -2380,7 +2385,7 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
|
|||
|
||||
adapter_config_path = checkpoint_path_obj / "adapter_config.json"
|
||||
if adapter_config_path.exists():
|
||||
with open(adapter_config_path, "r", encoding = "utf-8") as f:
|
||||
with open(adapter_config_path, "r", encoding = "utf-8-sig") as f:
|
||||
config = json.load(f)
|
||||
base_model = config.get("base_model_name_or_path")
|
||||
if base_model:
|
||||
|
|
@ -2389,7 +2394,7 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
|
|||
|
||||
config_path = checkpoint_path_obj / "config.json"
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding = "utf-8") as f:
|
||||
with open(config_path, "r", encoding = "utf-8-sig") as f:
|
||||
config = json.load(f)
|
||||
for key in ("model_name", "_name_or_path"):
|
||||
base_model = config.get(key)
|
||||
|
|
@ -2445,7 +2450,7 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
|
|||
# adapter_config.json first
|
||||
adapter_config_path = lora_path_obj / "adapter_config.json"
|
||||
if adapter_config_path.exists():
|
||||
with open(adapter_config_path, "r", encoding = "utf-8") as f:
|
||||
with open(adapter_config_path, "r", encoding = "utf-8-sig") as f:
|
||||
config = json.load(f)
|
||||
base_model = config.get("base_model_name_or_path")
|
||||
if base_model:
|
||||
|
|
@ -2535,7 +2540,7 @@ def get_base_model_from_lora_identifier(
|
|||
last_exc = exc
|
||||
continue
|
||||
try:
|
||||
with open(cfg_path, "r", encoding = "utf-8") as f:
|
||||
with open(cfg_path, "r", encoding = "utf-8-sig") as f:
|
||||
base_model = json.load(f).get("base_model_name_or_path")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not parse adapter_config.json for '%s': %s", identifier, exc)
|
||||
|
|
@ -2781,7 +2786,7 @@ class ModelConfig:
|
|||
meta_path = gguf_dir / "export_metadata.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding = "utf-8"))
|
||||
meta = json.loads(meta_path.read_text(encoding = "utf-8-sig"))
|
||||
base = meta.get("base_model")
|
||||
if base and is_vision_model(base, hf_token = hf_token):
|
||||
base_is_vision = True
|
||||
|
|
@ -2912,7 +2917,7 @@ class ModelConfig:
|
|||
token = hf_token,
|
||||
cache_dir = active_hf_hub_cache(),
|
||||
)
|
||||
with open(config_path, "r", encoding = "utf-8") as f:
|
||||
with open(config_path, "r", encoding = "utf-8-sig") as f:
|
||||
adapter_config = json.load(f)
|
||||
base_model = adapter_config.get("base_model_name_or_path")
|
||||
if base_model:
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ def _node_version_ok(executable: str) -> bool:
|
|||
[executable, "-v"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = _NODE_VERSION_PROBE_TIMEOUT_SECONDS,
|
||||
**windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ def lmstudio_model_dirs() -> list[Path]:
|
|||
settings_path = Path.home() / ".lmstudio" / "settings.json"
|
||||
if settings_path.is_file():
|
||||
try:
|
||||
with open(settings_path, encoding = "utf-8") as f:
|
||||
with open(settings_path, encoding = "utf-8-sig") as f:
|
||||
settings = json.load(f)
|
||||
downloads = settings.get("downloadsFolder", "")
|
||||
if downloads:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from typing import Callable, Optional
|
|||
|
||||
import structlog
|
||||
|
||||
from utils.child_stdio import utf8_child_env
|
||||
from utils.process_lifetime import child_popen_kwargs
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
|
@ -159,6 +160,8 @@ def resolve_prebuilt_for_host(
|
|||
cmd,
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 60,
|
||||
)
|
||||
out = (proc.stdout or "").strip()
|
||||
|
|
@ -303,7 +306,10 @@ def stream_installer(
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = env,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
# Make the Python child emit the UTF-8 we decode above.
|
||||
env = utf8_child_env(env),
|
||||
**child_popen_kwargs(),
|
||||
)
|
||||
timed_out = threading.Event()
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -
|
|||
for name in _REMOTE_CODE_CONFIG_FILES:
|
||||
p = root / name
|
||||
if p.is_file():
|
||||
configs.append(json.loads(p.read_text(encoding = "utf-8")))
|
||||
configs.append(json.loads(p.read_text(encoding = "utf-8-sig")))
|
||||
return configs
|
||||
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
|
@ -164,7 +164,7 @@ def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -
|
|||
# Transient/auth failure is not "absent" -> fail closed to "unknown" so
|
||||
# the caller scans (a tokenizer/processor-only auto_map must not slip by).
|
||||
return None
|
||||
configs.append(json.loads(Path(p).read_text(encoding = "utf-8")))
|
||||
configs.append(json.loads(Path(p).read_text(encoding = "utf-8-sig")))
|
||||
# Every config was read or a genuine 404 -> an empty list is a definitive
|
||||
# "no auto_map", not "unknown".
|
||||
return configs
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ def _indexed_shard_paths(
|
|||
inconclusive = True # transient: an index that might exist could not be read
|
||||
continue
|
||||
try:
|
||||
weight_map = (json.loads(open(index_path, encoding = "utf-8").read()) or {}).get(
|
||||
weight_map = (json.loads(open(index_path, encoding = "utf-8-sig").read()) or {}).get(
|
||||
"weight_map"
|
||||
) or {}
|
||||
for shard in weight_map.values():
|
||||
|
|
@ -328,7 +328,7 @@ def _st_load_roots(snapshot: Path) -> list:
|
|||
roots = [snapshot]
|
||||
try:
|
||||
import json
|
||||
modules = json.loads((snapshot / "modules.json").read_text(encoding = "utf-8"))
|
||||
modules = json.loads((snapshot / "modules.json").read_text(encoding = "utf-8-sig"))
|
||||
except (OSError, ValueError):
|
||||
return roots # no / invalid modules.json -> snapshot root is the only load root
|
||||
for module in modules or ():
|
||||
|
|
@ -355,7 +355,7 @@ def _indexed_pickle_shards(index_path: Path, root: Path, snapshot: Path) -> list
|
|||
try:
|
||||
# JSON is UTF-8 by spec; pin it so a non-ASCII index is not misdecoded (and needlessly
|
||||
# blocked) under Windows' cp1252 default.
|
||||
parsed = json.loads(index_path.read_text(encoding = "utf-8"))
|
||||
parsed = json.loads(index_path.read_text(encoding = "utf-8-sig"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise OSError(f"unreadable weight index: {index_path}") from exc
|
||||
weight_map = parsed.get("weight_map") if isinstance(parsed, dict) else None
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ def approval_target_key(targets) -> str:
|
|||
def _load() -> dict:
|
||||
"""Parsed store, or an empty skeleton on any error (fail-safe = re-prompt)."""
|
||||
try:
|
||||
with open(_store_path(), encoding = "utf-8") as f:
|
||||
with open(_store_path(), encoding = "utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
# Validate the shape, not just the version: a hand-edited ``subjects`` that is not a
|
||||
# dict (e.g. ``[]``) would otherwise crash lookup/record instead of failing safe.
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
|
|||
p = root / name
|
||||
if p.is_file():
|
||||
try:
|
||||
ext_refs |= _auto_map_refs(json.loads(p.read_text(encoding = "utf-8")))
|
||||
ext_refs |= _auto_map_refs(json.loads(p.read_text(encoding = "utf-8-sig")))
|
||||
except Exception:
|
||||
pass
|
||||
if not _add_external_refs(files, ext_refs, hf_token, model_name):
|
||||
|
|
@ -483,7 +483,7 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
|
|||
f"{model_name}: config {cfg_name} could not be fetched ({exc})"
|
||||
) from exc
|
||||
try:
|
||||
refs |= _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8")))
|
||||
refs |= _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8-sig")))
|
||||
except Exception:
|
||||
pass
|
||||
own_refs = {fn for repo, fn in refs if repo is None}
|
||||
|
|
@ -616,7 +616,7 @@ def external_auto_map_repos(model_name: str, hf_token: Optional[str] = None) ->
|
|||
if not p.is_file():
|
||||
continue
|
||||
try:
|
||||
refs = _auto_map_refs(json.loads(p.read_text(encoding = "utf-8")))
|
||||
refs = _auto_map_refs(json.loads(p.read_text(encoding = "utf-8-sig")))
|
||||
except Exception:
|
||||
continue
|
||||
repos.update(repo for repo, _fn in refs if repo)
|
||||
|
|
@ -638,7 +638,7 @@ def external_auto_map_repos(model_name: str, hf_token: Optional[str] = None) ->
|
|||
except Exception:
|
||||
continue
|
||||
try:
|
||||
refs = _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8")))
|
||||
refs = _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8-sig")))
|
||||
except Exception:
|
||||
continue
|
||||
repos.update(repo for repo, _fn in refs if repo)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import threading
|
|||
from typing import Any, Callable, Optional
|
||||
|
||||
from loggers import get_logger
|
||||
from utils.child_stdio import utf8_child_env
|
||||
from utils.wheel_utils import (
|
||||
direct_wheel_url,
|
||||
install_wheel,
|
||||
|
|
@ -254,6 +255,12 @@ def _install_kernel(
|
|||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"text": True,
|
||||
# pip and the compilers it drives write UTF-8 down this pipe; the Windows
|
||||
# ANSI codepage would mojibake or raise over a fine install.
|
||||
"encoding": "utf-8",
|
||||
"errors": "replace",
|
||||
# Make the Python child emit the UTF-8 we decode above.
|
||||
"env": utf8_child_env(),
|
||||
}
|
||||
if is_hip:
|
||||
run_kwargs["timeout"] = 1800 # ROCm builds can take 10-30 min
|
||||
|
|
@ -261,7 +268,8 @@ def _install_kernel(
|
|||
if "--gcc-install-dir" not in existing:
|
||||
gcc_dir = _hipcc_gcc_install_dir()
|
||||
if gcc_dir:
|
||||
_env = os.environ.copy()
|
||||
# Extends the UTF-8 env above rather than replacing it.
|
||||
_env = dict(run_kwargs["env"])
|
||||
_env["HIPCC_COMPILE_FLAGS_APPEND"] = (
|
||||
f"{existing} --gcc-install-dir={gcc_dir}".strip()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.DEVNULL,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = _GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
|
|
@ -81,6 +83,8 @@ def _git_branch(repo_root: Path) -> str | None:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.DEVNULL,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = _GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import time
|
|||
from pathlib import Path
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.child_stdio import utf8_child_env
|
||||
from utils.hf_cache_settings import get_hf_cache_paths
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
|
|
@ -420,7 +421,7 @@ def _resolve_base_model(model_name: str) -> str:
|
|||
adapter_cfg_path = local_path / "adapter_config.json"
|
||||
if _safe_is_file(adapter_cfg_path):
|
||||
try:
|
||||
with open(adapter_cfg_path, encoding = "utf-8") as f:
|
||||
with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
|
||||
cfg = json.load(f)
|
||||
base = cfg.get("base_model_name_or_path")
|
||||
if base:
|
||||
|
|
@ -437,7 +438,7 @@ def _resolve_base_model(model_name: str) -> str:
|
|||
config_json_path = local_path / "config.json"
|
||||
if _safe_is_file(config_json_path):
|
||||
try:
|
||||
with open(config_json_path, encoding = "utf-8") as f:
|
||||
with open(config_json_path, encoding = "utf-8-sig") as f:
|
||||
cfg = json.load(f)
|
||||
# Unsloth writes model_name, HF writes _name_or_path; skip a self-reference.
|
||||
for _key in ("model_name", "_name_or_path"):
|
||||
|
|
@ -544,7 +545,7 @@ def _adapter_base_from_hf_cache(model_name: str) -> str | None:
|
|||
)
|
||||
for cfg_path in candidates:
|
||||
if cfg_path.is_file():
|
||||
base = json.loads(cfg_path.read_text(encoding = "utf-8")).get(
|
||||
base = json.loads(cfg_path.read_text(encoding = "utf-8-sig")).get(
|
||||
"base_model_name_or_path"
|
||||
)
|
||||
return base or None
|
||||
|
|
@ -616,7 +617,7 @@ def _check_tokenizer_config_needs_v5(model_name: str, hf_token: str | None = Non
|
|||
local_tc = local_path / "tokenizer_config.json"
|
||||
if _safe_is_file(local_tc):
|
||||
try:
|
||||
with open(local_tc, encoding = "utf-8") as f:
|
||||
with open(local_tc, encoding = "utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
tokenizer_class = data.get("tokenizer_class", "")
|
||||
result = tokenizer_class in _TRANSFORMERS_5_TOKENIZER_CLASSES
|
||||
|
|
@ -706,7 +707,7 @@ def _config_json_from_hf_cache(model_name: str) -> dict | None:
|
|||
)
|
||||
for cfg_path in candidates:
|
||||
if cfg_path.is_file():
|
||||
with open(cfg_path, encoding = "utf-8") as f:
|
||||
with open(cfg_path, encoding = "utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
except Exception as exc:
|
||||
logger.debug("HF cache config.json lookup failed for '%s': %s", model_name, exc)
|
||||
|
|
@ -731,7 +732,7 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No
|
|||
local_cfg = Path(model_name) / "config.json"
|
||||
if _safe_is_file(local_cfg):
|
||||
try:
|
||||
with open(local_cfg, encoding = "utf-8") as f:
|
||||
with open(local_cfg, encoding = "utf-8-sig") as f:
|
||||
cfg = json.load(f)
|
||||
_config_json_cache[cache_key] = cfg
|
||||
return cfg
|
||||
|
|
@ -1271,9 +1272,10 @@ def _probe_autoconfig(target_dir: str, model_name: str, hf_token: str | None) ->
|
|||
[sys.executable, "-c", _PROBE_CONFIG_SCRIPT, target_dir, model_name],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = _PROBE_TIMEOUT_SECS,
|
||||
env = env,
|
||||
env = utf8_child_env(env),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
@ -1811,7 +1813,11 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = utf8_child_env(
|
||||
get_hf_cache_paths().child_env(child_env_without_native_path_secret())
|
||||
),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
|
|
@ -1834,7 +1840,9 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = utf8_child_env(get_hf_cache_paths().child_env(child_env_without_native_path_secret())),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
|
|
@ -2079,7 +2087,7 @@ class SidecarSwapInProgress(RuntimeError):
|
|||
|
||||
def _read_swap_lock(path: Path) -> dict | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding = "utf-8"))
|
||||
data = json.loads(path.read_text(encoding = "utf-8-sig"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
|
@ -2120,7 +2128,7 @@ def try_begin_sidecar_swap(kind: str = "install") -> bool:
|
|||
break
|
||||
if fd is not None:
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
with os.fdopen(fd, "w", encoding = "utf-8") as f:
|
||||
f.write(
|
||||
json.dumps(
|
||||
{"pid": os.getpid(), "at": time.time(), "token": token, "kind": kind}
|
||||
|
|
@ -2466,7 +2474,11 @@ def _ensure_venv_llmcompressor_exists() -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = utf8_child_env(
|
||||
get_hf_cache_paths().child_env(child_env_without_native_path_secret())
|
||||
),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
last_out = result.stdout or ""
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
|
|||
PYPI_FAILURE_TTL_SECONDS = 60 * 60
|
||||
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
|
||||
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
|
||||
FAKE_UPDATE_ENV_VAR = "UNSLOTH_STUDIO_FAKE_UPDATE"
|
||||
|
||||
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
|
||||
|
||||
|
|
@ -107,11 +108,32 @@ def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
|
|||
)
|
||||
|
||||
|
||||
def _is_version(value: str) -> bool:
|
||||
try:
|
||||
Version(value)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_studio_update_status(current_version: str) -> dict[str, Any]:
|
||||
"""Return public, read-only update status for the web UI."""
|
||||
install_source = detect_install_source()
|
||||
disabled = os.environ.get(DISABLE_ENV_VAR) == "1"
|
||||
|
||||
if os.environ.get(DISABLE_ENV_VAR) == "1":
|
||||
# Dev-only: the popup is PyPI-install-only, so fake a version to review it
|
||||
# from a checkout. The documented opt-out still wins.
|
||||
forced_version = os.environ.get(FAKE_UPDATE_ENV_VAR, "").strip()
|
||||
if forced_version and not disabled and _is_version(forced_version):
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = forced_version,
|
||||
install_source = "pypi",
|
||||
update_available = True,
|
||||
can_show_web_notification = True,
|
||||
)
|
||||
|
||||
if disabled:
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = None,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ def hf_cache_snapshot_dir(model_name: str) -> Optional[Path]:
|
|||
snapshot = repo_dir / "snapshots" / commit
|
||||
if snapshot.is_dir():
|
||||
return snapshot
|
||||
# UnicodeDecodeError is a ValueError, not an OSError: a torn refs
|
||||
# file must keep meaning "not cached here", not fail the offline check.
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import urllib.request
|
|||
from typing import Callable
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.child_stdio import utf8_child_env
|
||||
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
|
@ -43,6 +44,8 @@ def has_blackwell_gpu() -> bool:
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.DEVNULL,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 10,
|
||||
env = child_env_without_native_path_secret(),
|
||||
)
|
||||
|
|
@ -102,8 +105,10 @@ def probe_torch_wheel_env(*, timeout: int | None = None) -> dict[str, str] | Non
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.PIPE,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = timeout,
|
||||
env = child_env_without_native_path_secret(),
|
||||
env = utf8_child_env(child_env_without_native_path_secret()),
|
||||
**windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
@ -201,6 +206,8 @@ def install_wheel(
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
env = child_env_without_native_path_secret(),
|
||||
)
|
||||
attempts.append(("uv", result))
|
||||
|
|
@ -213,7 +220,10 @@ def install_wheel(
|
|||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
# Make the Python child emit the UTF-8 we decode above.
|
||||
env = utf8_child_env(child_env_without_native_path_secret()),
|
||||
)
|
||||
attempts.append(("pip", result))
|
||||
return attempts
|
||||
|
|
|
|||
|
|
@ -121,7 +121,14 @@ def _installed_whisper_version(binary: Optional[str]) -> Optional[str]:
|
|||
if not binary:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run([binary, "--version"], capture_output = True, text = True, timeout = 20)
|
||||
proc = subprocess.run(
|
||||
[binary, "--version"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
encoding = "utf-8",
|
||||
errors = "replace",
|
||||
timeout = 20,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
m = re.search(r"v?(\d+\.\d+\.\d+)", (proc.stderr or "") + (proc.stdout or ""))
|
||||
|
|
|
|||
|
|
@ -214,7 +214,8 @@ function TauriUpdateLayer({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2">
|
||||
// Capped like the browser stack: the download panel shares it, so both must fit.
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex max-h-[calc(100dvh_-_2rem)] flex-col items-end gap-2">
|
||||
<UpdateBanner
|
||||
status={update.status}
|
||||
info={update.info}
|
||||
|
|
@ -223,6 +224,7 @@ function TauriUpdateLayer({
|
|||
isExternalServer={isExternalServer}
|
||||
updatePolicyMode={update.updatePolicyMode}
|
||||
manualReleaseUrl={update.manualReleaseUrl}
|
||||
releasePageUrl={update.releasePageUrl}
|
||||
positioned={false}
|
||||
onInstall={update.installUpdate}
|
||||
onDismiss={update.dismiss}
|
||||
|
|
@ -379,9 +381,11 @@ function TauriWrapper({ children }: { children: ReactNode }) {
|
|||
return (
|
||||
<>
|
||||
{children}
|
||||
{/* One bottom-right stack so overlays never overlap; they stack with a
|
||||
gap, download panel anchored at the corner with banners above. */}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2">
|
||||
{/* One bottom-right stack so overlays never overlap: download panel at the
|
||||
corner, banners above, each owning its width. */}
|
||||
{/* Capped to the viewport, or a long download list plus expanded notes
|
||||
pushes the top of the stack off screen. */}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex max-h-[calc(100dvh_-_2rem)] flex-col items-end gap-2">
|
||||
<WebUpdateBanner
|
||||
positioned={false}
|
||||
enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)}
|
||||
|
|
|
|||
|
|
@ -1226,7 +1226,9 @@ export function AppSidebar() {
|
|||
openNewChat(null);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-[6px] select-none transition-opacity",
|
||||
// min-w-0 so a narrow sidebar truncates the wordmark
|
||||
// instead of pushing the search icon over the logo.
|
||||
"flex min-w-0 items-center gap-[6px] select-none transition-opacity",
|
||||
chatDisabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
aria-label={t("shell.aria.home")}
|
||||
|
|
@ -1238,17 +1240,17 @@ export function AppSidebar() {
|
|||
<img
|
||||
src="/circle-logo-small.png"
|
||||
alt="Unsloth"
|
||||
className="h-[calc(26px+0.5rem*var(--ui-font-scale,1))] w-[calc(26px+0.5rem*var(--ui-font-scale,1))] rounded-full object-cover"
|
||||
className="h-[calc(26px+0.5rem*var(--ui-font-scale,1))] w-[calc(26px+0.5rem*var(--ui-font-scale,1))] shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span className="font-heading text-[calc(13px+0.5rem*var(--ui-font-scale,1))] font-semibold tracking-[0em] leading-none text-black dark:text-white dark:tracking-[0.02em]">
|
||||
<span className="truncate font-heading text-[calc(13px+0.5rem*var(--ui-font-scale,1))] font-semibold tracking-[0em] leading-none text-black dark:text-white dark:tracking-[0.02em]">
|
||||
unsloth
|
||||
</span>
|
||||
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[calc(0.5rem*var(--ui-font-scale,1))] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
|
||||
<span className="nav-badge ml-0.5 inline-flex shrink-0 items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[calc(0.5rem*var(--ui-font-scale,1))] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
|
||||
{t("shell.beta")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex shrink-0 items-center gap-0.25">
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
|
|
@ -1257,7 +1259,7 @@ export function AppSidebar() {
|
|||
useChatSearchStore.getState().open();
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="inline-flex h-[33px] w-[28px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={t("shell.navigation.search")}
|
||||
>
|
||||
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-icon" />
|
||||
|
|
@ -1281,7 +1283,7 @@ export function AppSidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="inline-flex h-[33px] w-[28px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={t("shell.aria.closeSidebar")}
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
|
|
@ -1325,10 +1327,10 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
{/* Uniform pl-1.5 pr-2 keeps every hover pill the same width, inset from the edge. */}
|
||||
{/* Uniform pl-1.5 pr-1.75 keeps every hover pill the same width, inset from the edge. */}
|
||||
<SidebarGroup
|
||||
className={cn(
|
||||
"group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 shrink-0 transition-[padding]",
|
||||
"group-data-[collapsible=icon]:px-0 pl-1.5 pr-1.75 shrink-0 transition-[padding]",
|
||||
showCompactMacBrand ? "pt-0" : "pt-[9px]",
|
||||
// Scrolled: New Chat is pinned, give a little gap below it.
|
||||
scrolled ? "pb-[5px]" : "pb-px",
|
||||
|
|
@ -1417,7 +1419,7 @@ export function AppSidebar() {
|
|||
scrolled && "is-scrolled",
|
||||
)}
|
||||
>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 py-0 shrink-0">
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-1.75 py-0 shrink-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
|
|
@ -1499,7 +1501,7 @@ export function AppSidebar() {
|
|||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent className="pl-1.5 pr-2">
|
||||
<SidebarGroupContent className="pl-1.5 pr-1.75">
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={TestTubeOutlineIcon}
|
||||
|
|
@ -1574,7 +1576,7 @@ export function AppSidebar() {
|
|||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent className="pl-1.5 pr-2">
|
||||
<SidebarGroupContent className="pl-1.5 pr-1.75">
|
||||
<SidebarMenu>
|
||||
{pinnedProjectRecords.map((project) => {
|
||||
const projectChats =
|
||||
|
|
@ -1721,7 +1723,7 @@ export function AppSidebar() {
|
|||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent className="pl-1.5 pr-2">
|
||||
<SidebarGroupContent className="pl-1.5 pr-1.75">
|
||||
<SidebarMenu>
|
||||
{recentChatItems.map((item) =>
|
||||
renderChatSidebarItem(item, "recent"),
|
||||
|
|
@ -1753,7 +1755,7 @@ export function AppSidebar() {
|
|||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent className="pl-1.5 pr-2">
|
||||
<SidebarGroupContent className="pl-1.5 pr-1.75">
|
||||
<SidebarMenu>
|
||||
{runItems.map((run) => {
|
||||
// Explicit selection wins. Otherwise highlight the active
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function LlamaUpdateBanner({
|
|||
className={cn(
|
||||
positioned
|
||||
? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]"
|
||||
: "pointer-events-auto w-full",
|
||||
: "pointer-events-auto w-[calc(100vw-2rem)] max-w-[400px]",
|
||||
)}
|
||||
data-testid="llama-update-banner"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ReleaseNotesPanel } from "@/components/update/release-notes-panel";
|
||||
import type {
|
||||
DesktopUpdatePolicyMode,
|
||||
RetainedUpdateFailure,
|
||||
|
|
@ -22,6 +23,8 @@ interface UpdateBannerProps {
|
|||
isExternalServer?: boolean;
|
||||
updatePolicyMode: DesktopUpdatePolicyMode;
|
||||
manualReleaseUrl: string | null;
|
||||
// Release page for this version, preferred over the generic changelog.
|
||||
releasePageUrl?: string | null;
|
||||
// false fills a shared overlay stack; true self-anchors.
|
||||
positioned?: boolean;
|
||||
onInstall: () => void;
|
||||
|
|
@ -30,6 +33,7 @@ interface UpdateBannerProps {
|
|||
}
|
||||
|
||||
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
||||
const LEADING_V = /^v/;
|
||||
|
||||
function formatVersion(version: string | null | undefined): string {
|
||||
if (!version) return "";
|
||||
|
|
@ -44,6 +48,7 @@ export function UpdateBanner({
|
|||
isExternalServer = false,
|
||||
updatePolicyMode,
|
||||
manualReleaseUrl,
|
||||
releasePageUrl = null,
|
||||
positioned = true,
|
||||
onInstall,
|
||||
onDismiss,
|
||||
|
|
@ -52,6 +57,8 @@ export function UpdateBanner({
|
|||
const [copying, setCopying] = useState(false);
|
||||
const [manualReport, setManualReport] = useState<string | null>(null);
|
||||
const [manualMessage, setManualMessage] = useState<string | null>(null);
|
||||
// Version whose notes are expanded; a new offer collapses the panel.
|
||||
const [notesVersion, setNotesVersion] = useState<string | null>(null);
|
||||
const showFailure = Boolean(lastFailure) && !dismissed;
|
||||
const showAvailable = status === "available" && !dismissed && !showFailure;
|
||||
const show = showFailure || (showAvailable && Boolean(info));
|
||||
|
|
@ -62,6 +69,11 @@ export function UpdateBanner({
|
|||
const currentVersion = formatVersion(info?.currentVersion);
|
||||
const latestVersion = formatVersion(info?.version);
|
||||
const Icon = showFailure ? CircleAlert : Download;
|
||||
// Keyed by the backend release, not the app's SemVer; headings drop the v.
|
||||
const notesTargetVersion =
|
||||
(info?.pypiVersion ?? info?.version)?.replace(LEADING_V, "") ?? null;
|
||||
const notesOpen =
|
||||
notesTargetVersion !== null && notesVersion === notesTargetVersion;
|
||||
|
||||
async function handleCopyDiagnostics() {
|
||||
setCopying(true);
|
||||
|
|
@ -94,13 +106,14 @@ export function UpdateBanner({
|
|||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
||||
className={cn(
|
||||
// Wider than the other overlays: notes preview plus three buttons.
|
||||
positioned
|
||||
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
|
||||
: "pointer-events-auto w-full",
|
||||
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]"
|
||||
: "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col",
|
||||
)}
|
||||
data-testid="tauri-update-banner"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
|
||||
<div className="relative flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
|
|
@ -160,7 +173,40 @@ export function UpdateBanner({
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
|
||||
{!showFailure && notesTargetVersion ? (
|
||||
<ReleaseNotesPanel
|
||||
version={notesTargetVersion}
|
||||
open={notesOpen}
|
||||
// Used only if CHANGELOG.md has no section for this version.
|
||||
fallbackMarkdown={info?.body ?? null}
|
||||
className="min-h-0 flex-1"
|
||||
releaseNotesUrl={releasePageUrl ?? manualReleaseUrl}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex flex-wrap items-center gap-x-1 gap-y-2",
|
||||
!showFailure && notesTargetVersion
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
)}
|
||||
>
|
||||
{!showFailure && notesTargetVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
// same type size as the action buttons
|
||||
className="-ml-2 h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
|
||||
onClick={() =>
|
||||
setNotesVersion(notesOpen ? null : notesTargetVersion)
|
||||
}
|
||||
aria-expanded={notesOpen}
|
||||
data-testid="tauri-update-release-notes-toggle"
|
||||
>
|
||||
{notesOpen ? "Hide release notes" : "Show release notes"}
|
||||
</Button>
|
||||
) : null}
|
||||
{showFailure ? (
|
||||
<>
|
||||
<Button
|
||||
|
|
@ -187,28 +233,31 @@ export function UpdateBanner({
|
|||
onClick={onInstall}
|
||||
disabled={installDisabled}
|
||||
>
|
||||
{isManualLinuxPackage ? "Open release page" : "Retry update"}
|
||||
{isManualLinuxPackage
|
||||
? "Open release page"
|
||||
: "Retry update"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
// wrap + right-align so the action pair stays together
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-auto rounded-full px-3 py-2 text-ui-13 font-medium text-foreground"
|
||||
className="h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Remind me later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-ui-13"
|
||||
className="-mr-1 h-auto whitespace-nowrap rounded-full px-3 py-2 text-ui-13"
|
||||
onClick={onInstall}
|
||||
disabled={installDisabled}
|
||||
>
|
||||
{isManualLinuxPackage ? "Open release page" : "Update"}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{manualMessage && (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
|
||||
import { useSidebarWidth } from "@/hooks/use-sidebar-width";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -110,9 +111,13 @@ export function WindowTitlebar({
|
|||
const [enabled] = useState(shouldUseCustomWindowTitlebar);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const { pinned, togglePinned } = useSidebarPin();
|
||||
// The titlebar sits outside the sidebar wrapper, so it cannot inherit
|
||||
// --sidebar-width. Read the resized width from the same store instead.
|
||||
const { width } = useSidebarWidth();
|
||||
const sidebarWidth = showSidebarSurface
|
||||
? pinned
|
||||
? "var(--studio-sidebar-expanded-width,17.5rem)"
|
||||
? // The live value only exists mid-drag; otherwise the committed width.
|
||||
`var(--studio-sidebar-live-width, ${width}px)`
|
||||
: "var(--studio-sidebar-collapsed-width,3rem)"
|
||||
: "0px";
|
||||
const contentBorderLeft = pinned ? `calc(${sidebarWidth} + 12px)` : "0px";
|
||||
|
|
|
|||
317
studio/frontend/src/components/ui/panel-resize-handle.tsx
Normal file
317
studio/frontend/src/components/ui/panel-resize-handle.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { getClientPlatform } from "@/components/tauri/window-titlebar"
|
||||
|
||||
/** Pointer travel (px) below which a drag counts as a plain click. */
|
||||
const DRAG_SLOP = 4
|
||||
/** A compatibility click lands immediately after pointer-up. */
|
||||
const CLICK_COMPAT_WINDOW_MS = 300
|
||||
/** Arrow-key resize step for keyboard users. */
|
||||
const RESIZE_STEP = 16
|
||||
|
||||
type DragState = {
|
||||
startX: number
|
||||
startWidth: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export type PanelResizeHandleProps = {
|
||||
/** Which edge of the panel the handle sits on. */
|
||||
edge: "left" | "right"
|
||||
open: boolean
|
||||
width: number
|
||||
/** Uncapped stored preference, so a capped drag does not lower it. */
|
||||
stored: number
|
||||
min: number
|
||||
max: number
|
||||
clamp: (px: number) => number
|
||||
setWidth: (px: number) => void
|
||||
resetWidth: () => void
|
||||
onToggle: () => void
|
||||
/** Element to paint the live width onto, and the property to paint. */
|
||||
target: () => HTMLElement | null
|
||||
cssVar: string
|
||||
/** Measured to start a drag from the rendered size when collapsed. */
|
||||
measure: () => number
|
||||
label: string
|
||||
toggleLabel: string
|
||||
/** Translated tooltip copy; the caller owns the translation layer. */
|
||||
collapseHint: string
|
||||
expandHint: string
|
||||
dragHint: string
|
||||
/** Shown in the tooltip when the panel has a toggle shortcut. */
|
||||
shortcut?: string
|
||||
dataSlot?: string
|
||||
className?: string
|
||||
/** Mirrors the live width onto :root for chrome outside the panel. */
|
||||
rootVar?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A draggable panel edge: drag to resize, click to collapse or expand. Arrow
|
||||
* keys resize, Home restores the default. The width is painted straight to the
|
||||
* target while dragging and only persisted on release.
|
||||
*/
|
||||
export function PanelResizeHandle({
|
||||
edge,
|
||||
open,
|
||||
width,
|
||||
stored,
|
||||
min,
|
||||
max,
|
||||
clamp,
|
||||
setWidth,
|
||||
resetWidth,
|
||||
onToggle,
|
||||
target,
|
||||
cssVar,
|
||||
measure,
|
||||
label,
|
||||
toggleLabel,
|
||||
collapseHint,
|
||||
expandHint,
|
||||
dragHint,
|
||||
shortcut,
|
||||
dataSlot = "panel-resize-handle",
|
||||
className,
|
||||
rootVar,
|
||||
}: PanelResizeHandleProps) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
const dragRef = React.useRef<DragState | null>(null)
|
||||
const [dragging, setDragging] = React.useState(false)
|
||||
const [hovered, setHovered] = React.useState(false)
|
||||
const [focused, setFocused] = React.useState(false)
|
||||
const [isMacPlatform] = React.useState(() => getClientPlatform().includes("mac"))
|
||||
const hint = shortcut ? shortcut.replace("Mod", isMacPlatform ? "⌘" : "Ctrl+") : null
|
||||
|
||||
// Cached on pointer down so no DOM walk per move.
|
||||
const targetRef = React.useRef<HTMLElement | null>(null)
|
||||
const frameRef = React.useRef(0)
|
||||
const pendingRef = React.useRef(0)
|
||||
// What the pointer asked for, before the viewport cap. Committing the capped
|
||||
// value instead would quietly downgrade a stored preference on a narrow window.
|
||||
const rawRef = React.useRef(0)
|
||||
// When a pointer sequence last ended. The browser's compatibility click
|
||||
// lands in the same tick, so only a click that close behind is a duplicate.
|
||||
// A timestamp cannot go stale the way an armed flag does: a genuine cancel
|
||||
// emits no click, and a later assistive-tech click still gets through.
|
||||
const handledAtRef = React.useRef(0)
|
||||
const committedRef = React.useRef(width)
|
||||
React.useEffect(() => {
|
||||
committedRef.current = width
|
||||
}, [width])
|
||||
|
||||
const paint = React.useCallback(
|
||||
(value: string) => {
|
||||
targetRef.current?.style.setProperty(cssVar, value)
|
||||
if (rootVar) {
|
||||
document.documentElement.style.setProperty(rootVar, value)
|
||||
}
|
||||
},
|
||||
[cssVar, rootVar],
|
||||
)
|
||||
|
||||
// Resizing relayouts the whole shell, and pointermove fires faster than the
|
||||
// display refreshes, so coalesce to one paint per frame.
|
||||
const paintWidth = React.useCallback(
|
||||
(px: number) => {
|
||||
pendingRef.current = px
|
||||
if (frameRef.current) return
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0
|
||||
paint(`${pendingRef.current}px`)
|
||||
})
|
||||
},
|
||||
[paint],
|
||||
)
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
// Only a sequence that actually started can produce a compatibility click.
|
||||
// This also runs as the effect cleanup, where no drag happened.
|
||||
if (dragRef.current) handledAtRef.current = Date.now()
|
||||
dragRef.current = null
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = 0
|
||||
}
|
||||
// Hand the property back to the committed value. A commit re-renders with
|
||||
// the new width; a cancel or a no-commit drag keeps DOM and store in step.
|
||||
paint(`${committedRef.current}px`)
|
||||
if (rootVar) document.documentElement.style.removeProperty(rootVar)
|
||||
targetRef.current?.removeAttribute("data-resizing")
|
||||
document.documentElement.removeAttribute("data-panel-resizing")
|
||||
targetRef.current = null
|
||||
setDragging(false)
|
||||
document.body.style.removeProperty("cursor")
|
||||
document.body.style.removeProperty("user-select")
|
||||
}, [paint, rootVar])
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
targetRef.current = target()
|
||||
// Collapsed: grow from the rendered size so the edge tracks the pointer.
|
||||
const start = open ? width : measure()
|
||||
dragRef.current = { startX: event.clientX, startWidth: start, moved: false }
|
||||
pendingRef.current = start
|
||||
rawRef.current = start
|
||||
targetRef.current?.setAttribute("data-resizing", "true")
|
||||
document.documentElement.setAttribute("data-panel-resizing", "true")
|
||||
setDragging(true)
|
||||
document.body.style.setProperty("cursor", "col-resize")
|
||||
document.body.style.setProperty("user-select", "none")
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
// A panel whose handle is on its left edge grows as the pointer moves left.
|
||||
const delta = (edge === "left" ? -1 : 1) * (event.clientX - drag.startX)
|
||||
if (!drag.moved && Math.abs(delta) < DRAG_SLOP) return
|
||||
drag.moved = true
|
||||
|
||||
const next = drag.startWidth + delta
|
||||
rawRef.current = next
|
||||
if (!open) {
|
||||
// Past the minimum, dragging the collapsed edge reopens it.
|
||||
if (next >= min) {
|
||||
paintWidth(clamp(next))
|
||||
onToggle()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Dragging inward stops at the minimum. Collapsing is click or the shortcut.
|
||||
paintWidth(clamp(next))
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
endDrag()
|
||||
|
||||
if (!drag.moved) {
|
||||
onToggle()
|
||||
return
|
||||
}
|
||||
// A drag below the minimum leaves the stored width alone.
|
||||
if (!open) return
|
||||
// Capped: the visible edge is already at the cap, so an outward pull cannot
|
||||
// express intent beyond it. Committing would silently lower the larger
|
||||
// hidden preference. A deliberate inward drag still commits.
|
||||
if (stored > max && rawRef.current >= max) return
|
||||
// Commit what was asked for, not the capped paint, so a drag on a narrow
|
||||
// window cannot shrink a larger stored preference. setWidth clamps.
|
||||
setWidth(rawRef.current)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
// The collapse/expand the label advertises, for keyboard users. Pointer-up
|
||||
// handles it for the mouse; a synthesized click never reaches it.
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
// preventDefault cancels the native click, so nothing follows to guard
|
||||
// against; arming here would swallow the next assistive-tech click.
|
||||
event.preventDefault()
|
||||
onToggle()
|
||||
return
|
||||
}
|
||||
const outward = edge === "left" ? "ArrowLeft" : "ArrowRight"
|
||||
const inward = edge === "left" ? "ArrowRight" : "ArrowLeft"
|
||||
if (event.key === outward || event.key === inward) {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
// Collapsed there is nothing to resize, so the outward arrow reopens.
|
||||
if (event.key === outward) onToggle()
|
||||
return
|
||||
}
|
||||
if (event.key === outward && stored > max && width >= max) return
|
||||
setWidth(width + (event.key === outward ? RESIZE_STEP : -RESIZE_STEP))
|
||||
return
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
resetWidth()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear a stuck cursor override if we unmount mid-drag.
|
||||
React.useEffect(() => endDrag, [endDrag])
|
||||
|
||||
return (
|
||||
<Tooltip open={(hovered || focused) && !dragging}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-slot={dataSlot}
|
||||
data-dragging={dragging || undefined}
|
||||
aria-label={open ? label : toggleLabel}
|
||||
{...(open ? { "aria-orientation": "vertical" as const } : {})}
|
||||
{...(open
|
||||
? { "aria-valuenow": width, "aria-valuemin": min, "aria-valuemax": max }
|
||||
: {})}
|
||||
role={open ? "separator" : "button"}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={endDrag}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => {
|
||||
// Switch and voice control activate by dispatching a bare click
|
||||
// with no pointer or key events, which nothing else here catches.
|
||||
if (Date.now() - handledAtRef.current < CLICK_COMPAT_WINDOW_MS) return
|
||||
onToggle()
|
||||
}}
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
onFocus={(event) => setFocused(event.target.matches(":focus-visible"))}
|
||||
onBlur={() => setFocused(false)}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-30 hidden w-2 touch-none select-none sm:block",
|
||||
edge === "left" ? "-left-1" : "-right-1",
|
||||
// `!` overrides the app-wide hand cursor on buttons.
|
||||
open
|
||||
? "cursor-col-resize!"
|
||||
: edge === "left"
|
||||
? "cursor-w-resize!"
|
||||
: "cursor-e-resize!",
|
||||
// Sits exactly on the panel border so hover recolours one line.
|
||||
"after:absolute after:inset-y-0 after:w-px after:bg-transparent after:transition-colors after:duration-150",
|
||||
edge === "left" ? "after:left-1" : "after:right-1",
|
||||
"hover:after:bg-sidebar-ring/25 data-dragging:after:bg-sidebar-ring/25",
|
||||
// The app zeroes the native outline on buttons, so mark focus here.
|
||||
"focus-visible:outline-none focus-visible:after:bg-sidebar-ring/60",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={edge === "left" ? "left" : "right"}
|
||||
align="center"
|
||||
className="tooltip-compact"
|
||||
>
|
||||
<span className="flex flex-col gap-px">
|
||||
<span>
|
||||
{open ? collapseHint : expandHint}
|
||||
{hint ? ` ${hint}` : ""}
|
||||
</span>
|
||||
<span className="opacity-70">{dragHint}</span>
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -24,13 +24,21 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelResizeHandle } from "@/components/ui/panel-resize-handle"
|
||||
import { useT } from "@/i18n"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
clampSidebarWidth,
|
||||
useSidebarWidth,
|
||||
} from "@/hooks/use-sidebar-width"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const SIDEBAR_WIDTH = "17.5rem"
|
||||
const SIDEBAR_WIDTH = `${SIDEBAR_WIDTH_DEFAULT}px`
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
|
|
@ -46,6 +54,11 @@ type SidebarContextProps = {
|
|||
pinned: boolean
|
||||
setPinned: (value: boolean) => void
|
||||
togglePinned: () => void
|
||||
width: number
|
||||
storedWidth: number
|
||||
maxWidth: number
|
||||
setWidth: (value: number) => void
|
||||
resetWidth: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
|
@ -80,6 +93,13 @@ function SidebarProvider({
|
|||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const {
|
||||
width,
|
||||
max: maxWidth,
|
||||
stored: storedWidth,
|
||||
setWidth,
|
||||
resetWidth,
|
||||
} = useSidebarWidth()
|
||||
|
||||
const prevIsMobileRef = React.useRef(isMobile)
|
||||
React.useEffect(() => {
|
||||
|
|
@ -163,8 +183,13 @@ function SidebarProvider({
|
|||
pinned,
|
||||
setPinned,
|
||||
togglePinned,
|
||||
width,
|
||||
storedWidth,
|
||||
maxWidth,
|
||||
setWidth,
|
||||
resetWidth,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned, width, storedWidth, maxWidth, setWidth, resetWidth]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -173,7 +198,8 @@ function SidebarProvider({
|
|||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
// The drag handle writes this same property live while resizing.
|
||||
"--sidebar-width": `${width}px`,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
|
|
@ -311,11 +337,64 @@ function Sidebar({
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
<SidebarResizeHandle side={side} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's draggable edge, over the shared panel handle.
|
||||
*/
|
||||
function SidebarResizeHandle({
|
||||
className,
|
||||
side = "left",
|
||||
}: {
|
||||
className?: string
|
||||
side?: "left" | "right"
|
||||
}) {
|
||||
const { open, toggleSidebar, width, storedWidth, maxWidth, setWidth, resetWidth } =
|
||||
useSidebar()
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<div ref={ref} className="contents">
|
||||
<PanelResizeHandle
|
||||
edge={side === "right" ? "left" : "right"}
|
||||
open={open}
|
||||
width={width}
|
||||
stored={storedWidth}
|
||||
min={SIDEBAR_WIDTH_MIN}
|
||||
max={maxWidth}
|
||||
clamp={clampSidebarWidth}
|
||||
setWidth={setWidth}
|
||||
resetWidth={resetWidth}
|
||||
onToggle={toggleSidebar}
|
||||
target={() =>
|
||||
ref.current?.closest<HTMLElement>('[data-slot="sidebar-wrapper"]') ?? null
|
||||
}
|
||||
cssVar="--sidebar-width"
|
||||
// The custom titlebar renders outside the wrapper and cannot inherit it.
|
||||
rootVar="--studio-sidebar-live-width"
|
||||
measure={() =>
|
||||
ref.current
|
||||
?.closest<HTMLElement>('[data-slot="sidebar-container"]')
|
||||
?.getBoundingClientRect().width ?? SIDEBAR_WIDTH_MIN
|
||||
}
|
||||
label={t("shell.aria.resizeSidebar")}
|
||||
toggleLabel={t("shell.aria.openSidebar")}
|
||||
collapseHint={t("shell.resize.collapse")}
|
||||
expandHint={t("shell.resize.expand")}
|
||||
dragHint={t("shell.resize.drag")}
|
||||
shortcut="ModB"
|
||||
dataSlot="sidebar-resize-handle"
|
||||
className={className}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
|
|
@ -777,6 +856,7 @@ export {
|
|||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarResizeHandle,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
|
|
|
|||
251
studio/frontend/src/components/update/release-notes-panel.tsx
Normal file
251
studio/frontend/src/components/update/release-notes-panel.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { MarkdownPreview } from "@/components/markdown/markdown-preview";
|
||||
import { useReleaseNotes } from "@/hooks/use-release-notes";
|
||||
import { resolveChangelogLinks } from "@/lib/changelog-links";
|
||||
import { releaseNotesPreview } from "@/lib/release-notes-preview";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
interface ReleaseNotesPanelProps {
|
||||
// Notes are looked up for this exact version only.
|
||||
version: string;
|
||||
// Collapsed previews the top bullets; expanded scrolls the full notes.
|
||||
open: boolean;
|
||||
// Desktop updater's body, used only if CHANGELOG.md has no section here.
|
||||
fallbackMarkdown?: string | null;
|
||||
releaseNotesUrl?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NOTES_LINK_CLASS =
|
||||
"shrink-0 whitespace-nowrap text-ui-11 font-medium text-foreground underline underline-offset-2";
|
||||
|
||||
function NotesMessage({
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-1 py-2">
|
||||
<p className="text-ui-11 text-muted-foreground">{children}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogLink({ href }: { href: string }): ReactElement {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={NOTES_LINK_CLASS}
|
||||
data-testid="update-release-notes-link"
|
||||
>
|
||||
Open changelog
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseNotesPanel({
|
||||
version,
|
||||
open,
|
||||
fallbackMarkdown = null,
|
||||
releaseNotesUrl = null,
|
||||
className,
|
||||
}: ReleaseNotesPanelProps): ReactElement | null {
|
||||
// Fetched with the popup: the collapsed preview needs the notes too.
|
||||
const { state, notes, retry } = useReleaseNotes({ version, enabled: true });
|
||||
const scrollRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The fallback stands in for "no section in the changelog", which the hook
|
||||
// reports as ready. An error is retryable, and the desktop fallback is the
|
||||
// updater's static blurb, so taking it there would hide Retry until cache expiry.
|
||||
const source = notes?.matched
|
||||
? notes.markdown
|
||||
: state === "error"
|
||||
? null
|
||||
: (fallbackMarkdown ?? null);
|
||||
// Notes target the repository, so relative links must point back at it.
|
||||
const markdown = useMemo(
|
||||
() => (source === null ? null : resolveChangelogLinks(source)),
|
||||
[source],
|
||||
);
|
||||
|
||||
// Notes that are only a code block or a table preview as nothing.
|
||||
const preview = useMemo(
|
||||
() => (markdown === null ? null : releaseNotesPreview(markdown)),
|
||||
[markdown],
|
||||
);
|
||||
|
||||
// Start at the top on expand, and again once async notes land.
|
||||
useEffect(() => {
|
||||
if (open && markdown && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [open, markdown]);
|
||||
|
||||
// Caller's URL wins: the API returns only the generic changelog, while the
|
||||
// desktop banner passes this version's release page.
|
||||
const notesUrl = releaseNotesUrl ?? notes?.releaseNotesUrl;
|
||||
const link = notesUrl ? <ChangelogLink href={notesUrl} /> : null;
|
||||
|
||||
// Nothing previewable yet or ever: keep the collapsed popup compact.
|
||||
if (
|
||||
!open &&
|
||||
(!markdown ||
|
||||
state === "loading" ||
|
||||
state === "idle" ||
|
||||
preview?.items.length === 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mt-3 flex min-h-0 flex-col", className)}
|
||||
data-testid="update-release-notes-panel"
|
||||
data-notes-state={state}
|
||||
data-notes-version={version}
|
||||
data-notes-open={open}
|
||||
>
|
||||
{/* borderless fill, lighter than the card in dark mode */}
|
||||
<div className="flex min-h-0 flex-col rounded-[14px] bg-muted/40 px-3 py-1 dark:bg-white/[0.06]">
|
||||
{markdown ? (
|
||||
open ? (
|
||||
<section
|
||||
ref={scrollRef}
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard-scrollable region
|
||||
tabIndex={0}
|
||||
aria-label={`Release notes for version ${version}`}
|
||||
// Long notes scroll here instead of pushing the buttons off screen.
|
||||
className="hover-scrollbar max-h-64 min-h-0 flex-1 overflow-y-auto overscroll-contain py-3 pr-1"
|
||||
data-testid="update-release-notes-scroll"
|
||||
>
|
||||
<MarkdownPreview
|
||||
markdown={markdown}
|
||||
// Streamdown ships headings at mt-6 and code at text-sm, and
|
||||
// clears max-width on descendants, so rescale and re-cap both.
|
||||
className="max-h-none overflow-visible border-0 bg-transparent p-0 text-ui-11 [&_[data-streamdown=link-safety-modal]>*]:max-w-md [&_img]:h-auto [&_img]:max-w-full [&>*:first-child]:mt-0 [&>*>*:first-child]:mt-0 [&_code]:text-[0.92em] [&_h1]:mt-4 [&_h1]:font-heading [&_h1]:text-ui-13 [&_h2]:mt-4 [&_h2]:font-heading [&_h2]:text-ui-13 [&_h3]:mt-4 [&_h3]:font-heading [&_h3]:text-ui-11 [&_pre]:text-[0.92em]"
|
||||
/>
|
||||
{notes?.truncated ? (
|
||||
<p className="mt-2 text-ui-10 text-muted-foreground/80">
|
||||
Notes truncated. See the full changelog.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : (
|
||||
<ReleaseNotesSummary preview={preview} />
|
||||
)
|
||||
) : (
|
||||
<NotesStatus
|
||||
state={state}
|
||||
version={version}
|
||||
link={link}
|
||||
retry={retry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{open && markdown && link ? (
|
||||
<div className="mt-2 flex justify-end px-1">{link}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsed view: the first few bullets, one line each where possible. */
|
||||
function ReleaseNotesSummary({
|
||||
preview,
|
||||
}: {
|
||||
preview: ReturnType<typeof releaseNotesPreview> | null;
|
||||
}): ReactElement | null {
|
||||
if (preview === null || preview.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const { items, remaining } = preview;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className="space-y-1 py-2 pr-1"
|
||||
data-testid="update-release-notes-summary"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
// Two releases can carry the same bullet text, so index is the key.
|
||||
key={`${index}-${item.lead}`}
|
||||
className="flex gap-1.5 text-ui-11 leading-snug text-muted-foreground"
|
||||
>
|
||||
<span aria-hidden="true" className="text-muted-foreground/60">
|
||||
•
|
||||
</span>
|
||||
<span className="line-clamp-2 min-w-0">
|
||||
{/* lead sentence carries the change */}
|
||||
<span className="font-medium text-foreground">{item.lead}</span>
|
||||
{item.rest ? <span> {item.rest}</span> : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{remaining > 0 ? (
|
||||
<li className="pl-3 text-ui-10 text-muted-foreground/70">
|
||||
+{remaining} more
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesStatus({
|
||||
state,
|
||||
version,
|
||||
link,
|
||||
retry,
|
||||
}: {
|
||||
state: ReturnType<typeof useReleaseNotes>["state"];
|
||||
version: string;
|
||||
link: ReactNode;
|
||||
retry: () => void;
|
||||
}): ReactElement {
|
||||
if (state === "loading" || state === "idle") {
|
||||
return <NotesMessage>Loading release notes...</NotesMessage>;
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return (
|
||||
<NotesMessage
|
||||
action={
|
||||
// The changelog page may be reachable when the lookup is not.
|
||||
<span className="flex shrink-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
className={NOTES_LINK_CLASS}
|
||||
data-testid="update-release-notes-retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
{link}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
Could not load release notes.
|
||||
</NotesMessage>
|
||||
);
|
||||
}
|
||||
|
||||
// Matched nothing: link out rather than show another release's notes.
|
||||
return (
|
||||
<NotesMessage action={link}>
|
||||
No release notes published for {version} yet.
|
||||
</NotesMessage>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ReleaseNotesPanel } from "@/components/update/release-notes-panel";
|
||||
import { type DeviceType, usePlatformStore } from "@/config/env";
|
||||
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
|
|
@ -40,6 +41,7 @@ export function WebUpdateBanner({
|
|||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const installCmd = installCommandForDevice(deviceType);
|
||||
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
|
||||
const [notesVersion, setNotesVersion] = useState<string | null>(null);
|
||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -68,6 +70,8 @@ export function WebUpdateBanner({
|
|||
}
|
||||
|
||||
const copied = status != null && copiedVersion === status.latestVersion;
|
||||
// Keyed by version so a new offer collapses the panel.
|
||||
const notesOpen = status != null && notesVersion === status.latestVersion;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
|
@ -78,13 +82,14 @@ export function WebUpdateBanner({
|
|||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
||||
className={cn(
|
||||
// Wider than the other overlays: notes preview plus three buttons.
|
||||
positioned
|
||||
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
|
||||
: "pointer-events-auto w-full",
|
||||
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]"
|
||||
: "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col",
|
||||
)}
|
||||
data-testid="web-update-banner"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
|
||||
<div className="relative flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
|
|
@ -127,22 +132,33 @@ export function WebUpdateBanner({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ReleaseNotesPanel
|
||||
version={status.latestVersion}
|
||||
open={notesOpen}
|
||||
releaseNotesUrl={RELEASE_NOTES_URL}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
|
||||
{/* one row at one type size; wraps only on narrow viewports */}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-y-2">
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="-ml-2 whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground transition-colors hover:bg-muted"
|
||||
data-testid="web-update-release-notes-link"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="-ml-2 h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
|
||||
onClick={() =>
|
||||
setNotesVersion(notesOpen ? null : status.latestVersion)
|
||||
}
|
||||
aria-expanded={notesOpen}
|
||||
data-testid="web-update-release-notes-toggle"
|
||||
>
|
||||
Release notes
|
||||
</a>
|
||||
{notesOpen ? "Hide release notes" : "Show release notes"}
|
||||
</Button>
|
||||
{/* wrap + right-align so buttons stack instead of clipping on very narrow banners */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-auto rounded-full px-3 py-2 text-ui-13 font-medium text-foreground"
|
||||
className="h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
|
||||
onClick={snooze}
|
||||
data-testid="web-update-snooze-button"
|
||||
>
|
||||
|
|
@ -151,7 +167,7 @@ export function WebUpdateBanner({
|
|||
<Button
|
||||
size="sm"
|
||||
// -mr optically aligns the filled pill's edge with the card padding
|
||||
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-ui-13"
|
||||
className="-mr-1 h-auto whitespace-nowrap rounded-full px-3 py-2 text-ui-13"
|
||||
onClick={handleCopyCommand}
|
||||
data-testid="web-update-copy-button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { InfoHint } from "@/components/ui/info-hint";
|
||||
import { PanelResizeHandle } from "@/components/ui/panel-resize-handle";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
|
|
@ -44,7 +45,13 @@ import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
|||
import { NumericValueInput, snapToStep } from "@/features/model-picker";
|
||||
import { RetrievalSettingsSection } from "@/features/rag";
|
||||
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
|
||||
import {
|
||||
CHAT_SETTINGS_WIDTH_MIN,
|
||||
clampChatSettingsWidth,
|
||||
useChatSettingsWidth,
|
||||
} from "@/hooks/use-chat-settings-width";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useT } from "@/i18n";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -52,7 +59,7 @@ import { Edit03Icon, LayoutAlignRightIcon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Braces, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { type CSSProperties, Fragment, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
|
||||
import { PermissionModeDropdown } from "./permission-mode-select";
|
||||
|
|
@ -363,6 +370,15 @@ export function ChatSettingsPanel({
|
|||
onExternalProviderChange,
|
||||
externalProviderType = null,
|
||||
}: ChatSettingsPanelProps) {
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const t = useT();
|
||||
const {
|
||||
width: settingsWidth,
|
||||
max: settingsMax,
|
||||
stored: settingsStored,
|
||||
setWidth: setSettingsWidth,
|
||||
resetWidth: resetSettingsWidth,
|
||||
} = useChatSettingsWidth();
|
||||
// Local models show every knob; providerCapabilities is only consulted when
|
||||
// isExternalModel. Unknown providers fall back to the OpenAI-compat shape via
|
||||
// getProviderCapabilities, so these flags never undercount support.
|
||||
|
|
@ -461,6 +477,23 @@ export function ChatSettingsPanel({
|
|||
// When the prompt overflows the inline box, clicking opens the popup editor.
|
||||
const systemPromptBoxRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [systemPromptOverflows, setSystemPromptOverflows] = useState(false);
|
||||
const promptObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const measurePromptRef = useRef<() => void>(() => {});
|
||||
// The section unmounts its textarea when collapsed, so observe through a
|
||||
// callback ref: a stored observer would cling to the detached node and the
|
||||
// remounted one would never be measured.
|
||||
const attachPromptBox = useCallback((node: HTMLTextAreaElement | null) => {
|
||||
systemPromptBoxRef.current = node;
|
||||
promptObserverRef.current?.disconnect();
|
||||
promptObserverRef.current = null;
|
||||
if (!node || typeof ResizeObserver === "undefined") return;
|
||||
// Resizing rewraps the prompt, and a drag changes the width through a
|
||||
// custom property without re-rendering, so watch the box itself.
|
||||
const observer = new ResizeObserver(() => measurePromptRef.current());
|
||||
observer.observe(node);
|
||||
promptObserverRef.current = observer;
|
||||
measurePromptRef.current();
|
||||
}, []);
|
||||
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
|
||||
const presets = useMemo(() => {
|
||||
return getOrderedPresets(customPresets);
|
||||
|
|
@ -746,15 +779,20 @@ export function ChatSettingsPanel({
|
|||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = systemPromptBoxRef.current;
|
||||
setSystemPromptOverflows(
|
||||
currentSystemPrompt.length > 0 &&
|
||||
el != null &&
|
||||
el.clientHeight > 0 &&
|
||||
el.scrollHeight > el.clientHeight + 1,
|
||||
);
|
||||
measurePromptRef.current = () => {
|
||||
const el = systemPromptBoxRef.current;
|
||||
setSystemPromptOverflows(
|
||||
currentSystemPrompt.length > 0 &&
|
||||
el != null &&
|
||||
el.clientHeight > 0 &&
|
||||
el.scrollHeight > el.clientHeight + 1,
|
||||
);
|
||||
};
|
||||
measurePromptRef.current();
|
||||
}, [currentSystemPrompt, open]);
|
||||
|
||||
useEffect(() => () => promptObserverRef.current?.disconnect(), []);
|
||||
|
||||
const settingsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const settingsContent = (
|
||||
|
|
@ -1124,7 +1162,7 @@ export function ChatSettingsPanel({
|
|||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={systemPromptBoxRef}
|
||||
ref={attachPromptBox}
|
||||
value={currentSystemPrompt}
|
||||
onChange={(e) => set("systemPrompt")(e.target.value)}
|
||||
onMouseDown={(e) => {
|
||||
|
|
@ -1433,17 +1471,47 @@ export function ChatSettingsPanel({
|
|||
|
||||
return (
|
||||
<aside
|
||||
ref={asideRef}
|
||||
data-tour="chat-settings"
|
||||
data-slot="chat-settings-panel"
|
||||
className={cn(
|
||||
"relative z-50 shrink-0 overflow-hidden bg-panel-surface text-panel-surface-fg font-heading",
|
||||
open ? "w-[17rem] border-l border-sidebar-border" : "w-0",
|
||||
"relative z-50 shrink-0 bg-panel-surface text-panel-surface-fg font-heading",
|
||||
open
|
||||
? "w-(--chat-settings-width) border-l border-sidebar-border"
|
||||
: "w-0 overflow-hidden",
|
||||
)}
|
||||
style={{
|
||||
height: "calc(100% - var(--studio-custom-titlebar-height, 0px))",
|
||||
marginTop: "var(--studio-custom-titlebar-height, 0px)",
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--chat-settings-width": `${settingsWidth}px`,
|
||||
height: "calc(100% - var(--studio-custom-titlebar-height, 0px))",
|
||||
marginTop: "var(--studio-custom-titlebar-height, 0px)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full">{settingsContent}</div>
|
||||
{open ? (
|
||||
<PanelResizeHandle
|
||||
edge="left"
|
||||
open={open}
|
||||
width={settingsWidth}
|
||||
stored={settingsStored}
|
||||
min={CHAT_SETTINGS_WIDTH_MIN}
|
||||
max={settingsMax}
|
||||
clamp={clampChatSettingsWidth}
|
||||
setWidth={setSettingsWidth}
|
||||
resetWidth={resetSettingsWidth}
|
||||
onToggle={() => onOpenChange?.(!open)}
|
||||
target={() => asideRef.current}
|
||||
cssVar="--chat-settings-width"
|
||||
measure={() => asideRef.current?.getBoundingClientRect().width ?? 0}
|
||||
label={t("shell.aria.resizeRunSettings")}
|
||||
toggleLabel={t("shell.aria.openRunSettings")}
|
||||
collapseHint={t("shell.resize.collapse")}
|
||||
expandHint={t("shell.resize.expand")}
|
||||
dragHint={t("shell.resize.drag")}
|
||||
dataSlot="chat-settings-resize-handle"
|
||||
/>
|
||||
) : null}
|
||||
<div className="h-full w-full overflow-hidden">{settingsContent}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { type KeyboardEvent, useState } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import type { ResearchWebsitePolicy } from "../types/research";
|
||||
|
|
@ -24,7 +25,12 @@ function normalizeDomain(raw: string): string | null {
|
|||
if (!value || /[\\\s]/.test(value)) return null;
|
||||
try {
|
||||
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
||||
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.port) {
|
||||
if (
|
||||
!/^https?:$/.test(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return url.hostname
|
||||
|
|
@ -99,7 +105,9 @@ function DomainList({
|
|||
type="button"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Remove ${domain}`}
|
||||
onClick={() => onChange(values.filter((value) => value !== domain))}
|
||||
onClick={() =>
|
||||
onChange(values.filter((value) => value !== domain))
|
||||
}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
|
|
@ -129,7 +137,9 @@ export function DeepResearchComposerButton({
|
|||
onConfigure: () => void;
|
||||
}) {
|
||||
const enabled = useChatRuntimeStore((state) => state.deepResearchEnabled);
|
||||
const setEnabled = useChatRuntimeStore((state) => state.setDeepResearchEnabled);
|
||||
const setEnabled = useChatRuntimeStore(
|
||||
(state) => state.setDeepResearchEnabled,
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
|
|
@ -158,9 +168,12 @@ export function DeepResearchComposerButton({
|
|||
<XIcon className="composer-pill-x" />
|
||||
</span>
|
||||
<span>Deep research</span>
|
||||
<span className="composer-pill-caret flex items-center gap-0.5 text-primary/70">
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</span>
|
||||
{/* Same caret as the other composer pills, so the arrows match. */}
|
||||
<HugeiconsIcon
|
||||
icon={ChevronDownStandardIcon}
|
||||
strokeWidth={1.5}
|
||||
className="composer-pill-caret size-[15px] text-primary/70"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -173,7 +186,9 @@ export function DeepResearchWebsiteAccessDialog({
|
|||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const policy = useChatRuntimeStore((state) => state.researchWebsitePolicy);
|
||||
const setPolicy = useChatRuntimeStore((state) => state.setResearchWebsitePolicy);
|
||||
const setPolicy = useChatRuntimeStore(
|
||||
(state) => state.setResearchWebsitePolicy,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -201,41 +216,40 @@ function DeepResearchWebsiteAccessContent({
|
|||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Website access</DialogTitle>
|
||||
<DialogDescription>
|
||||
Control which websites the next Deep Research run can search and
|
||||
read. Limits are enforced by the server and shared with the research
|
||||
model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<DomainList
|
||||
label="Allow only"
|
||||
description="When set, research can access only these domains and their subdomains."
|
||||
values={draft.allowedDomains}
|
||||
onChange={(allowedDomains) => setDraft({ ...draft, allowedDomains })}
|
||||
/>
|
||||
<DomainList
|
||||
label="Always block"
|
||||
description="These domains and their subdomains stay blocked. Blocking takes precedence."
|
||||
values={draft.blockedDomains}
|
||||
onChange={(blockedDomains) => setDraft({ ...draft, blockedDomains })}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPolicy(draft);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Save limits
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Website access</DialogTitle>
|
||||
<DialogDescription>
|
||||
Control which websites the next Deep Research run can search and read.
|
||||
Limits are enforced by the server and shared with the research model.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<DomainList
|
||||
label="Allow only"
|
||||
description="When set, research can access only these domains and their subdomains."
|
||||
values={draft.allowedDomains}
|
||||
onChange={(allowedDomains) => setDraft({ ...draft, allowedDomains })}
|
||||
/>
|
||||
<DomainList
|
||||
label="Always block"
|
||||
description="These domains and their subdomains stay blocked. Blocking takes precedence."
|
||||
values={draft.blockedDomains}
|
||||
onChange={(blockedDomains) => setDraft({ ...draft, blockedDomains })}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPolicy(draft);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Save limits
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,21 @@ import {
|
|||
removeScanFolder,
|
||||
} from "@/features/hub";
|
||||
import { FolderBrowser } from "@/features/model-picker";
|
||||
import { openModelsDir } from "@/features/native-intents";
|
||||
import {
|
||||
openModelsDir,
|
||||
pickHuggingFaceCacheDir,
|
||||
} from "@/features/native-intents";
|
||||
import {
|
||||
type HuggingFaceCacheSettings,
|
||||
loadHuggingFaceCacheSettings,
|
||||
updateHuggingFaceCacheSettings,
|
||||
} from "@/features/settings";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Delete02Icon,
|
||||
DownloadCircle01Icon,
|
||||
FileSearchIcon,
|
||||
FolderAddIcon,
|
||||
FolderExportIcon,
|
||||
|
|
@ -49,6 +58,12 @@ function formatError(error: unknown): string {
|
|||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function formatFreeSpace(bytes: number | null): string | null {
|
||||
if (bytes === null || !Number.isFinite(bytes)) return null;
|
||||
const gb = bytes / 1024 ** 3;
|
||||
return gb >= 10 ? `${Math.round(gb)} GB free` : `${gb.toFixed(1)} GB free`;
|
||||
}
|
||||
|
||||
export function OnDeviceFoldersDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -68,6 +83,11 @@ export function OnDeviceFoldersDialog({
|
|||
);
|
||||
const refreshIdRef = useRef(0);
|
||||
const mutationVersionRef = useRef(0);
|
||||
const [downloadCache, setDownloadCache] =
|
||||
useState<HuggingFaceCacheSettings | null>(null);
|
||||
const [downloadCacheLoaded, setDownloadCacheLoaded] = useState(false);
|
||||
const [downloadBrowserOpen, setDownloadBrowserOpen] = useState(false);
|
||||
const [downloadSaving, setDownloadSaving] = useState(false);
|
||||
|
||||
const sortedFolders = useMemo(
|
||||
() => [...folders].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
|
|
@ -108,10 +128,66 @@ export function OnDeviceFoldersDialog({
|
|||
return () => window.clearTimeout(timer);
|
||||
}, [open, refreshFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
// The dialog stays mounted between opens, so re-arm the flag or a reopen
|
||||
// shows the previous answer as if it were fresh.
|
||||
setDownloadCacheLoaded(false);
|
||||
loadHuggingFaceCacheSettings()
|
||||
// Indexed locations do not depend on this. Null drops the stale path
|
||||
// rather than offer Change against a location we could not confirm.
|
||||
.catch(() => null)
|
||||
.then((settings) => {
|
||||
if (cancelled) return;
|
||||
setDownloadCache(settings);
|
||||
setDownloadCacheLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleInventoryChanged = useCallback(() => {
|
||||
onInventoryChange?.();
|
||||
}, [onInventoryChange]);
|
||||
|
||||
// Relocating the cache changes which repos are on disk, but
|
||||
// updateHuggingFaceCacheSettings already bumps the inventory version, which
|
||||
// re-fetches every source. Refreshing here too would scan twice, since the
|
||||
// two rounds carry different version keys and cannot be deduplicated.
|
||||
const saveDownloadLocation = useCallback(async (nextPath: string | null) => {
|
||||
setDownloadSaving(true);
|
||||
try {
|
||||
const settings = await updateHuggingFaceCacheSettings(nextPath);
|
||||
setDownloadCache(settings);
|
||||
toast.success("Download location updated", {
|
||||
description: settings.cacheHome,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error("Couldn't update the download location", {
|
||||
description: formatError(err),
|
||||
});
|
||||
} finally {
|
||||
setDownloadSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changeDownloadLocation = useCallback(async () => {
|
||||
if (!isTauri) {
|
||||
setDownloadBrowserOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const picked = await pickHuggingFaceCacheDir();
|
||||
if (picked) await saveDownloadLocation(picked);
|
||||
} catch (err) {
|
||||
toast.error("Couldn't open the folder picker", {
|
||||
description: formatError(err),
|
||||
});
|
||||
}
|
||||
}, [saveDownloadLocation]);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
async (rawPath: string) => {
|
||||
const nextPath = rawPath.trim();
|
||||
|
|
@ -182,10 +258,10 @@ export function OnDeviceFoldersDialog({
|
|||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="gap-0 overflow-hidden p-0 sm:max-w-[620px] lg:max-w-[660px] xl:max-w-[680px] [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:top-3"
|
||||
className="flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[620px] lg:max-w-[660px] xl:max-w-[680px] [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:top-3"
|
||||
overlayClassName="bg-black/20 backdrop-blur-none"
|
||||
>
|
||||
<DialogHeader className="border-b border-border/60 px-5 py-4">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-4">
|
||||
<DialogTitle className="text-ui-15">
|
||||
On-device locations
|
||||
</DialogTitle>
|
||||
|
|
@ -195,7 +271,78 @@ export function OnDeviceFoldersDialog({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div className="rounded-[14px] border border-border/70 bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-ui-12 font-medium text-foreground">
|
||||
<HugeiconsIcon
|
||||
icon={DownloadCircle01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-3.5 text-muted-foreground"
|
||||
/>
|
||||
Download location
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
readOnly={true}
|
||||
aria-label="Model download location"
|
||||
value={
|
||||
downloadCache?.cacheHome ??
|
||||
(downloadCacheLoaded ? "Unknown" : "Loading...")
|
||||
}
|
||||
title={downloadCache?.cacheHome}
|
||||
className="field-soft h-9 min-w-0 flex-1 rounded-full px-3 font-mono text-ui-12"
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void changeDownloadLocation()}
|
||||
disabled={!downloadCache?.editable || downloadSaving}
|
||||
className="h-9 rounded-full px-3 text-ui-12p5"
|
||||
>
|
||||
{downloadSaving ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={FolderSearchIcon}
|
||||
strokeWidth={1.75}
|
||||
data-icon="inline-start"
|
||||
className="size-3.5"
|
||||
/>
|
||||
)}
|
||||
Change
|
||||
</Button>
|
||||
{downloadCache?.isCustom ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void saveDownloadLocation(null)}
|
||||
disabled={downloadSaving}
|
||||
className="h-9 rounded-full px-3 text-ui-12p5 text-muted-foreground"
|
||||
>
|
||||
Use default
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-ui-10p5 text-muted-foreground">
|
||||
{downloadCache?.source === "environment"
|
||||
? `Managed by the ${
|
||||
downloadCache.environmentVariable ?? "HF_HOME"
|
||||
} environment variable.`
|
||||
: [
|
||||
"New downloads only. Models already on disk stay where they are.",
|
||||
formatFreeSpace(downloadCache?.freeBytes ?? null),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[14px] border border-border/70 bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-ui-12 font-medium text-foreground">
|
||||
<HugeiconsIcon
|
||||
|
|
@ -425,6 +572,16 @@ export function OnDeviceFoldersDialog({
|
|||
onOpenChange={setBrowserOpen}
|
||||
onSelect={(selectedPath) => void handleAdd(selectedPath)}
|
||||
/>
|
||||
|
||||
<FolderBrowser
|
||||
open={!isTauri && downloadBrowserOpen}
|
||||
onOpenChange={setDownloadBrowserOpen}
|
||||
onSelect={(selectedPath) => void saveDownloadLocation(selectedPath)}
|
||||
initialPath={downloadCache?.cacheHome}
|
||||
title="Choose model download location"
|
||||
confirmLabel="Use for future downloads"
|
||||
showModelHints={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,8 +201,10 @@ export function DownloadManagerPanel({
|
|||
className={cn(
|
||||
// Standalone: anchor bottom-right. In a shared stack (positioned=false)
|
||||
// flow as a right-aligned row so overlays stack instead of overlapping.
|
||||
// min-h-0 there: a flex item's min-height defaults to auto, so the capped
|
||||
// stack would squeeze the update card instead of this list.
|
||||
"pointer-events-none",
|
||||
positioned ? "fixed bottom-4 right-4 z-50" : "flex justify-end",
|
||||
positioned ? "fixed bottom-4 right-4 z-50" : "flex min-h-0 justify-end",
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
|
|
@ -229,7 +231,7 @@ export function DownloadManagerPanel({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="hub-download-panel pointer-events-auto w-[min(400px,calc(100vw-2rem))] overflow-hidden">
|
||||
<div className="hub-download-panel pointer-events-auto flex min-h-0 w-[min(400px,calc(100vw-2rem))] flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-foreground/[0.07] py-2 pl-4 pr-3">
|
||||
<span className="min-w-0 flex-1 truncate text-ui-12p5 font-semibold text-foreground">
|
||||
{headerLabel}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
|
||||
export { SettingsDialog } from "./settings-dialog";
|
||||
export { loadEmbeddingModelSettings } from "./api/embedding-model";
|
||||
export {
|
||||
loadHuggingFaceCacheSettings,
|
||||
updateHuggingFaceCacheSettings,
|
||||
} from "./api/hugging-face-cache";
|
||||
export type { HuggingFaceCacheSettings } from "./api/hugging-face-cache";
|
||||
export {
|
||||
loadPersonalization,
|
||||
savePersonalization,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { SETTINGS_SEARCH_INDEX } from "./settings-search";
|
||||
import {
|
||||
SETTINGS_SEARCH_INDEX,
|
||||
SETTINGS_SEARCH_KEYWORDS,
|
||||
} from "./settings-search";
|
||||
import {
|
||||
type SettingsTab,
|
||||
useSettingsDialogStore,
|
||||
|
|
@ -157,8 +160,12 @@ export function SettingsDialog() {
|
|||
return TABS.map((tab) => {
|
||||
const tabLabel = t(tab.labelKey);
|
||||
const entries = SETTINGS_SEARCH_INDEX[tab.id]
|
||||
.map((key) => t(key))
|
||||
.filter((label) => label.toLowerCase().includes(q));
|
||||
.filter((key) => {
|
||||
if (t(key).toLowerCase().includes(q)) return true;
|
||||
const keywordsKey = SETTINGS_SEARCH_KEYWORDS[key];
|
||||
return keywordsKey ? t(keywordsKey).toLowerCase().includes(q) : false;
|
||||
})
|
||||
.map((key) => t(key));
|
||||
const deduped = [...new Set(entries)];
|
||||
return {
|
||||
tab,
|
||||
|
|
|
|||
|
|
@ -146,3 +146,15 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
"settings.about.shutDownStudio",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra terms a row matches on, beyond its own label. The value is a
|
||||
* translation key holding space-separated synonyms; it is never rendered.
|
||||
* Search matched labels only, so "models folder" or "directory" found nothing.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_KEYWORDS: Partial<
|
||||
Record<TranslationKey, TranslationKey>
|
||||
> = {
|
||||
"settings.resources.storage.modelsFolder":
|
||||
"settings.resources.storage.modelsFolderKeywords",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ const PREFS_KEYS: string[] = [
|
|||
LOCALE_STORAGE_KEY,
|
||||
// UI state
|
||||
"sidebar_pinned",
|
||||
"sidebar_width",
|
||||
"chat_settings_width",
|
||||
"unsloth_sidebar_navigate_open",
|
||||
"unsloth_settings_active_tab",
|
||||
// Chat runtime prefs
|
||||
|
|
|
|||
20
studio/frontend/src/hooks/use-chat-settings-width.ts
Normal file
20
studio/frontend/src/hooks/use-chat-settings-width.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { createPanelWidthStore } from "./use-panel-width.ts";
|
||||
|
||||
/** The previous fixed 17rem, at a 16px root font size. */
|
||||
export const CHAT_SETTINGS_WIDTH_DEFAULT = 272;
|
||||
/** Below this the sliders and their value pills start colliding. */
|
||||
export const CHAT_SETTINGS_WIDTH_MIN = 248;
|
||||
export const CHAT_SETTINGS_WIDTH_MAX = 560;
|
||||
|
||||
const store = createPanelWidthStore({
|
||||
key: "chat_settings_width",
|
||||
min: CHAT_SETTINGS_WIDTH_MIN,
|
||||
max: CHAT_SETTINGS_WIDTH_MAX,
|
||||
fallback: CHAT_SETTINGS_WIDTH_DEFAULT,
|
||||
});
|
||||
|
||||
export const clampChatSettingsWidth = store.clamp;
|
||||
export const useChatSettingsWidth = store.useWidth;
|
||||
138
studio/frontend/src/hooks/use-panel-width.ts
Normal file
138
studio/frontend/src/hooks/use-panel-width.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
/** Never let one panel eat more than this share of a narrow window. */
|
||||
const MAX_VIEWPORT_FRACTION = 0.4;
|
||||
|
||||
export type PanelWidthStore = {
|
||||
/** Clamps to what the current viewport allows. */
|
||||
clamp: (px: number) => number;
|
||||
useWidth: () => {
|
||||
width: number;
|
||||
max: number;
|
||||
/** The uncapped stored preference. */
|
||||
stored: number;
|
||||
setWidth: (value: number) => void;
|
||||
resetWidth: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A persisted, viewport-aware width for a draggable panel. The preference is
|
||||
* stored whole and an effective width is derived from it, so narrowing the
|
||||
* window shrinks the panel without losing what the user picked.
|
||||
*/
|
||||
export function createPanelWidthStore({
|
||||
key,
|
||||
min,
|
||||
max,
|
||||
fallback,
|
||||
}: {
|
||||
key: string;
|
||||
min: number;
|
||||
max: number;
|
||||
fallback: number;
|
||||
}): PanelWidthStore {
|
||||
function maxWidth(): number {
|
||||
if (typeof window === "undefined") return max;
|
||||
// The floor wins on a narrow window; collapsing is the escape.
|
||||
return Math.max(min, Math.min(max, window.innerWidth * MAX_VIEWPORT_FRACTION));
|
||||
}
|
||||
|
||||
/** Clamps to the absolute range, ignoring the viewport. */
|
||||
function clampStored(px: number): number {
|
||||
if (!Number.isFinite(px)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(px)));
|
||||
}
|
||||
|
||||
function clamp(px: number): number {
|
||||
return Math.min(maxWidth(), clampStored(px));
|
||||
}
|
||||
|
||||
function load(): number {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return clampStored(Number.parseFloat(raw));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
let storedWidth = load();
|
||||
let effectiveWidth = clamp(storedWidth);
|
||||
let effectiveMax = maxWidth();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let lastStored = storedWidth;
|
||||
|
||||
function recompute() {
|
||||
const nextWidth = clamp(storedWidth);
|
||||
const nextMax = maxWidth();
|
||||
if (
|
||||
nextWidth === effectiveWidth &&
|
||||
nextMax === effectiveMax &&
|
||||
storedWidth === lastStored
|
||||
) {
|
||||
return;
|
||||
}
|
||||
effectiveWidth = nextWidth;
|
||||
effectiveMax = nextMax;
|
||||
lastStored = storedWidth;
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
// With no subscribers there is no resize listener, so the cache can be
|
||||
// stale after a resize on a route that hides every panel. Refresh first;
|
||||
// useSyncExternalStore re-reads the snapshot right after subscribing.
|
||||
recompute();
|
||||
listeners.add(cb);
|
||||
if (typeof window === "undefined") {
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
// Keep tabs in sync, same as the pin flag.
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === key || e.key === null) {
|
||||
storedWidth = load();
|
||||
effectiveWidth = clamp(storedWidth);
|
||||
effectiveMax = maxWidth();
|
||||
cb();
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("resize", recompute);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("resize", recompute);
|
||||
};
|
||||
}
|
||||
|
||||
function setWidthGlobal(next: number) {
|
||||
const stored = clampStored(next);
|
||||
if (stored !== storedWidth) {
|
||||
storedWidth = stored;
|
||||
try {
|
||||
window.localStorage.setItem(key, String(stored));
|
||||
} catch {}
|
||||
}
|
||||
recompute();
|
||||
}
|
||||
|
||||
function useWidth() {
|
||||
const width = useSyncExternalStore(subscribe, () => effectiveWidth, () => fallback);
|
||||
// What the viewport actually allows right now, for aria-valuemax.
|
||||
const panelMax = useSyncExternalStore(subscribe, () => effectiveMax, () => max);
|
||||
// The uncapped preference, so a capped drag can avoid lowering it.
|
||||
const preference = useSyncExternalStore(subscribe, () => storedWidth, () => fallback);
|
||||
const setWidth = useCallback((value: number) => setWidthGlobal(value), []);
|
||||
const resetWidth = useCallback(() => setWidthGlobal(fallback), []);
|
||||
return { width, max: panelMax, stored: preference, setWidth, resetWidth };
|
||||
}
|
||||
|
||||
return { clamp, useWidth };
|
||||
}
|
||||
146
studio/frontend/src/hooks/use-release-notes.ts
Normal file
146
studio/frontend/src/hooks/use-release-notes.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch, hasAuthToken } from "@/features/auth";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Keyed to one exact version, so a new update never pairs with older notes.
|
||||
export interface ReleaseNotes {
|
||||
version: string;
|
||||
markdown: string | null;
|
||||
matched: boolean;
|
||||
truncated: boolean;
|
||||
source: string | null;
|
||||
releaseNotesUrl: string | null;
|
||||
// Set when the lookup itself failed, as opposed to a version with no notes.
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type ReleaseNotesState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
// Desktop auto-auth installs its token after first paint, so a startup popup can
|
||||
// ask before one exists. Wait briefly rather than fail.
|
||||
const AUTH_POLL_MS = 250;
|
||||
const AUTH_POLL_LIMIT = 40;
|
||||
|
||||
interface UseReleaseNotesOptions {
|
||||
version: string | null | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
type ApiObject = Record<string, unknown>;
|
||||
|
||||
function stringOrNull(value: ApiObject, key: string): string | null {
|
||||
const field = value[key];
|
||||
return typeof field === "string" && field.length > 0 ? field : null;
|
||||
}
|
||||
|
||||
function toReleaseNotes(value: unknown, version: string): ReleaseNotes | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const payload = value as ApiObject;
|
||||
const notesVersion = stringOrNull(payload, "version");
|
||||
// A response for another version is not usable here.
|
||||
if (notesVersion !== version) {
|
||||
return null;
|
||||
}
|
||||
const markdown = stringOrNull(payload, "markdown");
|
||||
return {
|
||||
version,
|
||||
markdown,
|
||||
matched: payload.matched === true && markdown !== null,
|
||||
truncated: payload.truncated === true,
|
||||
source: stringOrNull(payload, "source"),
|
||||
releaseNotesUrl: stringOrNull(payload, "release_notes_url"),
|
||||
error: stringOrNull(payload, "error"),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchReleaseNotes(
|
||||
version: string,
|
||||
refresh = false,
|
||||
): Promise<ReleaseNotes | null> {
|
||||
const query = `version=${encodeURIComponent(version)}${refresh ? "&refresh=true" : ""}`;
|
||||
// authFetch, not fetch: an expired token is refreshed and retried.
|
||||
const res = await authFetch(apiUrl(`/api/studio/release-notes?${query}`));
|
||||
if (!res.ok) {
|
||||
throw new Error(`Release notes request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return toReleaseNotes(await res.json(), version);
|
||||
}
|
||||
|
||||
export function useReleaseNotes({
|
||||
version,
|
||||
enabled = true,
|
||||
}: UseReleaseNotesOptions) {
|
||||
const [state, setState] = useState<ReleaseNotesState>("idle");
|
||||
const [notes, setNotes] = useState<ReleaseNotes | null>(null);
|
||||
// Version the current state belongs to; a change invalidates it.
|
||||
const requestedVersionRef = useRef<string | null>(null);
|
||||
// Identifies one request, so an earlier response cannot overwrite a later one.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const load = useCallback((target: string, refresh = false) => {
|
||||
requestedVersionRef.current = target;
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
setState("loading");
|
||||
setNotes(null);
|
||||
fetchReleaseNotes(target, refresh)
|
||||
.then((next) => {
|
||||
// A newer request owns the state now.
|
||||
if (requestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setNotes(next);
|
||||
// A reported failure is retryable; "no notes for this version" is not.
|
||||
const failed = !next || (!next.matched && next.error !== null);
|
||||
setState(failed ? "error" : "ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestIdRef.current === requestId) {
|
||||
setNotes(null);
|
||||
setState("error");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !version || requestedVersionRef.current === version) {
|
||||
return;
|
||||
}
|
||||
if (hasAuthToken()) {
|
||||
load(version);
|
||||
return;
|
||||
}
|
||||
let attempts = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
if (hasAuthToken() || attempts >= AUTH_POLL_LIMIT) {
|
||||
window.clearInterval(timer);
|
||||
// Out of patience: load anyway so the panel settles on retry.
|
||||
load(version);
|
||||
}
|
||||
}, AUTH_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [enabled, version, load]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (version) {
|
||||
requestedVersionRef.current = null;
|
||||
// Bypass the cached remote failure, or retry waits for it to expire.
|
||||
load(version, true);
|
||||
}
|
||||
}, [version, load]);
|
||||
|
||||
// Never hand back another version's notes: state lags `version` by a render.
|
||||
const matchesVersion = notes !== null && notes.version === version;
|
||||
return {
|
||||
state: notes !== null && !matchesVersion ? "loading" : state,
|
||||
notes: matchesVersion ? notes : null,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
21
studio/frontend/src/hooks/use-sidebar-width.ts
Normal file
21
studio/frontend/src/hooks/use-sidebar-width.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { createPanelWidthStore } from "./use-panel-width.ts";
|
||||
|
||||
/** The previous fixed 17.5rem, at a 16px root font size. */
|
||||
export const SIDEBAR_WIDTH_DEFAULT = 280;
|
||||
/** Narrowest width that still fits the wordmark. Firefox is the constraint:
|
||||
* it renders the heading ~3px wider than Chromium and WebKit. */
|
||||
export const SIDEBAR_WIDTH_MIN = 260;
|
||||
export const SIDEBAR_WIDTH_MAX = 480;
|
||||
|
||||
const store = createPanelWidthStore({
|
||||
key: "sidebar_width",
|
||||
min: SIDEBAR_WIDTH_MIN,
|
||||
max: SIDEBAR_WIDTH_MAX,
|
||||
fallback: SIDEBAR_WIDTH_DEFAULT,
|
||||
});
|
||||
|
||||
export const clampSidebarWidth = store.clamp;
|
||||
export const useSidebarWidth = store.useWidth;
|
||||
|
|
@ -21,6 +21,8 @@ export type UpdateStatus =
|
|||
export interface UpdateInfo {
|
||||
version: string;
|
||||
currentVersion: string;
|
||||
// Backend release this build pins; CHANGELOG.md is keyed by it, not the SemVer.
|
||||
pypiVersion?: string;
|
||||
body?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
|
@ -42,10 +44,17 @@ interface DesktopUpdatePolicy {
|
|||
interface ManualUpdateInfo {
|
||||
version: string;
|
||||
currentVersion: string;
|
||||
pypiVersion?: string | null;
|
||||
body?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/** `pypi_version` from latest.json, which the updater passes through raw. */
|
||||
function rawPypiVersion(raw: Record<string, unknown>): string | undefined {
|
||||
const value = raw.pypi_version;
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export interface RetainedUpdateFailure {
|
||||
error: string;
|
||||
phase: UpdatePhase;
|
||||
|
|
@ -162,6 +171,7 @@ export function useTauriUpdate(isExternalServer = false) {
|
|||
setInfo({
|
||||
version: manualUpdate.version,
|
||||
currentVersion: manualUpdate.currentVersion,
|
||||
pypiVersion: manualUpdate.pypiVersion ?? undefined,
|
||||
body: manualUpdate.body,
|
||||
date: manualUpdate.date,
|
||||
});
|
||||
|
|
@ -197,6 +207,7 @@ export function useTauriUpdate(isExternalServer = false) {
|
|||
setInfo({
|
||||
version: update.version,
|
||||
currentVersion: update.currentVersion,
|
||||
pypiVersion: rawPypiVersion(update.rawJson),
|
||||
body: update.body,
|
||||
date: update.date,
|
||||
});
|
||||
|
|
@ -384,10 +395,13 @@ export function useTauriUpdate(isExternalServer = false) {
|
|||
});
|
||||
}
|
||||
|
||||
// Install target for Linux packages that cannot self-update.
|
||||
const manualReleaseUrl =
|
||||
updatePolicy.mode === "manual_linux_package" && info
|
||||
? manualReleasePageUrl(updatePolicy, info.version)
|
||||
: null;
|
||||
// Release page for the offered version, on every platform, for the notes link.
|
||||
const releasePageUrl = info ? manualReleasePageUrl(updatePolicy, info.version) : null;
|
||||
|
||||
return {
|
||||
status,
|
||||
|
|
@ -401,6 +415,7 @@ export function useTauriUpdate(isExternalServer = false) {
|
|||
isExternalServer,
|
||||
updatePolicyMode: updatePolicy.mode,
|
||||
manualReleaseUrl,
|
||||
releasePageUrl,
|
||||
installUpdate,
|
||||
retryUpdate,
|
||||
skipAndRestart,
|
||||
|
|
|
|||
|
|
@ -27,10 +27,18 @@ export const ar = {
|
|||
product: "Unsloth Studio",
|
||||
accountMenu: "قائمة حساب {name}",
|
||||
updateAvailable: "يتوفر تحديث",
|
||||
resize: {
|
||||
collapse: "انقر للطي",
|
||||
expand: "انقر للتوسيع",
|
||||
drag: "اسحب لتغيير الحجم",
|
||||
},
|
||||
aria: {
|
||||
home: "الصفحة الرئيسية لـ Unsloth",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
openSidebar: "فتح الشريط الجانبي",
|
||||
resizeSidebar: "تغيير حجم الشريط الجانبي أو طيه",
|
||||
resizeRunSettings: "تغيير حجم إعدادات التشغيل أو إغلاقها",
|
||||
openRunSettings: "فتح إعدادات التشغيل",
|
||||
chatOptions: "خيارات المحادثة",
|
||||
runOptions: "خيارات التدريب",
|
||||
},
|
||||
|
|
@ -317,6 +325,8 @@ export const ar = {
|
|||
diskUsage: "{used} مستخدم / {total}",
|
||||
diskFree: "{free} متاح",
|
||||
modelsFolder: "مجلد النماذج",
|
||||
modelsFolderKeywords:
|
||||
"النماذج مجلد دليل مسار موقع تنزيلات التنزيل ذاكرة التخزين المؤقت تخزين قرص محرك نقل تغيير models folder path hugging face",
|
||||
modelsFolderDescription: "المكان الذي تُخزَّن فيه النماذج المُنزَّلة.",
|
||||
openAction: "فتح",
|
||||
copyAction: "نسخ المسار",
|
||||
|
|
|
|||
|
|
@ -27,10 +27,18 @@ export const de = {
|
|||
product: "Unsloth Studio",
|
||||
accountMenu: "Kontomenü von {name}",
|
||||
updateAvailable: "Update verfügbar",
|
||||
resize: {
|
||||
collapse: "Zum Einklappen klicken",
|
||||
expand: "Zum Ausklappen klicken",
|
||||
drag: "Zum Ändern der Größe ziehen",
|
||||
},
|
||||
aria: {
|
||||
home: "Unsloth Startseite",
|
||||
closeSidebar: "Seitenleiste schließen",
|
||||
openSidebar: "Seitenleiste öffnen",
|
||||
resizeSidebar: "Seitenleiste anpassen oder einklappen",
|
||||
resizeRunSettings: "Ausführungseinstellungen anpassen oder schließen",
|
||||
openRunSettings: "Ausführungseinstellungen öffnen",
|
||||
chatOptions: "Chat-Optionen",
|
||||
runOptions: "Trainingslauf-Optionen",
|
||||
},
|
||||
|
|
@ -330,9 +338,23 @@ export const de = {
|
|||
diskFree: "{free} frei",
|
||||
modelsFolder: "Modell-Ordner",
|
||||
modelsFolderDescription:
|
||||
"Wo heruntergeladene Modelle gespeichert werden.",
|
||||
"Wo heruntergeladene Modelle gespeichert werden. Ändern Sie ihn, um Modelle nicht auf dem Systemlaufwerk abzulegen.",
|
||||
modelsFolderKeywords:
|
||||
"Modelle Ordner Verzeichnis Pfad Speicherort Download Downloads Cache Speicher Festplatte Laufwerk verschieben ändern hugging face",
|
||||
futureDownloads: "Nur neue Downloads",
|
||||
environmentManaged:
|
||||
"Wird über die Umgebungsvariable {variable} verwaltet.",
|
||||
locationFree: "{free} frei",
|
||||
openAction: "Öffnen",
|
||||
copyAction: "Pfad kopieren",
|
||||
changeAction: "Ändern",
|
||||
resetAction: "Standard verwenden",
|
||||
chooseTitle: "Speicherort für Modell-Downloads wählen",
|
||||
chooseAction: "Für künftige Downloads verwenden",
|
||||
cacheSaved: "Speicherort für Modell-Downloads aktualisiert",
|
||||
cacheSaveError:
|
||||
"Der Speicherort für Modell-Downloads konnte nicht geändert werden",
|
||||
cachePickerError: "Die Ordnerauswahl konnte nicht geöffnet werden",
|
||||
copied: "Pfad kopiert",
|
||||
openError: "Der Ordner konnte nicht geöffnet werden",
|
||||
copyError: "Der Pfad konnte nicht kopiert werden",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue