feat(yarn_client): auth config for YARN REST (none/simple/basic/kerberos)

Add per-Connection authentication so CDH 5 / Kerberos / HTTP Basic
clusters can be queried.

- auth_type: none / simple / basic / kerberos
- auth_user / auth_password for HTTP Basic
- auth_principal / auth_keytab stored for audit/display; actual SPNEGO
  handled by httpx-kerberos using the system Kerberos credential cache
- YarnClientConfig.auth_for_httpx() returns the right httpx.Auth object
- _request passes auth= through to httpx.request alongside verify=

New dependency: httpx-kerberos.

Tests cover none/simple (no auth object), Basic auth header,
Kerberos auth object, missing basic user, invalid auth_type, and
tool-layer config propagation.
This commit is contained in:
Claude
2026-06-26 13:57:57 +08:00
parent ad557b5984
commit 10f075ab7f
9 changed files with 256 additions and 8 deletions
+1
View File
@@ -7,6 +7,7 @@ dependencies = [
"fastapi-mcp>=0.4.0",
"gunicorn>=23.0",
"httpx>=0.28.1",
"httpx-kerberos>=0.1.2",
"loguru>=0.7.3",
"pydantic>=2.13.4",
"pydantic-core>=2.46.4",
+32 -8
View File
@@ -21,6 +21,7 @@ import json
from dataclasses import dataclass
import httpx
import httpx_kerberos
from common.config import settings
from common.logging import logger
@@ -47,6 +48,11 @@ class YarnClientConfig:
yarn_rm_url: str | None
ssl_verify: bool | None = None
ssl_ca_bundle: str | None = None
auth_type: str = "none"
auth_user: str | None = None
auth_password: str | None = None
auth_principal: str | None = None # display/audit only for kerberos
auth_keytab: str | None = None # display/audit only for kerberos
@classmethod
def from_connection(cls, conn: Connection) -> "YarnClientConfig":
@@ -54,6 +60,11 @@ class YarnClientConfig:
yarn_rm_url=conn.yarn_rm_url,
ssl_verify=conn.ssl_verify,
ssl_ca_bundle=conn.ssl_ca_bundle,
auth_type=conn.auth_type,
auth_user=conn.auth_user,
auth_password=conn.auth_password,
auth_principal=conn.auth_principal,
auth_keytab=conn.auth_keytab,
)
def verify_for_httpx(self) -> bool | str:
@@ -70,6 +81,18 @@ class YarnClientConfig:
return self.ssl_verify
return settings.ssl_verify_default
def auth_for_httpx(self) -> httpx.Auth | None:
"""Return the httpx.Auth object implied by this config, if any."""
if self.auth_type in ("none", "simple"):
return None
if self.auth_type == "basic":
if not self.auth_user:
raise YarnConfigError("auth_type='basic' requires auth_user")
return httpx.BasicAuth(self.auth_user, self.auth_password or "")
if self.auth_type == "kerberos":
return httpx_kerberos.HTTPKerberosAuth()
raise YarnConfigError(f"Unknown auth_type: {self.auth_type!r}")
def _base_url(yarn_rm_url: str | None) -> str:
"""Resolve and validate the RM URL. Raises YarnConfigError if unusable."""
@@ -89,10 +112,11 @@ def _base_url(yarn_rm_url: str | None) -> str:
def _request(method: str, url: str, *, json_body: dict | None = None,
timeout: float = 30.0, verify: bool | str = True) -> httpx.Response:
timeout: float = 30.0, verify: bool | str = True,
auth: httpx.Auth | None = None) -> httpx.Response:
logger.debug(f"YARN {method} {url}" + (f" body={json_body}" if json_body else ""))
try:
resp = httpx.request(method, url, json=json_body, timeout=timeout, verify=verify)
resp = httpx.request(method, url, json=json_body, timeout=timeout, verify=verify, auth=auth)
except httpx.HTTPError as exc:
logger.error(f"YARN {method} {url} failed: {exc}")
raise YarnError(f"YARN connection failed: {exc}") from exc
@@ -106,7 +130,7 @@ def _request(method: str, url: str, *, json_body: dict | None = None,
def get_application_status(application_id: str, config: YarnClientConfig) -> tuple[str, str]:
"""Return (state, raw_json_text) for an application, or raise YarnError."""
url = f"{_base_url(config.yarn_rm_url)}/ws/v1/cluster/apps/{application_id}"
resp = _request("GET", url, verify=config.verify_for_httpx())
resp = _request("GET", url, verify=config.verify_for_httpx(), auth=config.auth_for_httpx())
if resp.status_code == 404:
raise YarnError(f"YARN application {application_id!r} not found")
if resp.status_code >= 400:
@@ -140,7 +164,7 @@ def _fetch_logs_via_nodemanager(application_id: str, config: YarnClientConfig) -
# Latest (or all) app attempts.
attempts_url = f"{app_url}/appattempts"
resp = _request("GET", attempts_url, timeout=30.0, verify=config.verify_for_httpx())
resp = _request("GET", attempts_url, timeout=30.0, verify=config.verify_for_httpx(), auth=config.auth_for_httpx())
if resp.status_code >= 400:
raise _logs_unavailable_error(application_id)
attempts = resp.json().get("appAttempts", {}).get("appAttempt", [])
@@ -154,7 +178,7 @@ def _fetch_logs_via_nodemanager(application_id: str, config: YarnClientConfig) -
if not attempt_id:
continue
containers_url = f"{app_url}/appattempts/{attempt_id}/containers"
resp = _request("GET", containers_url, timeout=30.0, verify=config.verify_for_httpx())
resp = _request("GET", containers_url, timeout=30.0, verify=config.verify_for_httpx(), auth=config.auth_for_httpx())
if resp.status_code >= 400:
raise _logs_unavailable_error(application_id)
containers.extend(resp.json().get("containers", {}).get("container", []))
@@ -171,7 +195,7 @@ def _fetch_logs_via_nodemanager(application_id: str, config: YarnClientConfig) -
continue
scheme = "https" if config.yarn_rm_url and config.yarn_rm_url.startswith("https://") else "http"
nm_url = f"{scheme}://{node_http_address}/node/containerlogs/{container_id}/{user}/"
resp = _request("GET", nm_url, timeout=60.0, verify=config.verify_for_httpx())
resp = _request("GET", nm_url, timeout=60.0, verify=config.verify_for_httpx(), auth=config.auth_for_httpx())
if resp.status_code >= 400:
raise _logs_unavailable_error(application_id)
parts.append(f"=== container: {container_id} ===\n{resp.text}")
@@ -184,7 +208,7 @@ def _fetch_logs_via_nodemanager(application_id: str, config: YarnClientConfig) -
def get_application_logs(application_id: str, config: YarnClientConfig) -> str:
"""Return aggregated container logs for an application as text."""
url = f"{_base_url(config.yarn_rm_url)}/ws/v1/cluster/apps/{application_id}/aggregated-logs"
resp = _request("GET", url, timeout=60.0, verify=config.verify_for_httpx())
resp = _request("GET", url, timeout=60.0, verify=config.verify_for_httpx(), auth=config.auth_for_httpx())
if resp.status_code in (404, 501):
return _fetch_logs_via_nodemanager(application_id, config)
if resp.status_code >= 400:
@@ -197,7 +221,7 @@ def get_application_logs(application_id: str, config: YarnClientConfig) -> str:
def kill_application(application_id: str, config: YarnClientConfig) -> None:
"""PUT state=KILLED to /ws/v1/cluster/apps/{appid}/state."""
url = f"{_base_url(config.yarn_rm_url)}/ws/v1/cluster/apps/{application_id}/state"
resp = _request("PUT", url, json_body={"state": "KILLED"}, verify=config.verify_for_httpx())
resp = _request("PUT", url, json_body={"state": "KILLED"}, verify=config.verify_for_httpx(), auth=config.auth_for_httpx())
if resp.status_code >= 400:
logger.error(f"YARN PUT {url} -> {resp.status_code}: {resp.text[:500]}")
raise YarnError(f"YARN kill returned HTTP {resp.status_code}: {resp.text}")
+16
View File
@@ -53,6 +53,13 @@ class Connection(BaseModel):
# overrides the global default for this connection.
ssl_verify: bool | None = None
ssl_ca_bundle: str | None = None
# Authentication for YARN REST calls.
auth_type: str = "none" # "none" | "simple" | "basic" | "kerberos"
auth_user: str | None = None
auth_password: str | None = None
# Display/audit only for kerberos; actual SPNEGO uses the system cache.
auth_principal: str | None = None
auth_keytab: str | None = None
@field_validator("master")
@classmethod
@@ -67,6 +74,15 @@ class Connection(BaseModel):
f"or 'local[/N]'; got {v!r}"
)
@field_validator("auth_type")
@classmethod
def _check_auth_type(cls, v: str) -> str:
if v not in {"none", "simple", "basic", "kerberos"}:
raise ValueError(
f"auth_type must be one of none/simple/basic/kerberos; got {v!r}"
)
return v
class PendingSubmission(BaseModel):
pending_id: str
+10
View File
@@ -17,6 +17,11 @@ def save_connection(
spark_conf: dict[str, str] | None = None,
ssl_verify: bool | None = None,
ssl_ca_bundle: str | None = None,
auth_type: str = "none",
auth_user: str | None = None,
auth_password: str | None = None,
auth_principal: str | None = None,
auth_keytab: str | None = None,
) -> dict[str, str]:
logger.debug(
f"save_connection enter name={name} master={master} deploy_mode={deploy_mode} "
@@ -30,6 +35,11 @@ def save_connection(
spark_conf=spark_conf or {},
ssl_verify=ssl_verify,
ssl_ca_bundle=ssl_ca_bundle,
auth_type=auth_type,
auth_user=auth_user,
auth_password=auth_password,
auth_principal=auth_principal,
auth_keytab=auth_keytab,
)
store.save(conn)
return {"name": name, "status": "SAVED"}
+5
View File
@@ -24,6 +24,11 @@ class SaveConnectionRequest(BaseModel):
spark_conf: dict[str, str] | None = None
ssl_verify: bool | None = None
ssl_ca_bundle: str | None = None
auth_type: str = "none"
auth_user: str | None = None
auth_password: str | None = None
auth_principal: str | None = None
auth_keytab: str | None = None
class PrepareSubmitJobRequest(BaseModel):
+23
View File
@@ -81,6 +81,29 @@ def test_save_connection_preserves_ssl_fields(_fresh_store):
assert out["ssl_ca_bundle"] == "/tmp/ca.crt"
def test_save_connection_with_basic_auth(_fresh_store):
connections.save_connection(
name="secure",
master="yarn",
auth_type="basic",
auth_user="hdfs",
auth_password="secret",
)
out = connections.get_connection("secure")
assert out["auth_type"] == "basic"
assert out["auth_user"] == "hdfs"
assert out["auth_password"] == "secret"
def test_save_connection_invalid_auth_type_raises(_fresh_store):
with pytest.raises(ValueError, match="auth_type must be one of none/simple/basic/kerberos"):
connections.save_connection(
name="bad",
master="yarn",
auth_type="oauth",
)
def test_get_connection_unknown_raises(_fresh_store):
with pytest.raises(KeyError):
connections.get_connection("missing")
+36
View File
@@ -8,6 +8,7 @@ from spark_executor.core.job_store import JobStore
from spark_executor.core import connection_store
from spark_executor.models import Connection, Job
from spark_executor.tools import connections, status
from spark_executor.core.yarn_client import YarnClientConfig
def _fresh_stores():
@@ -67,3 +68,38 @@ def test_get_job_status_raises_when_connection_missing():
)
with pytest.raises(KeyError, match="Connection not found"):
status.get_job_status("abc")
def test_get_job_status_passes_auth_config():
_fresh_stores()
status.conn_store.save(
Connection(
name="secure",
master="yarn",
yarn_rm_url="http://rm:8088",
auth_type="basic",
auth_user="hdfs",
auth_password="secret",
)
)
status.store.put(
Job(
job_id="abc",
application_id="application_1",
script_path="/tmp/j.py",
queue="default",
submit_time=datetime(2026, 6, 24),
connection="secure",
yarn_rm_url="http://rm:8088",
)
)
with patch(
"spark_executor.tools.status.get_application_status",
return_value=("RUNNING", "State : RUNNING\n"),
) as m:
status.get_job_status("abc")
args = m.call_args.args
assert args[0] == "application_1"
assert isinstance(args[1], YarnClientConfig)
assert args[1].auth_type == "basic"
assert args[1].auth_user == "hdfs"
+47
View File
@@ -1,4 +1,6 @@
# coding=utf-8
import base64
from unittest.mock import patch
import httpx
@@ -323,3 +325,48 @@ def test_ssl_verify_false_without_ca_bundle_uses_global_default():
with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m:
get_application_status("application_1", cfg)
assert m.call_args.kwargs["verify"] is False
# --- authentication configuration ---
def test_status_no_auth_for_none():
fake = _resp(200, json_data={"app": {"state": "RUNNING"}})
with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m:
get_application_status("application_1", YarnClientConfig(yarn_rm_url=RM, auth_type="none"))
assert m.call_args.kwargs["auth"] is None
def test_status_basic_auth_header():
fake = _resp(200, json_data={"app": {"state": "RUNNING"}})
cfg = YarnClientConfig(
yarn_rm_url=RM,
auth_type="basic",
auth_user="alice",
auth_password="pw",
)
with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m:
get_application_status("application_1", cfg)
auth = m.call_args.kwargs["auth"]
assert isinstance(auth, httpx.BasicAuth)
encoded = auth._auth_header.split(" ", 1)[1]
assert base64.b64decode(encoded).decode() == "alice:pw"
def test_status_no_auth_for_simple():
fake = _resp(200, json_data={"app": {"state": "RUNNING"}})
with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m:
get_application_status("application_1", YarnClientConfig(yarn_rm_url=RM, auth_type="simple"))
assert m.call_args.kwargs["auth"] is None
def test_status_kerberos_auth():
fake = _resp(200, json_data={"app": {"state": "RUNNING"}})
cfg = YarnClientConfig(yarn_rm_url=RM, auth_type="kerberos")
with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m:
get_application_status("application_1", cfg)
auth = m.call_args.kwargs["auth"]
assert auth is not None
assert "Kerberos" in type(auth).__name__
def test_basic_auth_missing_user_raises():
cfg = YarnClientConfig(yarn_rm_url=RM, auth_type="basic")
with pytest.raises(YarnConfigError, match="auth_type='basic' requires auth_user"):
get_application_status("application_1", cfg)
Generated
+86
View File
@@ -258,6 +258,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
name = "decorator"
version = "5.3.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
]
[[package]]
name = "fastapi"
version = "0.138.0"
@@ -295,6 +304,21 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/83/6bf02ff9e3ca1d24765050e3b51dceae9bb69909cc5385623cf6f3fd7c23/fastapi_mcp-0.4.0-py3-none-any.whl", hash = "sha256:d4a3fe7966af24d44e4b412720561c95eb12bed999a4443a88221834b3b15aec", size = 25085, upload-time = "2025-07-28T12:11:04.472Z" },
]
[[package]]
name = "gssapi"
version = "1.11.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
dependencies = [
{ name = "decorator", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/52/c1e90623c259a42ab0587078bb04f959867b970add46ff66750ead8fc7c5/gssapi-1.11.1.tar.gz", hash = "sha256:2049ee4b1d0c363163a1344b7282a363f9f4094e51d2c36de0cf01d4735e0ae2", size = 95233, upload-time = "2026-01-26T21:01:39.463Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/75/3cc18f2d084d19fbba38dc684588cf5f674c647e754f9cf1625bd78c39f8/gssapi-1.11.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2298e5909a8f2d27c29352885a24e4026cfd3fa24fc38d4a0a3743fa5a3e7667", size = 611712, upload-time = "2026-01-26T21:01:19.626Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/f9/ac0f8c43c209d56c89655f80cd4ae43379f88370d01a7e11f264f081eef5/gssapi-1.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:5b60b1f8d8d3e36c025bd3494105de1dfccee578e8de001f423cc094468e3022", size = 642782, upload-time = "2026-01-26T21:01:21.462Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/79/9148636b75ca5741ddc9c57c4b256adec422e3a89bca14306d53b48caac9/gssapi-1.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:82fba401e9514ad21749b8d8954e2de1c617b0a73204c8598ee84630e23c5810", size = 714379, upload-time = "2026-01-26T21:01:25.961Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/77/f34fd81bbccf2e682073964a1b1b0a383e70d02946e472f78881d50cec6f/gssapi-1.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb0250f27d288d4217d7f606d3b68ecb9a10fce9391106129cada96434a685b0", size = 734103, upload-time = "2026-01-26T21:01:27.3Z" },
]
[[package]]
name = "gunicorn"
version = "26.0.0"
@@ -344,6 +368,21 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-kerberos"
version = "0.1.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
dependencies = [
{ name = "cryptography" },
{ name = "httpx" },
{ name = "pyspnego" },
{ name = "pyspnego", extra = ["kerberos"], marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/e6/67838510aa9b6f38b139a13e60b001dab578c2bc1cc390b48a94c6542a72/httpx_kerberos-0.1.2.tar.gz", hash = "sha256:ae47ddbe9468dfee49e79477baeb5237143ecf483ecef15ea4b4a9c86a961264", size = 8372, upload-time = "2025-03-23T15:41:26.324Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/28/ed689c79425d9676418b1c011cd002b12c560e81da652667da93fe98dc36/httpx_kerberos-0.1.2-py3-none-any.whl", hash = "sha256:efc87c5dbb0b37f380727f68285fe900842a712c2ab38b80e8b8d01b13bab6ba", size = 9924, upload-time = "2025-03-23T15:41:25.521Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
@@ -398,6 +437,18 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "krb5"
version = "0.9.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/15/55a01be5f1816fe6d7d36fec4c6b2cb6f5264d289a015322562c582a81b7/krb5-0.9.0.tar.gz", hash = "sha256:4cdd2c85ff4770108edaf48fedf19888cf956ff374e2e97e40f8412b048caee6", size = 236761, upload-time = "2025-11-25T18:53:46.997Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/4e/0a35ae4821ca5f0491844d9343c816883b3218a265a4a95ecec66e76f239/krb5-0.9.0-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7a021e869833bfed44ed4c9dfefd25813fab40a381ad4482b79ea36744545f62", size = 913483, upload-time = "2025-11-25T18:53:38.966Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/32/d2a9d977d23425777c0c5722947e6f0bc80882c7de15038bd02f9269c01a/krb5-0.9.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:2bca06e7ce1551f3eb7f674508bc34d9f44fa1c9056f24120878ec9ab8e430cb", size = 939459, upload-time = "2025-11-25T18:53:40.597Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/b9/fd0079f9208738bc09cf99208fc92cdf7d8b6a2a363af9a73256efa76399/krb5-0.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ea2ee73bb5aa7818ce50895fb29c276c7fe478c6eda121a5b66b0cc45fe2d42e", size = 1090101, upload-time = "2025-11-25T18:53:41.937Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/9f/df4e24995e0fea7792e8ab152124c65ac845e00ddfb4dd8f7d22907d122b/krb5-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9da4558a47ec105a1a2c185bb4b2d1dd90b7c021e3116895171f913ef048d03", size = 1101333, upload-time = "2025-11-25T18:53:43.177Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
@@ -611,6 +662,25 @@ crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pyspnego"
version = "0.12.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
dependencies = [
{ name = "cryptography" },
{ name = "sspilib", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/84/58577bd1b14293650879de0579ec263a1d8350f1d6d227226cf776b5a6a6/pyspnego-0.12.1.tar.gz", hash = "sha256:ff4fb6df38202a012ea2a0f43091ae9680878443f0ea61c9ea0e2e8152a4b810", size = 226027, upload-time = "2026-03-02T20:16:09.74Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/9f/4da6a1b9611af2397289b77d6fd08f5fe8f1f34dabc3bf85b0638d655e64/pyspnego-0.12.1-py3-none-any.whl", hash = "sha256:7237cb47985ccf5da512106ddb2731e4f9cefec00991f76c054488eb95fb1a2d", size = 130247, upload-time = "2026-03-02T20:16:08.331Z" },
]
[package.optional-dependencies]
kerberos = [
{ name = "gssapi", marker = "sys_platform != 'win32'" },
{ name = "krb5", marker = "sys_platform != 'win32'" },
]
[[package]]
name = "pytest"
version = "9.1.1"
@@ -846,6 +916,7 @@ dependencies = [
{ name = "fastapi-mcp" },
{ name = "gunicorn" },
{ name = "httpx" },
{ name = "httpx-kerberos" },
{ name = "loguru" },
{ name = "pydantic" },
{ name = "pydantic-core" },
@@ -865,6 +936,7 @@ requires-dist = [
{ name = "fastapi-mcp", specifier = ">=0.4.0" },
{ name = "gunicorn", specifier = ">=23.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx-kerberos", specifier = ">=0.1.2" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "pydantic", specifier = ">=2.13.4" },
{ name = "pydantic-core", specifier = ">=2.46.4" },
@@ -900,6 +972,20 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" },
]
[[package]]
name = "sspilib"
version = "0.5.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/e6/d0d74b18bed8c16949fddc0401005c072947ae7bf1bab982ed28f9ebc2d8/sspilib-0.5.0.tar.gz", hash = "sha256:b62f7f2602aa1add0505eee2417e2df24421224cb411e53bf3ae42a71b62fe98", size = 59920, upload-time = "2025-12-03T00:31:05.564Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/1b/dd9066491168933b0f7ab6e396ac58cc024c8954e95264c38e3dc9363d7c/sspilib-0.5.0-cp311-abi3-win32.whl", hash = "sha256:fcb57b41b3200ef2e6e8846e2a13799d20b35b796267f2f75cc65e3883e8eeb6", size = 451246, upload-time = "2025-12-03T00:30:36.685Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/6a/a11abf90172ff580ac2f9ade3496d868e05e851c4ecf487dd5baeb966b1d/sspilib-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ca2a21a4e90db563c2cec639c66b3a29ea53129a0c55ff1e4154a02937f6bd45", size = 540777, upload-time = "2025-12-03T00:30:38.44Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/05/983876d281b9e7926f1c9126e72de8bd5928b1de45433163f54d4e217502/sspilib-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:6893bad16f122fc3c4bd908461b9728694465c05ca97c22f7e2094791c4ee3cb", size = 470353, upload-time = "2025-12-03T00:30:39.63Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/f8/34e8e86883054b961c2eb88a5b42b89b2bf975723b1acca090966c2d03ff/sspilib-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:9dad272abf3f4cf0bf95d495075d2987f6ba1fb300f8d603661ccac07d11272f", size = 567408, upload-time = "2025-12-03T00:30:48.723Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/d8/8c4ba75f925fd9651cb855c47e0e67931a175d6fd41e569193a8d58133ac/sspilib-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7d7724d5dbb31f68e62465863dfb862fe2793281ce40d0c8f2dc60c8f07998f2", size = 690291, upload-time = "2025-12-03T00:30:49.929Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/c3/07af17b6fcc2b02af294a8817e30441a502880a04c8d60be2d71e0a1eacc/sspilib-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8ce23ec740dee025136370ed4ae64b7d1535368321049ef960012a57c93ebe15", size = 534304, upload-time = "2025-12-03T00:30:51.348Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"