diff --git a/src/modelscope_hub/_openapi.py b/src/modelscope_hub/_openapi.py index 55d6aec..93a71b1 100644 --- a/src/modelscope_hub/_openapi.py +++ b/src/modelscope_hub/_openapi.py @@ -1084,6 +1084,62 @@ def _flatten_mcp_list_params(body: Mapping[str, Any]) -> QueryParams: params.append((key, str(value).lower() if isinstance(value, bool) else str(value))) 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) @@ -1309,6 +1365,8 @@ def list_mcp_servers( """ 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 @@ -1328,14 +1386,16 @@ def list_mcp_servers( 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 @@ -1343,14 +1403,16 @@ def _get() -> JSON: 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 --git a/src/modelscope_hub/compat/constants.py b/src/modelscope_hub/compat/constants.py index 75baec2..e666b5a 100644 --- a/src/modelscope_hub/compat/constants.py +++ b/src/modelscope_hub/compat/constants.py @@ -1,76 +1,79 @@ -"""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 -) -from ..constants import ( - UPLOAD_LFS_THRESHOLD as _UPLOAD_LFS_THRESHOLD, -) -from ..constants import ( - UPLOAD_MAX_FILE_COUNT as _UPLOAD_MAX_FILE_COUNT, + get_upload_ignore_file_pattern, ) -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) ModelVisibility_PRIVATE: int = int(Visibility.PRIVATE) @@ -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 --git a/src/modelscope_hub/constants.py b/src/modelscope_hub/constants.py index 8dd7326..1a9f970 100644 --- a/src/modelscope_hub/constants.py +++ b/src/modelscope_hub/constants.py @@ -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`). @@ -182,6 +183,38 @@ class License(StrEnum): LEGACY_API_PREFIX: str = "/api/v1" +# --------------------------------------------------------------------------- +# 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) # --------------------------------------------------------------------------- @@ -503,6 +536,9 @@ def _env_register( 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 @@ def get_upload_ignore_file_pattern() -> str | None: # 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 @@ def get_upload_ignore_file_pattern() -> str | None: 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 @@ def get_upload_ignore_file_pattern() -> str | None: "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 @@ def get_upload_ignore_file_pattern() -> str | None: "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 @@ def get_upload_ignore_file_pattern() -> str | None: "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 @@ def get_upload_ignore_file_pattern() -> str | None: "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 --git a/src/modelscope_hub/version.py b/src/modelscope_hub/version.py index e82eace..06d4093 100644 --- a/src/modelscope_hub/version.py +++ b/src/modelscope_hub/version.py @@ -1,3 +1,3 @@ """Version information for modelscope_hub.""" -__version__ = "0.4.2+main" +__version__ = "0.4.3+main" diff --git a/tests/cli/test_openapi.py b/tests/cli/test_openapi.py index 424b885..5f0115d 100644 --- a/tests/cli/test_openapi.py +++ b/tests/cli/test_openapi.py @@ -81,6 +81,27 @@ def test_filter_absent_when_not_requested(self, client): client.list_mcp_servers() assert "filter" not in mock_req.call_args.kwargs["json"] + def test_soft_input_parameter_error_is_not_downgraded_to_empty_list(self, client): + response = _mock_response( + json_data={ + "success": False, + "request_id": "soft-error-1", + "code": "InputParameterError", + "message": "Input parameter is invalid: invalid request body", + } + ) + with patch.object(client._session, "request", return_value=response) as mock_req: + with pytest.raises(InvalidParameter) as raised: + client.list_mcp_servers(page_number=1, page_size=20) + error = raised.value + assert error.error_code == "E3021" + assert error.status_code == 400 + assert error.request_id == "soft-error-1" + assert error.message == "Input parameter is invalid: invalid request body" + assert mock_req.call_count == 1 + assert mock_req.call_args.kwargs["method"] == "PUT" + assert mock_req.call_args.kwargs["json"] == {"page_number": 1, "page_size": 20} + def test_put_route_unsupported_falls_back_to_get(self, client): not_found = _mock_response(status_code=404, json_data={"message": "not found"}) success = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) @@ -155,7 +176,7 @@ def test_within_limit(self, client): with patch.object(client._session, "request", return_value=resp): client.list_mcp_servers(page_number=5, page_size=20) - @pytest.mark.parametrize("page_number", [0, -1]) + @pytest.mark.parametrize("page_number", [0, -1, True, 1.5]) def test_rejects_non_positive_page_number_before_request(self, client, page_number): with patch.object(client._session, "request") as mock_request: with pytest.raises(InvalidParameter, match="page_number must be an integer >= 1") as excinfo: @@ -164,6 +185,15 @@ def test_rejects_non_positive_page_number_before_request(self, client, page_numb assert excinfo.value.retryable is False mock_request.assert_not_called() + @pytest.mark.parametrize("page_size", [0, -1, True, 1.5]) + def test_rejects_non_positive_or_non_integer_page_size_before_request(self, client, page_size): + with patch.object(client._session, "request") as mock_request: + with pytest.raises(InvalidParameter, match="page_size must be an integer >= 1") as excinfo: + client.list_mcp_servers(page_size=page_size) + assert excinfo.value.error_code == "E3021" + assert excinfo.value.retryable is False + mock_request.assert_not_called() + def test_exceeds_limit(self, client): with pytest.raises(InvalidParameter, match="<= 100"): client.list_mcp_servers(page_number=11, page_size=10) diff --git a/tests/test_compat_constants_completeness.py b/tests/test_compat_constants_completeness.py new file mode 100644 index 0000000..1ab3e3a --- /dev/null +++ b/tests/test_compat_constants_completeness.py @@ -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"), +} + + +@pytest.mark.parametrize(("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}"