Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-modelscope-hub for
openSUSE:Factory checked in at 2026-09-17 15:19:57
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-modelscope-hub (Old)
and /work/SRC/openSUSE:Factory/.python-modelscope-hub.new.383539 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-modelscope-hub"
Thu Sep 17 15:19:57 2026 rev:6 rq:1378300 version:0.4.3
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-modelscope-hub/python-modelscope-hub.changes
2026-09-10 17:40:54.495489746 +0200
+++
/work/SRC/openSUSE:Factory/.python-modelscope-hub.new.383539/python-modelscope-hub.changes
2026-09-17 15:21:16.704658221 +0200
@@ -1,0 +2,16 @@
+Wed Sep 16 07:24:49 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.4.3:
+ * Agent listing requires a read-scoped token and raises 403
+ OperationNotAllowed instead of returning an empty list
+ * MCP list pagination uses the requested page_number/page_size
+ when the service omits them, and rejects invalid page numbers
+ * Agent-IDP signing components are validated as ASCII without
+ '|'; Unicode encoding failures become E3021 InvalidParameter
+ * MCP list CLI drops the unsupported status column; HTTP-200
+ error envelopes decode to typed exceptions
+ * Re-export legacy modelscope SDK constants from
+ modelscope_hub.constants
+- Require pytest >= 7.0 to match upstream's test extra
+
+-------------------------------------------------------------------
Old:
----
modelscope_hub-0.4.2.tar.gz
New:
----
modelscope_hub-0.4.3.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-modelscope-hub.spec ++++++
--- /var/tmp/diff_new_pack.fO4BuL/_old 2026-09-17 15:21:17.327684353 +0200
+++ /var/tmp/diff_new_pack.fO4BuL/_new 2026-09-17 15:21:17.329684437 +0200
@@ -19,7 +19,7 @@
%bcond_without libalternatives
%{?sle15_python_module_pythons}
Name: python-modelscope-hub
-Version: 0.4.2
+Version: 0.4.3
Release: 0
Summary: Official Python client for ModelScope Hub
License: Apache-2.0
@@ -29,7 +29,7 @@
BuildRequires: %{python_module cryptography >= 41}
BuildRequires: %{python_module filelock >= 3.9}
BuildRequires: %{python_module pip}
-BuildRequires: %{python_module pytest}
+BuildRequires: %{python_module pytest >= 7.0}
BuildRequires: %{python_module requests >= 2.28}
BuildRequires: %{python_module responses >= 0.20}
BuildRequires: %{python_module setuptools >= 68.0}
++++++ modelscope_hub-0.4.2.tar.gz -> modelscope_hub-0.4.3.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/PKG-INFO
new/modelscope_hub-0.4.3/PKG-INFO
--- old/modelscope_hub-0.4.2/PKG-INFO 2026-09-10 04:59:09.073332300 +0200
+++ new/modelscope_hub-0.4.3/PKG-INFO 2026-09-15 14:09:21.563876600 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: modelscope-hub
-Version: 0.4.2
+Version: 0.4.3
Summary: The official Python client to connect with ModelScope Hub.
License: Apache-2.0
Keywords: modelscope,hub,sdk,openapi,machine-learning
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/src/modelscope_hub/_openapi.py
new/modelscope_hub-0.4.3/src/modelscope_hub/_openapi.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/_openapi.py 2026-09-10
04:58:57.682107400 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/_openapi.py 2026-09-15
14:09:12.680884000 +0200
@@ -1085,6 +1085,62 @@
return params
@staticmethod
+ def _decode_mcp_list_response(response: requests.Response) -> JSON:
+ """Decode MCP list replies, including legacy HTTP-200 error envelopes.
+
+ The service normally signals errors through HTTP status codes. Some
+ deployments instead return ``200`` with ``success: false`` and an
+ OpenAPI-style error code. Treating that dictionary as a list payload
+ silently turned invalid request bodies into empty server lists.
+ """
+ try:
+ payload = response.json()
+ except ValueError:
+ return {"raw_response": response.text}
+ if not isinstance(payload, dict):
+ return payload
+
+ success = payload.get("success") if "success" in payload else
payload.get("Success")
+ if success is False:
+ code = payload.get("code") if payload.get("code") is not None else
payload.get("Code")
+ message = next(
+ (
+ value.strip()
+ for key in ("message", "Message", "msg", "Msg", "detail",
"Detail")
+ if isinstance(value := payload.get(key), str) and
value.strip()
+ ),
+ "MCP server list request was rejected.",
+ )
+ request_id = payload.get("request_id") or payload.get("requestId")
or payload.get("RequestId")
+ code_text = str(code).strip()
+ error_cls: type[APIError] = APIError
+ status_code = 400
+ if code_text == "InputParameterError":
+ error_cls = InvalidParameter
+ elif code_text == "InvalidAuthentication":
+ error_cls = AuthenticationError
+ status_code = 401
+ elif code_text == "OperationNotAllowed":
+ error_cls = PermissionDeniedError
+ status_code = 403
+ elif code_text == "RateLimitExceed":
+ error_cls = RateLimitError
+ status_code = 429
+ raise error_cls(
+ message,
+ status_code=status_code,
+ request_id=request_id,
+ response_body=payload,
+ url=response.url,
+ method=response.request.method if response.request else "PUT",
+ )
+ if "data" in payload:
+ return payload["data"]
+ if "Data" in payload:
+ return payload["Data"]
+ return payload
+
+ @staticmethod
def _is_method_or_route_unsupported(exc: APIError) -> bool:
return exc.status_code in (404, 405, 501)
@@ -1307,6 +1363,10 @@
filter : dict, optional
Nested filter object. Supported keys: ``category``, ``is_hosted``.
"""
+ if isinstance(page_number, bool) or not isinstance(page_number, int)
or page_number < 1:
+ raise InvalidParameter("page_number must be an integer >= 1.")
+ if isinstance(page_size, bool) or not isinstance(page_size, int) or
page_size < 1:
+ raise InvalidParameter("page_size must be an integer >= 1.")
if page_number * page_size > 100:
# The service enforces this itself, answering 403 QuotaLimitExceed
with
# exactly this rule. Checking here spares the round trip and
reports it
@@ -1326,14 +1386,16 @@
body = {k: v for k, v in body.items() if v is not None}
def _get() -> JSON:
- return self._request(
+ response = self._request(
"GET",
"/mcp/servers",
params=self._flatten_mcp_list_params(body),
require_token=False,
required_scope=TokenScope.READ,
anonymous_retry=True,
+ unwrap=False,
)
+ return self._decode_mcp_list_response(response)
if self._mcp_list_supports_get:
# This deployment already answered GET and refused PUT, so leading
@@ -1341,14 +1403,16 @@
return _get()
try:
- return self._request(
+ response = self._request(
"PUT",
"/mcp/servers",
json_body=body,
require_token=False,
required_scope=TokenScope.READ,
anonymous_retry=True,
+ unwrap=False,
)
+ return self._decode_mcp_list_response(response)
except APIError as exc:
if self._mcp_list_supports_get is False or not
self._is_method_or_route_unsupported(exc):
raise
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/modelscope_hub-0.4.2/src/modelscope_hub/agent/_api.py
new/modelscope_hub-0.4.3/src/modelscope_hub/agent/_api.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/agent/_api.py 2026-09-10
04:58:57.682107400 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/agent/_api.py 2026-09-15
14:09:12.681884000 +0200
@@ -22,8 +22,8 @@
from .._openapi import OpenAPIClient
from ..config import HubConfig
-from ..constants import Visibility
-from ..errors import AuthenticationError, NotExistError
+from ..constants import TokenScope, Visibility
+from ..errors import APIError, AuthenticationError, NotExistError,
PermissionDeniedError
logger = logging.getLogger("modelscope_hub.agent")
@@ -230,12 +230,88 @@
"""True if the repo exists, False on 404."""
return self.repo_info(path, name) is not None
+ @staticmethod
+ def _decode_dolphin_list_response(response: object, *, list_url: str) ->
object:
+ """Decode the legacy dolphin list envelope without hiding soft errors.
+
+ Most endpoints signal access denial with HTTP 403, which OpenAPIClient
+ maps normally. ``/api/v1/dolphin/agents`` can instead answer HTTP 200
+ with ``Code=OperationNotAllowed``. Its former decode path then treated
+ the payload as an empty result, making a scope error indistinguishable
+ from an owner with no repositories.
+ """
+ try:
+ payload = response.json() # type: ignore[union-attr]
+ except (AttributeError, ValueError) as exc:
+ raise APIError(
+ "Agent repository list endpoint returned a non-JSON response.",
+ status_code=500,
+ url=list_url,
+ method="PUT",
+ ) from exc
+
+ if not isinstance(payload, dict):
+ return payload
+
+ code = payload.get("Code") if payload.get("Code") is not None else
payload.get("code")
+ success = payload.get("Success") if "Success" in payload else
payload.get("success")
+ message = next(
+ (
+ value.strip()
+ for key in ("Message", "message", "Msg", "msg")
+ if isinstance(value := payload.get(key), str) and value.strip()
+ ),
+ "Agent repository list endpoint rejected the request.",
+ )
+ code_text = str(code).strip()
+ message_lower = message.lower()
+ permission_denied = (
+ code_text == "OperationNotAllowed"
+ or code_text == "403"
+ or "operationnotallowed" in message_lower
+ or "permission denied" in message_lower
+ or "not allowed" in message_lower
+ or "forbidden" in message_lower
+ )
+ failed_envelope = success is False or (code is not None and code_text
not in ("", "0", "200"))
+ if permission_denied:
+ error = PermissionDeniedError(
+ f"OperationNotAllowed: {message}",
+ status_code=403,
+ request_id=payload.get("RequestId") or
payload.get("request_id"),
+ response_body=payload,
+ url=list_url,
+ method="PUT",
+ )
+ error.suggestion = (
+ "Agent repository listing requires a token with 'read'
permission; "
+ "an api-inference-only token cannot access this endpoint."
+ )
+ raise error
+ if failed_envelope:
+ raise APIError(
+ f"Agent repository list endpoint rejected the request:
{message}",
+ status_code=400,
+ request_id=payload.get("RequestId") or
payload.get("request_id"),
+ response_body=payload,
+ url=list_url,
+ method="PUT",
+ )
+
+ # Match OpenAPIClient's success-envelope unwrapping while preserving
the
+ # outer envelope long enough to inspect soft errors above.
+ if "data" in payload:
+ return payload["data"]
+ if "Data" in payload:
+ return payload["Data"]
+ return payload
+
def list_agents(self, owner: str | None = None, page_number: int = 1,
page_size: int = 10) -> dict:
"""List agent repositories (PUT /api/v1/dolphin/agents).
- Queries the dolphin search endpoint. When *owner* is given it is sent
as
- a ``Path contains`` criterion (the group filter). Returns a dict with
- 'items' (list of agent metadata dicts) and 'total_count' (int).
+ This is an account-scoped operation, not a public catalogue lookup. It
+ requires a token with ``read`` permission and must never downgrade a
+ denied request into an anonymous empty list.
"""
criterion: list[dict] = []
if owner:
@@ -254,7 +330,15 @@
"Criterion": criterion,
}
list_url = f"{self.server}/api/v1/dolphin/agents"
- data = self._openapi.request("PUT", url=list_url, json_body=body,
require_token=False)
+ response = self._openapi._request(
+ "PUT",
+ url=list_url,
+ json_body=body,
+ require_token=True,
+ required_scope=TokenScope.READ,
+ unwrap=False,
+ )
+ data = self._decode_dolphin_list_response(response, list_url=list_url)
if isinstance(data, list):
return {"items": data, "total_count": len(data)}
if isinstance(data, dict):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/src/modelscope_hub/agent_idp.py
new/modelscope_hub-0.4.3/src/modelscope_hub/agent_idp.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/agent_idp.py 2026-09-10
04:58:57.682107400 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/agent_idp.py 2026-09-15
14:09:12.681884000 +0200
@@ -54,6 +54,30 @@
return decoded
+def _canonical_signing_component(value: str, field: str) -> str:
+ """Validate one component of the server-defined ASCII signing message.
+
+ The Agent-IDP protocol signs the literal
+ ``agent_id|kid|audience|timestamp`` byte sequence. ``audience`` is the
+ target Hub application's ``client_id``, not a display name. Encoding a
+ Unicode display name as UTF-8 here would silently change the bytes the
+ service verifies, so reject it explicitly instead of leaking a Python
+ ``UnicodeEncodeError``.
+ """
+ try:
+ value.encode("ascii")
+ except UnicodeEncodeError:
+ reason = (
+ "the target Hub application's client_id"
+ if field == "audience"
+ else "the canonical Agent-IDP signature message"
+ )
+ raise InvalidParameter(f"{field} must contain only ASCII characters
because it is {reason}.") from None
+ if "|" in value:
+ raise InvalidParameter(f"{field} must not contain '|', the Agent-IDP
signature message delimiter.")
+ return value
+
+
def _normalise_private_jwk(value: AgentJWK | Mapping[str, Any]) -> AgentJWK:
"""Validate an Ed25519 private JWK and return a safe structured copy."""
if isinstance(value, AgentJWK):
@@ -186,20 +210,28 @@
audience: str,
timestamp: int,
) -> TokenSignPayload:
- """Build the signed body required by anonymous ``POST /agent_id/token``."""
+ """Build the signed body required by anonymous ``POST /agent_id/token``.
+
+ ``audience`` must be the ASCII Hub application ``client_id``. It is not a
+ human-readable application name: every component is signed as the literal
+ ASCII ``agent_id|kid|audience|timestamp`` protocol byte sequence.
+ """
if not isinstance(agent_id, str) or not agent_id:
raise InvalidParameter("agent_id must be a non-empty string.")
if not isinstance(audience, str) or not audience:
raise InvalidParameter("audience must be a non-empty string.")
if isinstance(timestamp, bool) or not isinstance(timestamp, int) or
timestamp <= 0:
raise InvalidParameter("timestamp must be a positive Unix timestamp in
seconds.")
+ agent_id = _canonical_signing_component(agent_id, "agent_id")
+ audience = _canonical_signing_component(audience, "audience")
key = _normalise_private_jwk(private_jwk)
- message = f"{agent_id}|{key.kid}|{audience}|{timestamp}".encode("ascii")
+ kid = _canonical_signing_component(key.kid, "kid")
+ message = f"{agent_id}|{kid}|{audience}|{timestamp}".encode("ascii")
private_bytes = _decode_base64url(key.d, "d")
signature =
Ed25519PrivateKey.from_private_bytes(private_bytes).sign(message)
return {
"agent_id": agent_id,
- "kid": key.kid,
+ "kid": kid,
"audience": audience,
"timestamp": timestamp,
"signature": _encode_base64url(signature),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/src/modelscope_hub/api.py
new/modelscope_hub-0.4.3/src/modelscope_hub/api.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/api.py 2026-09-10
04:58:57.683107600 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/api.py 2026-09-15
14:09:12.681884000 +0200
@@ -1707,11 +1707,9 @@
"""
rt = self._normalize_repo_type(repo_type)
paths = self._normalize_delete_values(file_paths, "file_paths")
- patterns = self._normalize_delete_values(
- delete_patterns, "delete_patterns")
+ patterns = self._normalize_delete_values(delete_patterns,
"delete_patterns")
if not paths and not patterns:
- raise InvalidParameter(
- "Provide at least one file path or delete pattern.")
+ raise InvalidParameter("Provide at least one file path or delete
pattern.")
resolved_revision = revision or "master"
if patterns:
@@ -1726,9 +1724,8 @@
if file.path and file.type != "tree"
]
paths.extend(
- path for path in remote_paths
- if any(fnmatch.fnmatchcase(path, pattern)
- for pattern in patterns))
+ path for path in remote_paths if any(fnmatch.fnmatchcase(path,
pattern) for pattern in patterns)
+ )
paths = list(dict.fromkeys(paths))
if not paths:
@@ -1758,8 +1755,7 @@
normalized: list[str] = []
for value in raw_values:
if not isinstance(value, str):
- raise InvalidParameter(
- f"{parameter_name} must contain only strings.")
+ raise InvalidParameter(f"{parameter_name} must contain only
strings.")
if value:
normalized.append(value)
return normalized
@@ -2240,8 +2236,16 @@
filter=filter,
extra={k: v for k, v in extra.items() if v is not None} or None,
)
- items, total, page, size = self._extract_paged(payload)
- return PagedResult(items=list(items), total_count=total,
page_number=page, page_size=size)
+ items, total, _page, _size = self._extract_paged(payload)
+ # The MCP service currently omits page_number/page_size in its
response.
+ # The request values are authoritative, exactly as in
list_repos("mcp"),
+ # so callers can reliably tell which page they received.
+ return PagedResult(
+ items=list(items),
+ total_count=total,
+ page_number=page_number,
+ page_size=page_size,
+ )
def list_operational_mcp_servers(self) -> PagedResult[dict]:
"""List the MCP servers the caller currently has hosted.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/src/modelscope_hub/cli/agent.py
new/modelscope_hub-0.4.3/src/modelscope_hub/cli/agent.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/cli/agent.py 2026-09-10
04:58:57.683107600 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/cli/agent.py 2026-09-15
14:09:12.682884000 +0200
@@ -35,11 +35,27 @@
return 1
+def _is_operation_not_allowed(e: APIError) -> bool:
+ """Recognise the server's permission code in either message or envelope."""
+ if "OperationNotAllowed" in e.message:
+ return True
+ body = e.response_body
+ if not isinstance(body, dict):
+ return False
+ code = body.get("Code") if body.get("Code") is not None else
body.get("code")
+ return str(code) == "OperationNotAllowed"
+
+
def _api_error_message(e: APIError, action: str = "request") -> str:
status = e.status_code or 0
if status == 401:
return "authentication failed. Please login again."
if status == 403:
+ if action == "list" and _is_operation_not_allowed(e):
+ return (
+ "permission denied (403 OperationNotAllowed). Agent repository
listing requires a token with "
+ "'read' permission; an api-inference-only token cannot access
this endpoint."
+ )
return "permission denied. You do not have access to this resource."
if status == 404:
return "resource not found. Check the repository name and try again."
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/src/modelscope_hub/cli/mcp.py
new/modelscope_hub-0.4.3/src/modelscope_hub/cli/mcp.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/cli/mcp.py 2026-09-10
04:58:57.684107500 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/cli/mcp.py 2026-09-15
14:09:12.682884000 +0200
@@ -64,12 +64,11 @@
(
item.get("id") or item.get("Id") or "-",
item.get("name") or item.get("Name") or "-",
- item.get("status") or item.get("Status") or "-",
item.get("description") or item.get("Description") or "-",
)
for item in result.items
]
- info(render_table(rows, headers=["id", "name", "status",
"description"]))
+ info(render_table(rows, headers=["id", "name", "description"]))
info(f"\npage {result.page_number} / total {result.total_count}")
@staticmethod
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/modelscope_hub-0.4.2/src/modelscope_hub/compat/constants.py
new/modelscope_hub-0.4.3/src/modelscope_hub/compat/constants.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/compat/constants.py
2026-09-10 04:58:57.684107500 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/compat/constants.py
2026-09-15 14:09:12.683884000 +0200
@@ -1,75 +1,78 @@
-"""Legacy constant mappings for backward compatibility with modelscope SDK."""
+"""Legacy constant mappings for backward compatibility with the modelscope SDK.
-from ..constants import (
+Every name here is re-exported from :mod:`modelscope_hub.constants`, the single
+source of truth. This module defines no independent values of its own; it only
+surfaces the historical names (plus a few integer / env-name aliases) that the
+modelscope SDK imports.
+"""
+
+from ..constants import ( # noqa: F401
+ DEFAULT_CREDENTIALS_PATH,
+ DEFAULT_DATASET_REVISION,
+ DEFAULT_MAX_WORKERS,
+ DEFAULT_MODELSCOPE_DATA_ENDPOINT,
+ DEFAULT_MODELSCOPE_DOMAIN,
+ DEFAULT_MODELSCOPE_GROUP,
+ DEFAULT_MODELSCOPE_INTL_DATA_ENDPOINT,
+ DEFAULT_MODELSCOPE_INTL_DOMAIN,
+ DEFAULT_SKILLS_DIR,
+ FILE_HASH,
+ MODEL_ID_SEPARATOR,
+ REPO_TYPE_DATASET,
+ REPO_TYPE_MODEL,
+ REPO_TYPE_STUDIO,
+ REPO_TYPE_SUPPORT,
+ TEMPORARY_FOLDER_NAME,
+ UPLOAD_ADAPTIVE_BATCH_SIZE,
UPLOAD_ADAPTIVE_BATCHING_ENABLED,
+ UPLOAD_BLOB_CONNECT_TIMEOUT,
UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS,
UPLOAD_BLOB_MAX_ATTEMPTS,
+ UPLOAD_BLOB_MAX_RETRIES,
UPLOAD_BLOB_PROGRESS_THRESHOLD_BYTES,
+ UPLOAD_BLOB_READ_TIMEOUT,
UPLOAD_BLOB_READ_TIMEOUT_SECONDS,
+ UPLOAD_BLOB_RETRY_BACKOFF,
UPLOAD_BLOB_RETRY_BACKOFF_BASE_SECONDS,
UPLOAD_BLOB_RETRY_MAX_DELAY_SECONDS,
+ UPLOAD_BLOB_RETRY_MAX_WAIT,
+ UPLOAD_BLOB_TIMEOUT,
+ UPLOAD_BLOB_TQDM_DISABLE_THRESHOLD,
UPLOAD_BLOB_VALIDATION_BATCH_MAX_OBJECTS,
UPLOAD_CACHE_ENABLED,
UPLOAD_COMMIT_BATCH_MAX_OPERATIONS,
+ UPLOAD_COMMIT_BATCH_SIZE,
+ UPLOAD_FAILED_FILE_MAX_RETRIES,
UPLOAD_FAILED_FILE_MAX_RETRY_ROUNDS,
UPLOAD_HTTP_RETRY_ALLOWED_METHODS,
UPLOAD_LFS_FORCE_THRESHOLD_BYTES,
+ UPLOAD_LFS_THRESHOLD,
UPLOAD_MAX_CONCURRENT_WORKERS,
+ UPLOAD_MAX_FILE_COUNT,
+ UPLOAD_MAX_FILE_COUNT_IN_DIR,
+ UPLOAD_MAX_FILE_SIZE,
UPLOAD_MAX_FILE_SIZE_BYTES,
UPLOAD_MAX_FILES_PER_DIRECTORY,
+ UPLOAD_NORMAL_FILE_SIZE_TOTAL_LIMIT,
UPLOAD_NORMAL_FILES_TOTAL_SIZE_BYTES,
+ UPLOAD_REACT_BACKOFF_MAX_EXPONENT,
+ UPLOAD_REACT_ENABLED,
+ UPLOAD_REACT_MAX_DELAY,
+ UPLOAD_REACT_ROUND2_BASE_DELAY,
+ UPLOAD_REACT_ROUND3_FILE_DELAY,
UPLOAD_RECOVERY_BACKOFF_MAX_EXPONENT,
UPLOAD_RECOVERY_ENABLED,
UPLOAD_RECOVERY_MAX_DELAY_SECONDS,
UPLOAD_RECOVERY_SERIAL_BACKOFF_BASE_SECONDS,
UPLOAD_RECOVERY_SINGLE_FILE_DELAY_SECONDS,
+ UPLOAD_RETRY_ALLOWED_METHODS,
+ UPLOAD_SIZE_THRESHOLD_TO_ENFORCE_LFS,
+ UPLOAD_USE_CACHE,
+ UPLOAD_VALIDATE_BLOB_BATCH_SIZE,
RepoType,
Visibility,
- get_upload_ignore_file_pattern, # noqa: F401
+ get_upload_ignore_file_pattern,
)
-from ..constants import (
- UPLOAD_LFS_THRESHOLD as _UPLOAD_LFS_THRESHOLD,
-)
-from ..constants import (
- UPLOAD_MAX_FILE_COUNT as _UPLOAD_MAX_FILE_COUNT,
-)
-
-REPO_TYPE_MODEL: str = RepoType.MODEL.value
-REPO_TYPE_DATASET: str = RepoType.DATASET.value
-REPO_TYPE_STUDIO: str = RepoType.STUDIO.value
-REPO_TYPE_SUPPORT: list[str] = [REPO_TYPE_MODEL, REPO_TYPE_DATASET,
REPO_TYPE_STUDIO]
-
-DEFAULT_DATASET_REVISION: str = "master"
-DEFAULT_MAX_WORKERS: int = UPLOAD_MAX_CONCURRENT_WORKERS
-
-# Legacy upload constants. Values come from the modelscope-hub runtime config.
-UPLOAD_BLOB_CONNECT_TIMEOUT: int = UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS
-UPLOAD_BLOB_READ_TIMEOUT: int = UPLOAD_BLOB_READ_TIMEOUT_SECONDS
-UPLOAD_BLOB_MAX_RETRIES: int = UPLOAD_BLOB_MAX_ATTEMPTS
-UPLOAD_BLOB_RETRY_BACKOFF: int = UPLOAD_BLOB_RETRY_BACKOFF_BASE_SECONDS
-UPLOAD_BLOB_RETRY_MAX_WAIT: int = UPLOAD_BLOB_RETRY_MAX_DELAY_SECONDS
-UPLOAD_FAILED_FILE_MAX_RETRIES: int = UPLOAD_FAILED_FILE_MAX_RETRY_ROUNDS
-UPLOAD_BLOB_TIMEOUT: tuple[int, int] = (
- UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS,
- UPLOAD_BLOB_READ_TIMEOUT_SECONDS,
-)
-UPLOAD_RETRY_ALLOWED_METHODS: frozenset[str] =
UPLOAD_HTTP_RETRY_ALLOWED_METHODS
-UPLOAD_MAX_FILE_SIZE: int = UPLOAD_MAX_FILE_SIZE_BYTES
-UPLOAD_MAX_FILE_COUNT: int = _UPLOAD_MAX_FILE_COUNT
-UPLOAD_SIZE_THRESHOLD_TO_ENFORCE_LFS: int = UPLOAD_LFS_FORCE_THRESHOLD_BYTES
-UPLOAD_LFS_THRESHOLD: int = _UPLOAD_LFS_THRESHOLD
-UPLOAD_MAX_FILE_COUNT_IN_DIR: int = UPLOAD_MAX_FILES_PER_DIRECTORY
-UPLOAD_NORMAL_FILE_SIZE_TOTAL_LIMIT: int = UPLOAD_NORMAL_FILES_TOTAL_SIZE_BYTES
-UPLOAD_COMMIT_BATCH_SIZE: int = UPLOAD_COMMIT_BATCH_MAX_OPERATIONS
-UPLOAD_VALIDATE_BLOB_BATCH_SIZE: int = UPLOAD_BLOB_VALIDATION_BATCH_MAX_OBJECTS
-UPLOAD_ADAPTIVE_BATCH_SIZE: bool = UPLOAD_ADAPTIVE_BATCHING_ENABLED
-UPLOAD_REACT_ENABLED: bool = UPLOAD_RECOVERY_ENABLED
-UPLOAD_REACT_ROUND2_BASE_DELAY: int =
UPLOAD_RECOVERY_SERIAL_BACKOFF_BASE_SECONDS
-UPLOAD_REACT_ROUND3_FILE_DELAY: int = UPLOAD_RECOVERY_SINGLE_FILE_DELAY_SECONDS
-UPLOAD_REACT_BACKOFF_MAX_EXPONENT: int = UPLOAD_RECOVERY_BACKOFF_MAX_EXPONENT
-UPLOAD_REACT_MAX_DELAY: int = UPLOAD_RECOVERY_MAX_DELAY_SECONDS
-UPLOAD_BLOB_TQDM_DISABLE_THRESHOLD: int = UPLOAD_BLOB_PROGRESS_THRESHOLD_BYTES
-UPLOAD_USE_CACHE: bool = UPLOAD_CACHE_ENABLED
# Visibility integer constants matching the old SDK
ModelVisibility_PUBLIC: int = int(Visibility.PUBLIC)
@@ -79,9 +82,3 @@
# Env var name constants (old SDK imports these as string constants)
MODELSCOPE_DOMAIN: str = "MODELSCOPE_DOMAIN"
MODELSCOPE_PREFER_AI_SITE: str = "MODELSCOPE_PREFER_AI_SITE"
-
-# API response field names
-FILE_HASH: str = "Sha256"
-
-# Download temp folder name
-TEMPORARY_FOLDER_NAME: str = "._____temp"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/src/modelscope_hub/constants.py
new/modelscope_hub-0.4.3/src/modelscope_hub/constants.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/constants.py 2026-09-10
04:58:57.685107700 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/constants.py 2026-09-15
14:09:12.683884000 +0200
@@ -12,6 +12,7 @@
import warnings
from dataclasses import dataclass
from enum import Enum, IntEnum
+from pathlib import Path
# ---------------------------------------------------------------------------
# StrEnum compatibility shim (Python 3.10 lacks :class:`enum.StrEnum`).
@@ -183,6 +184,38 @@
# ---------------------------------------------------------------------------
+# Repo-type string aliases and shared repo defaults.
+#
+# The modern surface models repo kinds as :class:`RepoType`; these string
+# aliases and the dataset-revision default are the historical names the
+# modelscope SDK consumes. Defined here so ``modelscope_hub.constants`` is the
+# single source of truth (``compat.constants`` re-exports them).
+# ---------------------------------------------------------------------------
+REPO_TYPE_MODEL: str = RepoType.MODEL.value
+REPO_TYPE_DATASET: str = RepoType.DATASET.value
+REPO_TYPE_STUDIO: str = RepoType.STUDIO.value
+REPO_TYPE_SUPPORT: list[str] = [REPO_TYPE_MODEL, REPO_TYPE_DATASET,
REPO_TYPE_STUDIO]
+DEFAULT_DATASET_REVISION: str = "master"
+
+# ---------------------------------------------------------------------------
+# Legacy modelscope domain / endpoint / filesystem constants.
+#
+# Historical names and shapes (``www.`` domains, the ``damo`` group, a ``Path``
+# credentials location) the modelscope SDK consumes. The modern canonical
+# endpoint stays :data:`DEFAULT_ENDPOINT` (no ``www``); these coexist for
+# backward compatibility and ``compat.constants`` re-exports them.
+# ---------------------------------------------------------------------------
+MODEL_ID_SEPARATOR: str = "/"
+DEFAULT_MODELSCOPE_GROUP: str = "damo"
+DEFAULT_MODELSCOPE_DOMAIN: str = "www.modelscope.cn"
+DEFAULT_MODELSCOPE_INTL_DOMAIN: str = "www.modelscope.ai"
+DEFAULT_MODELSCOPE_DATA_ENDPOINT: str = "https://" + DEFAULT_MODELSCOPE_DOMAIN
+DEFAULT_MODELSCOPE_INTL_DATA_ENDPOINT: str = "https://" +
DEFAULT_MODELSCOPE_INTL_DOMAIN
+DEFAULT_SKILLS_DIR: str = os.path.join(os.path.expanduser("~"), ".agents",
"skills")
+DEFAULT_CREDENTIALS_PATH: Path = Path.home().joinpath(".modelscope",
"credentials")
+
+
+# ---------------------------------------------------------------------------
# Helpers for environment-driven overrides (auto-registering)
# ---------------------------------------------------------------------------
_REGISTERED_NAMES: set[str] = set()
@@ -503,6 +536,9 @@
FILE_HASH_FIELD: str = "Sha256"
"""API response field name for file hash."""
+FILE_HASH: str = FILE_HASH_FIELD
+"""Legacy alias of :data:`FILE_HASH_FIELD` (modelscope SDK name)."""
+
ENV_FILE_LOCK: str = "MODELSCOPE_DOWNLOAD_FILE_LOCK"
_env_register(
ENV_FILE_LOCK,
@@ -755,6 +791,7 @@
# Deprecated Python aliases. Runtime code must use the explicit names above.
UPLOAD_BLOB_CONNECT_TIMEOUT = UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS
UPLOAD_BLOB_READ_TIMEOUT = UPLOAD_BLOB_READ_TIMEOUT_SECONDS
+UPLOAD_BLOB_TIMEOUT = (UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS,
UPLOAD_BLOB_READ_TIMEOUT_SECONDS)
UPLOAD_BLOB_MAX_RETRIES = UPLOAD_BLOB_MAX_ATTEMPTS
UPLOAD_BLOB_RETRY_BACKOFF = UPLOAD_BLOB_RETRY_BACKOFF_BASE_SECONDS
UPLOAD_BLOB_RETRY_MAX_WAIT = UPLOAD_BLOB_RETRY_MAX_DELAY_SECONDS
@@ -775,6 +812,7 @@
DEFAULT_MAX_WORKERS = UPLOAD_MAX_CONCURRENT_WORKERS
UPLOAD_USE_CACHE = UPLOAD_CACHE_ENABLED
UPLOAD_LFS_ENFORCE_THRESHOLD = UPLOAD_LFS_FORCE_THRESHOLD_BYTES
+UPLOAD_SIZE_THRESHOLD_TO_ENFORCE_LFS = UPLOAD_LFS_FORCE_THRESHOLD_BYTES
UPLOAD_MAX_FILE_COUNT_IN_DIR = UPLOAD_MAX_FILES_PER_DIRECTORY
UPLOAD_MAX_FILE_SIZE = UPLOAD_MAX_FILE_SIZE_BYTES
UPLOAD_NORMAL_FILE_SIZE_TOTAL_LIMIT = UPLOAD_NORMAL_FILES_TOTAL_SIZE_BYTES
@@ -923,10 +961,18 @@
"CONFIG_DIR_NAME",
"DATASET_LFS_SUFFIX",
"DEFAULT_CACHE_DIR_NAME",
+ "DEFAULT_CREDENTIALS_PATH",
+ "DEFAULT_DATASET_REVISION",
"DEFAULT_ENDPOINT",
"DEFAULT_IGNORE_PATTERNS",
"DEFAULT_INTL_ENDPOINT",
"DEFAULT_MAX_WORKERS",
+ "DEFAULT_MODELSCOPE_DATA_ENDPOINT",
+ "DEFAULT_MODELSCOPE_DOMAIN",
+ "DEFAULT_MODELSCOPE_GROUP",
+ "DEFAULT_MODELSCOPE_INTL_DATA_ENDPOINT",
+ "DEFAULT_MODELSCOPE_INTL_DOMAIN",
+ "DEFAULT_SKILLS_DIR",
"DOWNLOAD_CHUNK_SIZE",
"DOWNLOAD_PARALLEL_THRESHOLD",
"DOWNLOAD_PARALLELS",
@@ -942,12 +988,18 @@
"ENV_PREFER_AI_SITE",
"ENV_REGISTRY",
"EnvVar",
+ "FILE_HASH",
"FILE_HASH_FIELD",
"get_upload_ignore_file_pattern",
"LEGACY_API_PREFIX",
"License",
+ "MODEL_ID_SEPARATOR",
"MODEL_LFS_SUFFIX",
"OPENAPI_PREFIX",
+ "REPO_TYPE_DATASET",
+ "REPO_TYPE_MODEL",
+ "REPO_TYPE_STUDIO",
+ "REPO_TYPE_SUPPORT",
"RepoType",
"StrEnum",
"SESSION_FILE_NAME",
@@ -968,6 +1020,7 @@
"UPLOAD_BLOB_RETRY_BACKOFF_BASE_SECONDS",
"UPLOAD_BLOB_RETRY_MAX_DELAY_SECONDS",
"UPLOAD_BLOB_RETRY_MAX_WAIT",
+ "UPLOAD_BLOB_TIMEOUT",
"UPLOAD_BLOB_TQDM_DISABLE_THRESHOLD",
"UPLOAD_BLOB_VALIDATION_BATCH_MAX_OBJECTS",
"UPLOAD_CACHE_ENABLED",
@@ -1005,6 +1058,7 @@
"UPLOAD_RECOVERY_SERIAL_BACKOFF_BASE_SECONDS",
"UPLOAD_RECOVERY_SINGLE_FILE_DELAY_SECONDS",
"UPLOAD_RETRY_ALLOWED_METHODS",
+ "UPLOAD_SIZE_THRESHOLD_TO_ENFORCE_LFS",
"UPLOAD_USE_CACHE",
"UPLOAD_VALIDATE_BLOB_BATCH_SIZE",
"Visibility",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/src/modelscope_hub/version.py
new/modelscope_hub-0.4.3/src/modelscope_hub/version.py
--- old/modelscope_hub-0.4.2/src/modelscope_hub/version.py 2026-09-10
04:58:57.685107700 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub/version.py 2026-09-15
14:09:12.684884000 +0200
@@ -1,3 +1,3 @@
"""Version information for modelscope_hub."""
-__version__ = "0.4.2"
+__version__ = "0.4.3"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/modelscope_hub-0.4.2/src/modelscope_hub.egg-info/PKG-INFO
new/modelscope_hub-0.4.3/src/modelscope_hub.egg-info/PKG-INFO
--- old/modelscope_hub-0.4.2/src/modelscope_hub.egg-info/PKG-INFO
2026-09-10 04:59:09.045331700 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub.egg-info/PKG-INFO
2026-09-15 14:09:21.534876600 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: modelscope-hub
-Version: 0.4.2
+Version: 0.4.3
Summary: The official Python client to connect with ModelScope Hub.
License: Apache-2.0
Keywords: modelscope,hub,sdk,openapi,machine-learning
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/modelscope_hub-0.4.2/src/modelscope_hub.egg-info/SOURCES.txt
new/modelscope_hub-0.4.3/src/modelscope_hub.egg-info/SOURCES.txt
--- old/modelscope_hub-0.4.2/src/modelscope_hub.egg-info/SOURCES.txt
2026-09-10 04:59:09.054332000 +0200
+++ new/modelscope_hub-0.4.3/src/modelscope_hub.egg-info/SOURCES.txt
2026-09-15 14:09:21.544876600 +0200
@@ -55,6 +55,7 @@
tests/test_agent_idp.py
tests/test_agent_idp_keys.py
tests/test_cache_verification.py
+tests/test_compat_constants_completeness.py
tests/test_compat_delete_files.py
tests/test_compat_get_model_files.py
tests/test_compat_snapshot_download.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/tests/test_agent_idp.py
new/modelscope_hub-0.4.3/tests/test_agent_idp.py
--- old/modelscope_hub-0.4.2/tests/test_agent_idp.py 2026-09-10
04:58:57.689107700 +0200
+++ new/modelscope_hub-0.4.3/tests/test_agent_idp.py 2026-09-15
14:09:12.688884000 +0200
@@ -8,6 +8,7 @@
import requests
from modelscope_hub._openapi import OpenAPIClient
+from modelscope_hub.agent_idp import generate_agent_key_pair
from modelscope_hub.api import HubApi
from modelscope_hub.config import HubConfig
from modelscope_hub.errors import InvalidParameter
@@ -145,3 +146,17 @@
)
assert isinstance(token, AgentToken)
assert token.access_token == "jwt"
+
+ def test_private_key_facade_rejects_non_ascii_audience_before_http(self):
+ private_jwk, _ = generate_agent_key_pair("key-1")
+ api = HubApi(token="test-token")
+ api._openapi = MagicMock()
+ with pytest.raises(InvalidParameter, match="audience must contain only
ASCII characters") as raised:
+ api.issue_agent_token_with_private_key(
+ private_jwk,
+ agent_id="agent_id:modelscope:agent_xxx",
+ audience="高德地图",
+ timestamp=1700000000,
+ )
+ assert raised.value.error_code == "E3021"
+ api._openapi.issue_agent_token.assert_not_called()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/modelscope_hub-0.4.2/tests/test_agent_idp_keys.py
new/modelscope_hub-0.4.3/tests/test_agent_idp_keys.py
--- old/modelscope_hub-0.4.2/tests/test_agent_idp_keys.py 2026-09-10
04:58:57.689107700 +0200
+++ new/modelscope_hub-0.4.3/tests/test_agent_idp_keys.py 2026-09-15
14:09:12.688884000 +0200
@@ -73,3 +73,36 @@
private_jwk, _ = generate_agent_key_pair()
with pytest.raises(InvalidParameter, match="timestamp"):
sign_agent_token_request(private_jwk, agent_id="agent-1",
audience="hub", timestamp=0)
+
+
[email protected](
+ ("field", "value"),
+ [
+ ("audience", "高德地图"),
+ ("agent_id", "agent-高德"),
+ ("kid", "kid-高德"),
+ ],
+)
+def
test_signing_rejects_non_ascii_canonical_component_without_leaking_encoder_error(field,
value):
+ private_jwk, _ = generate_agent_key_pair(value if field == "kid" else
"key-1")
+ kwargs = {"agent_id": "agent-1", "audience": "hub", "timestamp": 100}
+ if field != "kid":
+ kwargs[field] = value
+ with pytest.raises(InvalidParameter) as raised:
+ sign_agent_token_request(private_jwk, **kwargs)
+ error = raised.value
+ assert error.error_code == "E3021"
+ assert f"{field} must contain only ASCII characters" in str(error)
+ assert "UnicodeEncodeError" not in str(error)
+ assert error.__cause__ is None
+
+
[email protected]("field", ["agent_id", "audience", "kid"])
+def test_signing_rejects_canonical_message_delimiter(field):
+ private_jwk, _ = generate_agent_key_pair("key|1" if field == "kid" else
"key-1")
+ kwargs = {"agent_id": "agent-1", "audience": "hub", "timestamp": 100}
+ if field != "kid":
+ kwargs[field] = "value|break"
+ with pytest.raises(InvalidParameter, match="must not contain '\\|'") as
raised:
+ sign_agent_token_request(private_jwk, **kwargs)
+ assert raised.value.error_code == "E3021"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/modelscope_hub-0.4.2/tests/test_compat_constants_completeness.py
new/modelscope_hub-0.4.3/tests/test_compat_constants_completeness.py
--- old/modelscope_hub-0.4.2/tests/test_compat_constants_completeness.py
1970-01-01 01:00:00.000000000 +0100
+++ new/modelscope_hub-0.4.3/tests/test_compat_constants_completeness.py
2026-09-15 14:09:12.688884000 +0200
@@ -0,0 +1,88 @@
+"""Completeness / consistency of the legacy compat constant surface.
+
+``modelscope.hub.constants`` re-exports these names from
+``modelscope_hub.compat.constants``, which in turn re-exports them from the
root
+``modelscope_hub.constants`` (the single source of truth). Two invariants must
+hold and are pinned below:
+
+* every legacy constant is present in the compat surface with the exact legacy
+ value and type (what the modelscope SDK imports); and
+* the root module is a superset of the compat surface for the shared prefixes
+ (so a legacy alias is never defined only in the compat shim again).
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+
+import modelscope_hub.compat.constants as hub_mod
+
+# Exact values (and types) as historically defined in modelscope.hub.constants.
+_EXPECTED: dict[str, object] = {
+ "MODEL_ID_SEPARATOR": "/",
+ "DEFAULT_MODELSCOPE_GROUP": "damo",
+ "DEFAULT_MODELSCOPE_DOMAIN": "www.modelscope.cn",
+ "DEFAULT_MODELSCOPE_INTL_DOMAIN": "www.modelscope.ai",
+ "DEFAULT_MODELSCOPE_DATA_ENDPOINT": "https://www.modelscope.cn",
+ "DEFAULT_MODELSCOPE_INTL_DATA_ENDPOINT": "https://www.modelscope.ai",
+ "DEFAULT_SKILLS_DIR": os.path.join(os.path.expanduser("~"), ".agents",
"skills"),
+ "DEFAULT_CREDENTIALS_PATH": Path.home().joinpath(".modelscope",
"credentials"),
+}
+
+
[email protected](("name", "expected"), sorted(_EXPECTED.items()))
+def test_compat_exposes_legacy_constant(name, expected):
+ assert hasattr(hub_mod, name), f"{name} missing from
modelscope_hub.compat.constants"
+ value = getattr(hub_mod, name)
+ assert value == expected
+ # Type must match too: modelscope derives MODELSCOPE_CREDENTIALS_PATH via
+ # DEFAULT_CREDENTIALS_PATH.as_posix(), so a str stand-in would break it.
+ assert type(value) is type(expected)
+
+
+def test_credentials_path_is_a_path_supporting_as_posix():
+ assert isinstance(hub_mod.DEFAULT_CREDENTIALS_PATH, Path)
+ assert
hub_mod.DEFAULT_CREDENTIALS_PATH.as_posix().endswith("/.modelscope/credentials")
+
+
+def test_matches_installed_modelscope_when_available():
+ """Mirror the field completeness/consistency check against the real SDK.
+
+ Skipped when ``modelscope`` is not importable (e.g. the hub CI matrix),
+ so the pinned assertions above remain the source of truth there.
+ """
+ legacy = pytest.importorskip("modelscope.hub.constants")
+ prefixes = ("UPLOAD_", "REPO_", "MODEL_", "DEFAULT_", "TEMPORARY_",
"FILE_")
+ targets = [a for a in dir(legacy) if not a.startswith("_") and
a.startswith(prefixes)]
+ missing = [a for a in targets if not hasattr(hub_mod, a)]
+ mismatch = [
+ (a, getattr(legacy, a), getattr(hub_mod, a))
+ for a in targets
+ if hasattr(hub_mod, a) and getattr(legacy, a) != getattr(hub_mod, a)
+ ]
+ assert not missing, f"missing from compat: {missing}"
+ assert not mismatch, f"value mismatch: {mismatch}"
+
+
+def test_root_module_is_superset_of_compat_surface():
+ """``modelscope_hub.constants`` must expose every prefixed compat constant.
+
+ compat re-exports from the root module, so the root is the single source of
+ truth; a name present in compat but missing from (or unequal in) the root
+ means a legacy alias regressed into being compat-only again.
+ """
+ import modelscope_hub.constants as root_mod
+
+ prefixes = ("UPLOAD_", "REPO_", "MODEL_", "DEFAULT_", "TEMPORARY_",
"FILE_", "MCP_")
+ keys = [a for a in dir(hub_mod) if not a.startswith("_") and
a.startswith(prefixes)]
+ missing = [k for k in keys if not hasattr(root_mod, k)]
+ mismatch = [
+ (k, getattr(hub_mod, k), getattr(root_mod, k))
+ for k in keys
+ if hasattr(root_mod, k) and getattr(hub_mod, k) != getattr(root_mod, k)
+ ]
+ assert not missing, f"missing from modelscope_hub.constants: {missing}"
+ assert not mismatch, f"value mismatch: {mismatch}"