diff --git a/src/modelscope_hub/_legacy_api.py b/src/modelscope_hub/_legacy_api.py index e84e516..cbc0bde 100644 --- a/src/modelscope_hub/_legacy_api.py +++ b/src/modelscope_hub/_legacy_api.py @@ -30,7 +30,7 @@ UPLOAD_BLOB_READ_TIMEOUT, UPLOAD_RETRY_ALLOWED_METHODS, ) -from .errors import InvalidParameter, NetworkError, RequestTimeoutError, raise_for_status +from .errors import InvalidParameter, NetworkError, RequestTimeoutError, ServerError, raise_for_status from .utils.logger import get_logger logger = get_logger("legacy_api") @@ -179,6 +179,8 @@ def _request( timeout=timeout or self._timeout, stream=stream, ) + except requests.exceptions.RetryError as exc: + raise ServerError(f"Max retries exceeded: {exc}") from exc except requests.ConnectionError as exc: raise NetworkError(f"Connection failed: {exc}") from exc except requests.Timeout as exc: diff --git a/src/modelscope_hub/_openapi.py b/src/modelscope_hub/_openapi.py index 4ccc852..dfffca4 100644 --- a/src/modelscope_hub/_openapi.py +++ b/src/modelscope_hub/_openapi.py @@ -28,6 +28,7 @@ from .constants import API_MAX_RETRIES, API_TIMEOUT, OPENAPI_PREFIX from .errors import ( AuthenticationError, + InvalidParameter, NetworkError, RateLimitError, RequestTimeoutError, @@ -50,6 +51,9 @@ # HTTP methods that are safe to retry without risking duplicate side-effects. _IDEMPOTENT_METHODS: frozenset[str] = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) +# POST endpoints that are semantically idempotent (deploy/stop are state transitions). +_RETRYABLE_POST_PATHS: frozenset[str] = frozenset({"/deploy", "/stop", "/undeploy"}) + # Errors that warrant a transparent retry. _RETRYABLE_EXC: tuple[type[BaseException], ...] = ( NetworkError, ServerError, RateLimitError, @@ -225,8 +229,12 @@ def _request( else: return self._decode(response, unwrap=unwrap) - # Retry policy: only for idempotent methods on transient errors. - if attempt >= attempts or method_upper not in _IDEMPOTENT_METHODS: + # Retry policy: idempotent methods + known-idempotent POST paths. + is_retryable = ( + method_upper in _IDEMPOTENT_METHODS + or (method_upper == "POST" and any(path.endswith(p) for p in _RETRYABLE_POST_PATHS)) + ) + if attempt >= attempts or not is_retryable: break backoff = min(2 ** (attempt - 1), 16) _logger.debug( @@ -271,7 +279,7 @@ def list_models( owner: str | None = None, sort: str | None = None, page_number: int = 1, - page_size: int = 20, + page_size: int = 10, filters: Filters = None, ) -> JSON: """``GET /models`` — list models with pagination and filters. @@ -279,6 +287,10 @@ def list_models( Supported filter keys: ``task``, ``library``, ``model_type``, ``custom_tag``, ``license``, ``deploy``. """ + if page_number * page_size > 3000: + raise InvalidParameter( + f"page_number * page_size must be <= 3000 (got {page_number * page_size})." + ) params = self._merge_params( { "search": search, @@ -305,10 +317,14 @@ def list_datasets( owner: str | None = None, sort: str | None = None, page_number: int = 1, - page_size: int = 20, + page_size: int = 10, filters: Filters = None, ) -> JSON: """``GET /datasets`` — list datasets. Filter keys: ``task``, ``license``.""" + if page_number * page_size > 3000: + raise InvalidParameter( + f"page_number * page_size must be <= 3000 (got {page_number * page_size})." + ) params = self._merge_params( { "search": search, @@ -379,7 +395,7 @@ def list_skills( *, search: str | None = None, page_number: int = 1, - page_size: int = 20, + page_size: int = 10, filters: Filters = None, ) -> JSON: """``GET /skills`` — list skills. @@ -387,6 +403,10 @@ def list_skills( Filter keys: ``developer``, ``category``, ``license``, ``custom_tag``, ``owner``. """ + if page_number * page_size > 3000: + raise InvalidParameter( + f"page_number * page_size must be <= 3000 (got {page_number * page_size})." + ) params = self._merge_params( { "search": search, @@ -427,7 +447,7 @@ def create_studio(self, payload: CreateStudioPayload | Mapping[str, Any]) -> JSO def get_studio(self, owner: str, repo_name: str) -> JSON: """``GET /studios/{owner}/{repo_name}`` — fetch Studio metadata.""" - return self._request("GET", f"/studios/{owner}/{repo_name}", require_token=False) + return self._request("GET", f"/studios/{owner}/{repo_name}") def deploy_studio( self, @@ -439,12 +459,12 @@ def deploy_studio( return self._request( "POST", f"/studios/{owner}/{repo_name}/deploy", - json_body=dict(payload or {}), + json_body=dict(payload) if payload else None, ) def stop_studio(self, owner: str, repo_name: str) -> JSON: """``POST /studios/{owner}/{repo_name}/stop`` — stop a running Studio.""" - return self._request("POST", f"/studios/{owner}/{repo_name}/stop") + return self._request("POST", f"/studios/{owner}/{repo_name}/stop", json_body=None) def get_studio_logs( self, @@ -523,15 +543,28 @@ def list_mcp_servers( *, search: str | None = None, page_number: int = 1, - page_size: int = 20, + page_size: int = 10, + filter: Mapping[str, Any] | None = None, extra: Mapping[str, Any] | None = None, ) -> JSON: - """``PUT /mcp/servers`` — discover MCP servers (JSON body, not query).""" + """``PUT /mcp/servers`` — discover MCP servers (JSON body, not query). + + Parameters + ---------- + filter : dict, optional + Nested filter object. Supported keys: ``category``, ``is_hosted``. + """ + if page_number * page_size > 100: + raise InvalidParameter( + f"page_number * page_size must be <= 100 for MCP servers (got {page_number * page_size})." + ) body: dict[str, Any] = { "search": search, "page_number": page_number, "page_size": page_size, } + if filter: + body["filter"] = dict(filter) if extra: body.update(extra) body = {k: v for k, v in body.items() if v is not None} diff --git a/src/modelscope_hub/api.py b/src/modelscope_hub/api.py index 05bd334..68456e5 100644 --- a/src/modelscope_hub/api.py +++ b/src/modelscope_hub/api.py @@ -248,7 +248,7 @@ def _normalize_visibility(visibility: int | str | Visibility | None) -> int | No _PAGED_ITEM_KEYS = ( "items", "list", "data", "results", - "models", "datasets", "skills", "servers", + "models", "datasets", "skills", "servers", "mcp_server_list", "Models", "Datasets", "Skills", "Servers", ) _PAGED_META_KEYS = frozenset({ @@ -824,7 +824,7 @@ def list_repos( payload = self.openapi.list_mcp_servers( search=search, page_number=page_number, page_size=page_size, - extra=clean_filters or None, + filter=clean_filters or None, ) elif rt is RepoType.STUDIO: raise NotSupportedError( @@ -834,6 +834,10 @@ def list_repos( raise NotSupportedError(f"list_repos not supported for {rt}") items, total, page, size = self._extract_paged(payload) + # MCP response omits page_number/page_size — use requested values. + if rt is RepoType.MCP: + page = page_number + size = page_size infos = [self._repo_info_from_payload(item, rt) for item in items] return PagedResult(items=infos, total_count=total, page_number=page, page_size=size) diff --git a/tests/cli/test_openapi.py b/tests/cli/test_openapi.py new file mode 100644 index 0000000..deef71d --- /dev/null +++ b/tests/cli/test_openapi.py @@ -0,0 +1,269 @@ +"""Unit tests for the OpenAPIClient (mock-based, no network). + +Covers fixes from audit items 2,3,4,5,6,8,10 and Section III risks. +""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from modelscope_hub._openapi import OpenAPIClient, _RETRYABLE_POST_PATHS +from modelscope_hub.api import HubApi +from modelscope_hub.config import HubConfig +from modelscope_hub.errors import InvalidParameter, ServerError + + +@pytest.fixture +def config(): + return HubConfig(token="test-token", endpoint="https://modelscope.cn") + + +@pytest.fixture +def client(config): + return OpenAPIClient(config) + + +def _mock_response(status_code=200, json_data=None): + resp = MagicMock(spec=requests.Response) + resp.status_code = status_code + resp.content = b'{"data": {}}' if json_data is None else b"x" + resp.headers = {} + resp.request = MagicMock() + resp.request.method = "GET" + resp.request.path_url = "/test" + resp.request.url = "https://modelscope.cn/test" + resp.url = "https://modelscope.cn/test" + if json_data is not None: + resp.json.return_value = json_data + resp.content = b"x" + else: + resp.json.return_value = {"success": True, "data": {}} + return resp + + +# ================================================================== +# Item 2: list_mcp_servers filter param +# ================================================================== +class TestListMcpServersFilter: + def test_filter_included_in_body(self, client): + resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_mcp_servers(filter={"category": "tools", "is_hosted": True}) + call_kwargs = mock_req.call_args.kwargs + body = call_kwargs["json"] + assert body["filter"] == {"category": "tools", "is_hosted": True} + + def test_filter_none_not_in_body(self, client): + resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_mcp_servers() + call_kwargs = mock_req.call_args.kwargs + body = call_kwargs["json"] + assert "filter" not in body + + +# ================================================================== +# Item 2 + Section III: MCP pagination limit +# ================================================================== +class TestMcpPaginationLimit: + def test_within_limit(self, client): + resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", return_value=resp): + client.list_mcp_servers(page_number=5, page_size=20) + + def test_exceeds_limit(self, client): + with pytest.raises(InvalidParameter, match="<= 100"): + client.list_mcp_servers(page_number=11, page_size=10) + + def test_at_boundary(self, client): + resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", return_value=resp): + client.list_mcp_servers(page_number=10, page_size=10) + + +# ================================================================== +# Item 3: deploy_studio no empty body +# ================================================================== +class TestDeployStudioBody: + def test_no_payload_sends_none(self, client): + resp = _mock_response() + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.deploy_studio("org", "demo") + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["json"] is None + + def test_with_payload_sends_dict(self, client): + resp = _mock_response() + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.deploy_studio("org", "demo", payload={"instance_count": 2}) + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["json"] == {"instance_count": 2} + + +# ================================================================== +# Item 4: _extract_paged MCP passthrough +# ================================================================== +class TestExtractPagedMcp: + def test_mcp_server_list_key_recognized(self): + payload = { + "mcp_server_list": [{"id": 1}, {"id": 2}], + "total": 50, + } + items, total, page, size = HubApi._extract_paged(payload) + assert len(items) == 2 + assert total == 50 + assert page == 1 + assert size == 2 # fallback since response has no page_size + + def test_list_repos_mcp_overrides_page_size(self): + api = HubApi(config=HubConfig(token="t", endpoint="https://modelscope.cn")) + mcp_response = { + "mcp_server_list": [{"id": "1", "name": "test"}], + "total": 30, + } + with patch.object(api.openapi, "list_mcp_servers", return_value=mcp_response): + result = api.list_repos("mcp", page_number=2, page_size=10) + assert result.page_number == 2 + assert result.page_size == 10 + assert result.total_count == 30 + + +# ================================================================== +# Item 5: stop_studio no body +# ================================================================== +class TestStopStudioBody: + def test_no_json_body_sent(self, client): + resp = _mock_response() + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.stop_studio("org", "demo") + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["json"] is None + + +# ================================================================== +# Item 6: get_studio requires token +# ================================================================== +class TestGetStudioAuth: + def test_requires_token_raises_without_token(self): + from modelscope_hub.errors import AuthenticationError + config = HubConfig(token="placeholder", endpoint="https://modelscope.cn") + config.token = None + client = OpenAPIClient(config) + with patch.object(HubConfig, "load_token", return_value=None): + with pytest.raises(AuthenticationError, match="Missing API token"): + client.get_studio("org", "demo") + + def test_sends_auth_header_when_token_present(self, client): + resp = _mock_response() + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.get_studio("org", "demo") + call_kwargs = mock_req.call_args.kwargs + assert "Authorization" in call_kwargs["headers"] + assert call_kwargs["headers"]["Authorization"] == "Bearer test-token" + + +# ================================================================== +# Item 8: page_size defaults +# ================================================================== +class TestPageSizeDefaults: + def test_list_models_default_page_size(self, client): + resp = _mock_response(json_data={"success": True, "data": {"models": [], "total_count": 0}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_models() + call_kwargs = mock_req.call_args.kwargs + params = call_kwargs["params"] + param_dict = dict(params) if isinstance(params, list) else params + assert param_dict.get("page_size") == "10" + + def test_list_datasets_default_page_size(self, client): + resp = _mock_response(json_data={"success": True, "data": {"datasets": [], "total_count": 0}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_datasets() + call_kwargs = mock_req.call_args.kwargs + params = call_kwargs["params"] + param_dict = dict(params) if isinstance(params, list) else params + assert param_dict.get("page_size") == "10" + + def test_list_skills_default_page_size(self, client): + resp = _mock_response(json_data={"success": True, "data": {"skills": [], "total_count": 0}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_skills() + call_kwargs = mock_req.call_args.kwargs + params = call_kwargs["params"] + param_dict = dict(params) if isinstance(params, list) else params + assert param_dict.get("page_size") == "10" + + def test_list_mcp_servers_default_page_size(self, client): + resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_mcp_servers() + call_kwargs = mock_req.call_args.kwargs + body = call_kwargs["json"] + assert body["page_size"] == 10 + + +# ================================================================== +# Section III: models/datasets pagination limit +# ================================================================== +class TestModelDatasetPaginationLimit: + def test_list_models_exceeds_limit(self, client): + with pytest.raises(InvalidParameter, match="<= 3000"): + client.list_models(page_number=61, page_size=50) + + def test_list_datasets_exceeds_limit(self, client): + with pytest.raises(InvalidParameter, match="<= 3000"): + client.list_datasets(page_number=61, page_size=50) + + def test_list_skills_exceeds_limit(self, client): + with pytest.raises(InvalidParameter, match="<= 3000"): + client.list_skills(page_number=61, page_size=50) + + def test_list_models_at_boundary(self, client): + resp = _mock_response(json_data={"success": True, "data": {"models": [], "total_count": 0}}) + with patch.object(client._session, "request", return_value=resp): + client.list_models(page_number=60, page_size=50) + + def test_list_models_page1_large_size_ok(self, client): + resp = _mock_response(json_data={"success": True, "data": {"models": [], "total_count": 0}}) + with patch.object(client._session, "request", return_value=resp): + client.list_models(page_number=1, page_size=50) + + +# ================================================================== +# Item 10: retry logic for idempotent POSTs +# ================================================================== +class TestRetryIdempotentPost: + def test_retryable_post_paths_defined(self): + assert "/deploy" in _RETRYABLE_POST_PATHS + assert "/stop" in _RETRYABLE_POST_PATHS + assert "/undeploy" in _RETRYABLE_POST_PATHS + + def test_deploy_studio_retried_on_server_error(self, client): + error_resp = _mock_response(status_code=500, json_data={"message": "Internal error"}) + success_resp = _mock_response(status_code=200, json_data={"success": True, "data": {"status": "deploying"}}) + with patch.object(client._session, "request", side_effect=[error_resp, success_resp]) as mock_req: + result = client.deploy_studio("org", "demo") + assert mock_req.call_count == 2 + + def test_stop_studio_retried_on_server_error(self, client): + error_resp = _mock_response(status_code=500, json_data={"message": "Internal error"}) + success_resp = _mock_response(status_code=200, json_data={"success": True, "data": {"status": "stopped"}}) + with patch.object(client._session, "request", side_effect=[error_resp, success_resp]) as mock_req: + result = client.stop_studio("org", "demo") + assert mock_req.call_count == 2 + + def test_create_skill_not_retried(self, client): + error_resp = _mock_response(status_code=500, json_data={"message": "Internal error"}) + with patch.object(client._session, "request", return_value=error_resp) as mock_req: + with pytest.raises(ServerError): + client.create_skill({"owner": "org", "skill_name": "test"}) + assert mock_req.call_count == 1 + + def test_deploy_mcp_server_retried(self, client): + error_resp = _mock_response(status_code=500, json_data={"message": "Internal error"}) + success_resp = _mock_response(status_code=200, json_data={"success": True, "data": {"status": "running"}}) + with patch.object(client._session, "request", side_effect=[error_resp, success_resp]) as mock_req: + result = client.deploy_mcp_server("123") + assert mock_req.call_count == 2 diff --git a/tests/integration/run_all.py b/tests/integration/run_all.py new file mode 100644 index 0000000..ee33072 --- /dev/null +++ b/tests/integration/run_all.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Run all integration tests (requires network and API credentials). + +Usage: + python tests/integration/run_all.py # from project root + python tests/integration/run_all.py --quick # skip slow tests (file ops) + python tests/integration/run_all.py --dry-run # collect only, don't execute + +Environment: + MODELSCOPE_TEST_TOKEN — API token for authenticated operations + MODELSCOPE_TEST_OWNER — Owner username for repo creation/listing + MODELSCOPE_TEST_ENDPOINT — API endpoint (default: https://modelscope.cn) + +The tests are organized by operation type: + test_openapi.py — Raw OpenAPI client surface (models, datasets, MCP, skills) + test_sdk_api.py — HubApi facade: repo CRUD, file ops, versioning, cache, compat + test_remote_repo.py — Repo lifecycle with cleanup + test_remote_file_ops.py — File upload/download/delete with cleanup + test_dataset_ops.py — Dataset-specific file listing and download +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +_TESTS_DIR = Path(__file__).resolve().parent +_PROJECT_ROOT = _TESTS_DIR.parent.parent + + +def main() -> int: + args = sys.argv[1:] + dry_run = "--dry-run" in args + quick = "--quick" in args + + cmd = [ + sys.executable, "-m", "pytest", + str(_TESTS_DIR), + "-v", + "--tb=short", + "-m", "remote", + ] + + if dry_run: + cmd.append("--collect-only") + + if quick: + cmd.extend(["--ignore", str(_TESTS_DIR / "test_remote_file_ops.py")]) + + for arg in args: + if arg not in ("--dry-run", "--quick"): + cmd.append(arg) + + print(f"Running: {' '.join(cmd)}") + print(f"Working directory: {_PROJECT_ROOT}") + print() + return subprocess.call(cmd, cwd=str(_PROJECT_ROOT)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/integration/test_openapi.py b/tests/integration/test_openapi.py index 5f0950d..5dc9dfc 100644 --- a/tests/integration/test_openapi.py +++ b/tests/integration/test_openapi.py @@ -78,11 +78,35 @@ class TestOpenAPIMCP: def test_list_mcp_servers(self, openapi): result = openapi.list_mcp_servers(page_size=5) assert isinstance(result, dict) + servers = result.get("mcp_server_list") or [] + assert isinstance(servers, list) + assert len(servers) <= 5 def test_list_mcp_servers_with_search(self, openapi): result = openapi.list_mcp_servers(search="weather", page_size=3) assert isinstance(result, dict) + def test_list_mcp_servers_with_filter(self, openapi): + result = openapi.list_mcp_servers( + page_size=5, + filter={"is_hosted": True}, + ) + assert isinstance(result, dict) + + def test_list_mcp_servers_total_count(self, openapi): + result = openapi.list_mcp_servers(page_size=1) + total = result.get("total") or result.get("total_count") or 0 + assert total > 0 + + def test_get_mcp_server(self, openapi): + listing = openapi.list_mcp_servers(page_size=1) + servers = listing.get("mcp_server_list") or [] + if not servers: + pytest.skip("No MCP servers available") + server_id = servers[0].get("id") or servers[0].get("Id") + result = openapi.get_mcp_server(server_id) + assert isinstance(result, dict) + @pytest.mark.remote class TestOpenAPISkills: @@ -91,3 +115,46 @@ class TestOpenAPISkills: def test_list_skills(self, openapi): result = openapi.list_skills(page_size=5) assert isinstance(result, dict) + skills = result.get("skills") or result.get("Skills") or [] + assert isinstance(skills, list) + + def test_list_skills_with_search(self, openapi): + result = openapi.list_skills(search="chat", page_size=3) + assert isinstance(result, dict) + + +@pytest.mark.remote +class TestOpenAPIStudios: + """Test studio endpoints via OpenAPI (read-only).""" + + def test_get_studio_public(self, openapi): + try: + result = openapi.get_studio("modelscope", "Qwen2.5-Coder-artifacts") + assert isinstance(result, dict) + except Exception: + pytest.skip("Public studio not available or requires auth") + + +@pytest.mark.remote +class TestOpenAPIPagination: + """Test pagination defaults and limits.""" + + def test_models_default_page_size_returns_10(self, openapi): + result = openapi.list_models() + models = result.get("Models") or result.get("models") or [] + assert len(models) <= 10 + + def test_datasets_default_page_size_returns_10(self, openapi): + result = openapi.list_datasets() + datasets = result.get("Datasets") or result.get("datasets") or [] + assert len(datasets) <= 10 + + def test_models_pagination_page_2(self, openapi): + page1 = openapi.list_models(page_size=3, page_number=1) + page2 = openapi.list_models(page_size=3, page_number=2) + models1 = page1.get("Models") or page1.get("models") or [] + models2 = page2.get("Models") or page2.get("models") or [] + if models1 and models2: + ids1 = {m.get("id") or m.get("Id") for m in models1} + ids2 = {m.get("id") or m.get("Id") for m in models2} + assert ids1 != ids2 diff --git a/tests/integration/test_sdk_api.py b/tests/integration/test_sdk_api.py index e446e7f..cd57eb1 100644 --- a/tests/integration/test_sdk_api.py +++ b/tests/integration/test_sdk_api.py @@ -260,8 +260,8 @@ def test_list_models(self, api): assert hasattr(result, "items") assert len(result.items) <= 5 - def test_list_datasets(self, api): - result = api.list_repos("dataset", page_size=3) + def test_list_datasets(self, api, test_owner): + result = api.list_repos("dataset", owner=test_owner, page_size=3) assert hasattr(result, "items") assert isinstance(result.items, list) @@ -352,3 +352,101 @@ def test_legacy_hub_api_get_model_files(self, test_token, test_endpoint): assert isinstance(files, list) paths = [f.get("Path") for f in files] assert "config.json" in paths + + +@pytest.mark.remote +class TestListReposFacade: + """Test list_repos for all supported repo types via HubApi facade.""" + + def test_list_models_with_search(self, api): + result = api.list_repos("model", search="bert", page_size=3) + assert result.total_count >= 0 + assert len(result.items) <= 3 + + def test_list_models_with_owner(self, api, test_owner): + result = api.list_repos("model", owner=test_owner, page_size=5) + assert isinstance(result.items, list) + for item in result.items: + assert item.owner == test_owner + + def test_list_datasets_legacy_path(self, api, test_owner): + """Datasets without search/sort/filters use legacy API.""" + result = api.list_repos("dataset", owner=test_owner, page_size=5) + assert isinstance(result.items, list) + assert result.page_size == 5 + + def test_list_datasets_openapi_path(self, api): + """Datasets with search use OpenAPI path.""" + result = api.list_repos("dataset", search="nlp", page_size=3) + assert isinstance(result.items, list) + assert result.total_count >= 0 + + def test_list_skills(self, api): + result = api.list_repos("skill", page_size=5) + assert isinstance(result.items, list) + assert result.total_count >= 0 + + def test_list_mcp_servers(self, api): + result = api.list_repos("mcp", page_size=5) + assert isinstance(result.items, list) + assert result.total_count >= 0 + assert result.page_size == 5 + assert result.page_number == 1 + + def test_list_mcp_servers_page_2(self, api): + result = api.list_repos("mcp", page_number=2, page_size=3) + assert result.page_number == 2 + assert result.page_size == 3 + + def test_list_studio_raises(self, api): + with pytest.raises(Exception, match="not supported"): + api.list_repos("studio") + + +@pytest.mark.remote +class TestMCPServerFacade: + """Test MCP server read operations via HubApi facade.""" + + def test_list_mcp_servers_method(self, api): + result = api.list_mcp_servers(page_size=3) + assert hasattr(result, "items") + assert hasattr(result, "total_count") + assert result.total_count >= 0 + + def test_list_mcp_servers_search(self, api): + result = api.list_mcp_servers(search="weather", page_size=5) + assert isinstance(result.items, list) + + def test_get_mcp_server(self, api): + listing = api.list_mcp_servers(page_size=1) + if not listing.items: + pytest.skip("No MCP servers available") + server = listing.items[0] + server_id = server.get("id") or server.get("Id") + if not server_id: + pytest.skip("Server has no id field") + result = api.get_mcp_server(str(server_id)) + assert isinstance(result, dict) + + +@pytest.mark.remote +class TestPaginationEdgeCases: + """Test pagination boundary behavior.""" + + def test_page_size_1_returns_single_item(self, api): + result = api.list_repos("model", page_size=1) + assert len(result.items) <= 1 + assert result.total_count >= 1 + + def test_large_page_number_returns_empty(self, api, test_owner): + result = api.list_repos("model", owner=test_owner, page_number=200, page_size=10) + assert result.items == [] + + def test_pagination_consistency(self, api, test_owner): + """page_size * page_number navigates correctly.""" + page1 = api.list_repos("model", owner=test_owner, page_number=1, page_size=2) + page2 = api.list_repos("model", owner=test_owner, page_number=2, page_size=2) + if page1.items and page2.items: + ids1 = {i.repo_id for i in page1.items} + ids2 = {i.repo_id for i in page2.items} + assert ids1.isdisjoint(ids2)