Catches upstream API drift early — before a PyPI release breaks user
workloads. For each tracked package + version, fetch the relevant
source files from raw.githubusercontent.com and grep for the symbols
unsloth + unsloth-zoo monkey-patch, subclass, or eval-import. No pip
install required, CPU-only, runs PR-time + daily cron.
Files:
- tests/vllm_compat/test_vllm_pinned_symbols.py
extend VLLM_TAGS from {0.9.0..0.15.0} to include
{0.16.0, 0.17.1, 0.18.1, 0.19.1, 0.20.1, main}.
- tests/version_compat/_fetch.py
shared fetch + grep helpers (fetch_text / has_def / first_match).
- tests/version_compat/test_trl_grpo_pinned_symbols.py
12 TRL tags (0.18.2 -> v1.3.0 + main) covering the supported
window (pyproject pin trl>=0.18.2,!=0.19.0,<=0.24.0) plus
above-cap canaries. Asserts:
* top-level GRPOTrainer / GRPOConfig / SFTTrainer / SFTConfig
re-exports (used by `from trl import X`)
* trl.trainer.grpo_trainer.GRPOTrainer class
* trl.trainer.grpo_config.GRPOConfig (or grpo_trainer.py fallback)
* DataCollatorForPreference reachable from EITHER dpo_trainer or
utils (rl_replacements.py:318 string-emits the dpo_trainer path)
* trl.trainer.utils.pad (rl_replacements.py:326)
* unwrap_model_for_generation in any known submodule
(rl.py:152-155 try/except handles both)
* trl.experimental.openenv (gated; rl_replacements.py:1765-1770)
* trl.generation.vllm_generation (gated; rl_replacements.py:1846)
* trl.__version__ exported via literal / submodule / metadata
- tests/version_compat/test_peft_pinned_symbols.py
5 PEFT tags (0.18.0 -> 0.19.1 + main). Asserts:
* top-level LoraConfig / get_peft_model / PeftModel
* peft.tuners.lora.LoraConfig at canonical path
* get_peft_model in mapping.py / mapping_func.py
(peft 0.18 split this out)
* peft.tuners.lora.LoraLayer
* peft.tuners.lora.bnb (Linear4bit / Linear8bitLt)
- tests/version_compat/test_sentence_transformers_pinned_symbols.py
6 ST tags (5.0.0 -> 5.4.1 + main). Handles BOTH layouts:
legacy (< 5.4): sentence_transformers/models[.py|/__init__.py]
modular (>= 5.4): classes under
sentence_transformers/base/modules/*
sentence_transformers/sentence_transformer/modules/*
Plus verifies the deprecated-import shim
(`setup_deprecated_module_imports`) is wired in __init__.py
so `from sentence_transformers.models import Pooling` keeps
working for unsloth/models/sentence_transformer.py.
- tests/version_compat/test_bitsandbytes_pinned_symbols.py
4 bnb tags (0.45.5 -> 0.49.2 + main; skip the broken 0.46.0 /
0.48.0 listed in pyproject !=). Asserts:
* bnb.functional.{dequantize_4bit, quantize_4bit}
* bnb.nn.{Linear4bit, Params4bit}
- .github/workflows/version-compat-ci.yml
7 jobs:
* vllm-pinned-symbols (existing tests/vllm_compat/, now wired)
* trl-grpo-pinned-symbols
* peft-pinned-symbols
* st-pinned-symbols
* bitsandbytes-pinned-symbols
* zoo-imports-under-spoof (real pip install + CUDA spoof,
unsloth_zoo.{rl_replacements, empty_model, vllm_utils,
vllm_lora_*} import smoke)
* daily-fresh-fetch (cron-only superset)
Triggers: pull_request (paths), daily 06:43 UTC, workflow_dispatch.
Authenticated GitHub raw fetches (GITHUB_TOKEN) for the 5000 req/h
quota.
Smoke-tested locally: 226 pass, 15 skipped (gated optional features).
72 lines
2.7 KiB
Python
72 lines
2.7 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team.
|
|
"""Shared helpers for the version-compat suites: fetch a file from
|
|
GitHub raw at a specific tag/branch, and grep for class / def / module
|
|
symbols without ast.parse so a single non-importable line doesn't
|
|
false-fail us. Mirrors tests/vllm_compat/test_vllm_pinned_symbols.py.
|
|
|
|
Used by:
|
|
- tests/version_compat/test_trl_grpo_pinned_symbols.py
|
|
- tests/version_compat/test_peft_pinned_symbols.py
|
|
- tests/version_compat/test_sentence_transformers_pinned_symbols.py
|
|
- tests/version_compat/test_bitsandbytes_pinned_symbols.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import pytest
|
|
|
|
|
|
def fetch_text(repo: str, ref: str, path: str) -> str | None:
|
|
"""Fetch a file from GitHub raw. None on 404 (the path was renamed
|
|
or removed in this version, which is informational and the caller
|
|
decides whether that's fatal). Skips the test on transient network
|
|
errors so we don't make CI flaky."""
|
|
url = f"https://raw.githubusercontent.com/{repo}/{ref}/{path}"
|
|
req = urllib.request.Request(url)
|
|
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
if token:
|
|
req.add_header("Authorization", f"Bearer {token}")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout = 15) as r:
|
|
return r.read().decode("utf-8", errors = "replace")
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
return None
|
|
pytest.skip(f"GitHub fetch failed ({e.code}) for {url}")
|
|
except (urllib.error.URLError, TimeoutError) as e:
|
|
pytest.skip(f"GitHub fetch failed ({e}) for {url}")
|
|
|
|
|
|
def has_def(src: str, name: str, kind: str = "any") -> bool:
|
|
"""Heuristic AST-equivalent grep for `class Name`, `def name`,
|
|
or `Name = ...` at module scope. We avoid a full ast.parse so a
|
|
single non-importable line (e.g. `# type: ignore` after an
|
|
unresolved alias) doesn't false-fail us."""
|
|
if kind in ("any", "class") and re.search(
|
|
rf"^class\s+{re.escape(name)}\b", src, re.MULTILINE
|
|
):
|
|
return True
|
|
if kind in ("any", "func") and re.search(
|
|
rf"^(?:async\s+)?def\s+{re.escape(name)}\b", src, re.MULTILINE
|
|
):
|
|
return True
|
|
if kind == "any" and re.search(rf"^{re.escape(name)}\s*[:=]", src, re.MULTILINE):
|
|
return True
|
|
return False
|
|
|
|
|
|
def first_match(repo: str, ref: str, paths: list[str]) -> tuple[str, str] | None:
|
|
"""Try a list of candidate paths; return (path, src) for the first
|
|
one that exists, or None if none do. Useful when upstream split or
|
|
moved a module across versions."""
|
|
for p in paths:
|
|
src = fetch_text(repo, ref, p)
|
|
if src is not None:
|
|
return (p, src)
|
|
return None
|