fix(scripts): push_predictions 支持 JWT 个人访问令牌

服务器上的 Label Studio 关闭了旧式 token(jwt_auth_jwtsettings.legacy_api_tokens_enabled=0),
用 authtoken_token 里的 key 直接请求返回 401。个人访问令牌是 JWT refresh token,
要先 POST /api/token/refresh/ 换成 access token 再以 Bearer 发送。

脚本按令牌形态自动区分:三段点分的按 JWT 换取,否则沿用旧式 Token。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-22 12:13:20 +08:00
co-authored by Claude Opus 5
parent dce2eef0a7
commit 9ead8c0a6e
2 changed files with 71 additions and 2 deletions
+28 -2
View File
@@ -15,6 +15,10 @@
export LABEL_STUDIO_API_KEY=xxxx export LABEL_STUDIO_API_KEY=xxxx
新版 Label Studio 默认只认 **JWT 个人访问令牌**(组织设置里 `legacy_api_tokens_enabled=false`)。
脚本会自动识别:令牌形如 JWT(两个点分三段)时先用 `/api/token/refresh/` 换取 access token
并以 `Bearer` 发送,否则按旧式 `Token` 发送。
图片路径从任务的 `/data/local-files/?d=...` 还原,所以 `--document-root` 必须和 图片路径从任务的 `/data/local-files/?d=...` 还原,所以 `--document-root` 必须和
Label Studio 的 `LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 一致。 Label Studio 的 `LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 一致。
@@ -49,7 +53,25 @@ class LabelStudio:
self.url = url.rstrip("/") self.url = url.rstrip("/")
self.session = requests.Session() self.session = requests.Session()
self.session.headers["Authorization"] = f"Token {token}" self.session.headers["Authorization"] = self._authorization(token)
def _authorization(self, token: str) -> str:
"""区分两种令牌。
新版默认关闭了旧式 `Token xxx`(组织设置 legacy_api_tokens_enabled=false),
个人访问令牌是一个 JWT refresh token,要先换成 access token 再以 Bearer 发送。
"""
if token.count(".") != 2: # 不是 JWT,按旧式 Token 处理
return f"Token {token}"
response = self.session.post(
f"{self.url}/api/token/refresh/", json={"refresh": token}, timeout=60
)
if response.status_code >= 400:
raise RuntimeError(
f"用个人访问令牌换取 access token 失败(HTTP {response.status_code}):"
f"{response.text[:200]}"
)
return f"Bearer {response.json()['access']}"
def tasks(self, project: int, page_size: int = 500) -> list[dict]: def tasks(self, project: int, page_size: int = 500) -> list[dict]:
"""列出项目里的全部任务(只取 id 与 data,分页拉完)。""" """列出项目里的全部任务(只取 id 与 data,分页拉完)。"""
@@ -179,7 +201,11 @@ def main() -> int:
logger.error("%s", exc) logger.error("%s", exc)
return 1 return 1
client = LabelStudio(args.url, args.token) try:
client = LabelStudio(args.url, args.token)
except RuntimeError as exc:
logger.error("%s", exc)
return 1
logger.info("拉取项目 %d 的任务列表…", args.project) logger.info("拉取项目 %d 的任务列表…", args.project)
tasks = client.tasks(args.project) tasks = client.tasks(args.project)
if args.limit: if args.limit:
+43
View File
@@ -33,6 +33,10 @@ class _FakeResponse:
self._payload = payload self._payload = payload
self.status_code = status self.status_code = status
@property
def text(self):
return str(self._payload)
def json(self): def json(self):
return self._payload return self._payload
@@ -86,3 +90,42 @@ def test_tasks_stops_on_empty_page(client):
client.session = _FakeSession([{"tasks": [{"id": 1}]}, {"tasks": []}]) client.session = _FakeSession([{"tasks": [{"id": 1}]}, {"tasks": []}])
assert [t["id"] for t in client.tasks(39)] == [1] assert [t["id"] for t in client.tasks(39)] == [1]
assert len(client.session.calls) == 2 assert len(client.session.calls) == 2
# ---------------- 两种令牌 ----------------
class _TokenSession:
def __init__(self, access="ACCESS", status=200):
self.headers = {}
self.access, self.status, self.posted = access, status, []
def post(self, url, json=None, timeout=None):
self.posted.append((url, json))
return _FakeResponse({"access": self.access}, self.status)
def test_legacy_token_is_sent_as_token(monkeypatch):
client = pp.LabelStudio.__new__(pp.LabelStudio)
client.url = "http://ls"
client.session = _TokenSession()
assert client._authorization("c657261234567890") == "Token c657261234567890"
assert client.session.posted == [] # 旧式令牌不需要换取
def test_jwt_personal_token_is_exchanged_for_a_bearer(monkeypatch):
"""新版默认禁用旧式 token,个人访问令牌要先换成 access token。"""
client = pp.LabelStudio.__new__(pp.LabelStudio)
client.url = "http://ls"
client.session = _TokenSession(access="AAA")
assert client._authorization("aaa.bbb.ccc") == "Bearer AAA"
assert client.session.posted[0][0] == "http://ls/api/token/refresh/"
assert client.session.posted[0][1] == {"refresh": "aaa.bbb.ccc"}
def test_failed_exchange_reports_clearly():
client = pp.LabelStudio.__new__(pp.LabelStudio)
client.url = "http://ls"
client.session = _TokenSession(status=401)
with pytest.raises(RuntimeError, match="access token 失败"):
client._authorization("aaa.bbb.ccc")