From bc0a88b802c86e665d8a41305e2f109e83720c99 Mon Sep 17 00:00:00 2001 From: Pankaj Baid Date: Tue, 25 Nov 2025 01:37:19 +0530 Subject: [PATCH] feat: Add CLI Commands for Browsing and Searching OpenML Studies This commit implements CLI commands for browsing and searching OpenML studies and benchmark suites, addressing issue #1506. ## Features Added: ### 1. Studies List Command () - Lists studies and/or benchmark suites with optional filtering - Supports filtering by: - Status (active, in_preparation, deactivated, all) - Uploader ID - Type (study, suite, or all) - Size and offset for pagination - Three output formats: list (default), table, json - Verbose mode shows detailed information ### 2. Studies Info Command () - Displays detailed information about a specific study or suite - Automatically detects whether ID refers to a study or suite - Shows study metadata, description, and entity counts - Verbose mode displays first 10 IDs of associated entities ### 3. Studies Search Command () - Case-insensitive search by name or alias - Searches both studies and suites simultaneously - Optional status filtering - Multiple output formats supported - Verbose mode for detailed results ## Implementation Details: - Added imports for study functions and types (OpenMLStudy, OpenMLBenchmarkSuite) - Created helper functions for formatting: - _format_studies_output(): Routes to appropriate formatter - _format_studies_table(): Displays studies in tabular format - _format_studies_list(): Displays studies as simple list - _display_study_entity_counts(): Shows entity counts - _display_study_entity_ids(): Shows first 10 entity IDs - Main command functions: - studies_list(): Handles listing with filters - studies_info(): Displays detailed study information - studies_search(): Performs search operations - studies(): Dispatcher for routing subcommands - Updated main() to register studies subparser with all arguments - Added proper type annotations using Union types ## Testing: - Created comprehensive test suite with 19 tests - All tests passing (19/19) - Tests cover: - Listing studies, suites, and combined results - Filtering by status, uploader, and type - Info display for studies and suites - Fallback from study to suite - Search functionality with various filters - Different output formats (list, table, json) - Verbose mode - Dispatcher routing - Error handling ## Code Quality: - All pre-commit checks passing: - ruff: Passed - ruff-format: Passed - mypy: Passed (with proper Union type annotations) - Code complexity kept under limits through helper function extraction - Proper error handling with informative messages - Follows existing CLI patterns and conventions Related: #1506 --- openml/cli.py | 396 ++++++++++++++++++++++++++++- tests/test_openml/test_cli.py | 460 ++++++++++++++++++++++++++++++++++ 2 files changed, 854 insertions(+), 2 deletions(-) create mode 100644 tests/test_openml/test_cli.py diff --git a/openml/cli.py b/openml/cli.py index d0a46e498..26fb84b7a 100644 --- a/openml/cli.py +++ b/openml/cli.py @@ -6,10 +6,16 @@ import string import sys from pathlib import Path -from typing import Callable +from typing import TYPE_CHECKING, Callable from urllib.parse import urlparse +import pandas as pd # Used at runtime for CLI output formatting + from openml import config +from openml.study import functions as study_functions + +if TYPE_CHECKING: + from openml.study.study import OpenMLBenchmarkSuite, OpenMLStudy def is_hex(string_: str) -> bool: @@ -327,12 +333,295 @@ def not_supported_yet(_: str) -> None: set_field_function(args.value) +def _format_studies_output( + studies_df: pd.DataFrame, + output_format: str, + *, + verbose: bool = False, +) -> None: + """Format and print studies output based on requested format. + + Parameters + ---------- + studies_df : pd.DataFrame + DataFrame containing studies information + output_format : str + Output format: 'json', 'table', or 'list' + verbose : bool + Whether to show detailed information + """ + if output_format == "json": + # Convert to JSON format + output = studies_df.to_json(orient="records", indent=2) + print(output) + elif output_format == "table": + _format_studies_table(studies_df, verbose=verbose) + else: # default: simple list + _format_studies_list(studies_df, verbose=verbose) + + +def _format_studies_table(studies_df: pd.DataFrame, *, verbose: bool = False) -> None: + """Format studies as a table. + + Parameters + ---------- + studies_df : pd.DataFrame + DataFrame containing studies information + verbose : bool + Whether to show all columns + """ + if verbose: + print(studies_df.to_string(index=False)) + else: + # Show only key columns for compact view + columns_to_show = ["id", "name", "main_entity_type", "status", "creator", "creation_date"] + available_columns = [col for col in columns_to_show if col in studies_df.columns] + print(studies_df[available_columns].to_string(index=False)) + + +def _format_studies_list(studies_df: pd.DataFrame, *, verbose: bool = False) -> None: + """Format studies as a simple list. + + Parameters + ---------- + studies_df : pd.DataFrame + DataFrame containing studies information + verbose : bool + Whether to show detailed information + """ + if verbose: + # Verbose: show detailed info for each study + for _, study in studies_df.iterrows(): + print(f"Study ID: {study['id']}") + print(f" Name: {study['name']}") + print(f" Type: {study.get('main_entity_type', 'N/A')}") + print(f" Status: {study.get('status', 'N/A')}") + print(f" Creator: {study.get('creator', 'N/A')}") + if "creation_date" in study and pd.notna(study["creation_date"]): + print(f" Created: {study['creation_date']}") + if "alias" in study and pd.notna(study["alias"]): + print(f" Alias: {study['alias']}") + print() + else: + # Simple: just list study IDs and names + for _, study in studies_df.iterrows(): + study_type = study.get("main_entity_type", "") + type_label = " (suite)" if study_type == "task" else "" + print(f"{study['id']}: {study['name']}{type_label}") + + +def studies_list(args: argparse.Namespace) -> None: + """List studies with optional filtering. + + Parameters + ---------- + args : argparse.Namespace + Arguments containing filtering criteria: status, uploader, type, size, offset, format + """ + # Build filter arguments, excluding None values + kwargs = {} + if args.status is not None: + kwargs["status"] = args.status + if args.uploader is not None: + kwargs["uploader"] = args.uploader + if args.size is not None: + kwargs["size"] = args.size + if args.offset is not None: + kwargs["offset"] = args.offset + + try: + # Fetch based on type + if args.type == "all": + # Fetch both studies and suites + studies_df = study_functions.list_studies(**kwargs) + suites_df = study_functions.list_suites(**kwargs) + # Combine results + combined_df = pd.concat([studies_df, suites_df], ignore_index=True) + _format_studies_output(combined_df, args.format, verbose=args.verbose) + elif args.type == "study": + # Fetch only studies (runs) + studies_df = study_functions.list_studies(**kwargs) + _format_studies_output(studies_df, args.format, verbose=args.verbose) + else: # suite + # Fetch only suites (tasks) + suites_df = study_functions.list_suites(**kwargs) + _format_studies_output(suites_df, args.format, verbose=args.verbose) + except Exception as e: # noqa: BLE001 + print(f"Error listing studies: {e}", file=sys.stderr) + sys.exit(1) + + +def _display_study_entity_counts(study: OpenMLStudy | OpenMLBenchmarkSuite) -> None: + """Display entity counts for a study. + + Parameters + ---------- + study : Union[OpenMLStudy, OpenMLBenchmarkSuite] + Study or suite object + """ + print("\nEntities:") + if hasattr(study, "data") and study.data: + print(f" Datasets: {len(study.data)}") + if hasattr(study, "tasks") and study.tasks: + print(f" Tasks: {len(study.tasks)}") + if hasattr(study, "flows") and study.flows: + print(f" Flows: {len(study.flows)}") + if hasattr(study, "runs") and study.runs: + print(f" Runs: {len(study.runs)}") + if hasattr(study, "setups") and study.setups: + print(f" Setups: {len(study.setups)}") + + +def _display_study_entity_ids(study: OpenMLStudy | OpenMLBenchmarkSuite) -> None: + """Display first 10 entity IDs for a study. + + Parameters + ---------- + study : Union[OpenMLStudy, OpenMLBenchmarkSuite] + Study or suite object + """ + if hasattr(study, "data") and study.data: + print(f"\nDataset IDs (first 10): {study.data[:10]}") + if hasattr(study, "tasks") and study.tasks: + print(f"Task IDs (first 10): {study.tasks[:10]}") + if hasattr(study, "flows") and study.flows: + print(f"Flow IDs (first 10): {study.flows[:10]}") + if hasattr(study, "runs") and study.runs: + print(f"Run IDs (first 10): {study.runs[:10]}") + + +def studies_info(args: argparse.Namespace) -> None: + """Display detailed information about a specific study. + + Parameters + ---------- + args : argparse.Namespace + Arguments containing the study_id to fetch + """ + try: + # Get study from server - try as study first, then as suite + study: OpenMLStudy | OpenMLBenchmarkSuite + try: + study = study_functions.get_study(args.study_id) + except Exception: # noqa: BLE001 + # Might be a suite (benchmark suite) + study = study_functions.get_suite(args.study_id) + + # Display study information + print(f"Study ID: {study.study_id}") + print(f"Name: {study.name}") + print(f"Main Entity Type: {study.main_entity_type}") + print(f"Status: {study.status}") + + if hasattr(study, "alias") and study.alias: + print(f"Alias: {study.alias}") + + if study.creator: + print(f"Creator: {study.creator}") + + if study.creation_date: + print(f"Creation Date: {study.creation_date}") + + if hasattr(study, "benchmark_suite") and study.benchmark_suite: + print(f"Benchmark Suite: {study.benchmark_suite}") + + # Display description + if study.description: + print("\nDescription:") + print(f" {study.description}") + + # Display entity counts and IDs + _display_study_entity_counts(study) + + if args.verbose: + _display_study_entity_ids(study) + + except Exception as e: # noqa: BLE001 + print(f"Error fetching study information: {e}", file=sys.stderr) + sys.exit(1) + + +def studies_search(args: argparse.Namespace) -> None: + """Search studies by name or alias. + + Parameters + ---------- + args : argparse.Namespace + Arguments containing the search query + """ + try: + # Get all studies (both types) + kwargs = {} + if args.status: + kwargs["status"] = args.status + + studies_df_runs = study_functions.list_studies(**kwargs) + studies_df_suites = study_functions.list_suites(**kwargs) + + # Combine both dataframes + if not studies_df_runs.empty and not studies_df_suites.empty: + all_studies = pd.concat([studies_df_runs, studies_df_suites], ignore_index=True) + elif not studies_df_runs.empty: + all_studies = studies_df_runs + elif not studies_df_suites.empty: + all_studies = studies_df_suites + else: + print("No studies found.") + return + + # Search by name (case-insensitive) + search_term = args.query.lower() + mask = all_studies["name"].str.lower().str.contains(search_term, na=False) + + # Also search by alias if available + if "alias" in all_studies.columns: + mask |= all_studies["alias"].str.lower().str.contains(search_term, na=False) + + results = all_studies[mask] + + if results.empty: + print(f"No studies found matching '{args.query}'.") + return + + print(f"Found {len(results)} study(ies) matching '{args.query}':\n") + + # Format output + _format_studies_output(results, args.format, verbose=args.verbose) + + except Exception as e: # noqa: BLE001 + print(f"Error searching studies: {e}", file=sys.stderr) + sys.exit(1) + + +def studies(args: argparse.Namespace) -> None: + """Route studies subcommands to the appropriate handler. + + Parameters + ---------- + args : argparse.Namespace + Arguments containing the subcommand and its arguments + """ + subcommands = { + "list": studies_list, + "info": studies_info, + "search": studies_search, + } + + handler = subcommands.get(args.studies_subcommand) + if handler: + handler(args) + else: + print(f"Unknown studies subcommand: {args.studies_subcommand}") + sys.exit(1) + + def main() -> None: - subroutines = {"configure": configure} + subroutines = {"configure": configure, "studies": studies} parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="subroutine") + # Configure subcommand parser_configure = subparsers.add_parser( "configure", description="Set or read variables in your configuration file. For more help also see " @@ -360,6 +649,109 @@ def main() -> None: help="The value to set the FIELD to.", ) + # Studies subcommand + parser_studies = subparsers.add_parser( + "studies", + description="Browse and search OpenML studies and benchmark suites from the command line.", + ) + studies_subparsers = parser_studies.add_subparsers(dest="studies_subcommand") + + # studies list subcommand + parser_studies_list = studies_subparsers.add_parser( + "list", + description="List studies/suites with optional filtering.", + help="List studies/suites with optional filtering.", + ) + parser_studies_list.add_argument( + "--status", + type=str, + choices=["active", "in_preparation", "deactivated", "all"], + help="Filter by status (default: active)", + ) + parser_studies_list.add_argument( + "--uploader", + type=int, + help="Filter by uploader ID", + ) + parser_studies_list.add_argument( + "--type", + type=str, + choices=["all", "study", "suite"], + default="all", + help="Type to list: all, study (runs), or suite (tasks) (default: all)", + ) + parser_studies_list.add_argument( + "--size", + type=int, + default=10, + help="Number of studies to retrieve (default: 10)", + ) + parser_studies_list.add_argument( + "--offset", + type=int, + default=0, + help="Offset for pagination (default: 0)", + ) + parser_studies_list.add_argument( + "--format", + type=str, + choices=["list", "table", "json"], + default="list", + help="Output format (default: list)", + ) + parser_studies_list.add_argument( + "--verbose", + action="store_true", + help="Show detailed information", + ) + + # studies info subcommand + parser_studies_info = studies_subparsers.add_parser( + "info", + description="Display detailed information about a specific study.", + help="Display detailed information about a specific study.", + ) + parser_studies_info.add_argument( + "study_id", + type=str, + help="Study ID (numeric or alias) to fetch information for", + ) + parser_studies_info.add_argument( + "--verbose", + action="store_true", + help="Show additional details including entity IDs", + ) + + # studies search subcommand + parser_studies_search = studies_subparsers.add_parser( + "search", + description="Search studies by name or alias.", + help="Search studies by name or alias.", + ) + parser_studies_search.add_argument( + "query", + type=str, + help="Search query (case-insensitive substring match)", + ) + parser_studies_search.add_argument( + "--status", + type=str, + choices=["active", "in_preparation", "deactivated", "all"], + help="Filter by status (default: active)", + ) + parser_studies_search.add_argument( + "--format", + type=str, + choices=["list", "table", "json"], + default="list", + help="Output format (default: list)", + ) + parser_studies_search.add_argument( + "--verbose", + action="store_true", + help="Show detailed information", + ) + args = parser.parse_args() subroutines.get(args.subroutine, lambda _: parser.print_help())(args) diff --git a/tests/test_openml/test_cli.py b/tests/test_openml/test_cli.py new file mode 100644 index 000000000..c938f7834 --- /dev/null +++ b/tests/test_openml/test_cli.py @@ -0,0 +1,460 @@ +# License: BSD 3-Clause +from __future__ import annotations + +import argparse +from unittest import mock + +import pandas as pd +import pytest + +from openml import cli + + +class TestCLIStudies: + """Test suite for studies CLI commands.""" + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_list_all_types(self, mock_list_suites, mock_list_studies): + """Test listing all types (studies + suites).""" + # Mock return values + mock_studies_df = pd.DataFrame({ + "id": [1, 2], + "alias": ["study1", "study2"], + "name": ["Study 1", "Study 2"], + "status": ["active", "active"], + "main_entity_type": ["run", "run"], + }) + mock_suites_df = pd.DataFrame({ + "id": [10, 11], + "alias": ["suite1", "suite2"], + "name": ["Suite 1", "Suite 2"], + "status": ["active", "active"], + "main_entity_type": ["task", "task"], + }) + mock_list_studies.return_value = mock_studies_df + mock_list_suites.return_value = mock_suites_df + + # Create args + args = argparse.Namespace( + status=None, + uploader=None, + type="all", + size=10, + offset=0, + format="list", + verbose=False, + ) + + # Execute + cli.studies_list(args) + + # Verify both functions were called (None values filtered out) + mock_list_studies.assert_called_once_with(size=10, offset=0) + mock_list_suites.assert_called_once_with(size=10, offset=0) + + @mock.patch("openml.study.functions.list_studies") + def test_studies_list_only_studies(self, mock_list_studies): + """Test listing only studies.""" + mock_df = pd.DataFrame({ + "id": [1, 2], + "alias": ["study1", "study2"], + "name": ["Study 1", "Study 2"], + }) + mock_list_studies.return_value = mock_df + + args = argparse.Namespace( + status="active", + uploader=123, + type="study", + size=5, + offset=0, + format="table", + verbose=False, + ) + + cli.studies_list(args) + + mock_list_studies.assert_called_once_with(status="active", uploader=123, size=5, offset=0) + + @mock.patch("openml.study.functions.list_suites") + def test_studies_list_only_suites(self, mock_list_suites): + """Test listing only suites.""" + mock_df = pd.DataFrame({ + "id": [10, 11], + "alias": ["suite1", "suite2"], + "name": ["Suite 1", "Suite 2"], + }) + mock_list_suites.return_value = mock_df + + args = argparse.Namespace( + status=None, + uploader=None, + type="suite", + size=10, + offset=0, + format="list", + verbose=False, + ) + + cli.studies_list(args) + + # None values are filtered out + mock_list_suites.assert_called_once_with(size=10, offset=0) + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_list_empty_results(self, mock_list_suites, mock_list_studies): + """Test handling of empty results.""" + mock_list_studies.return_value = pd.DataFrame() + mock_list_suites.return_value = pd.DataFrame() + + args = argparse.Namespace( + status=None, + uploader=None, + type="all", + size=10, + offset=0, + format="list", + verbose=False, + ) + + cli.studies_list(args) + + mock_list_studies.assert_called_once() + mock_list_suites.assert_called_once() + + @mock.patch("openml.study.functions.list_studies") + def test_studies_list_json_format(self, mock_list_studies): + """Test JSON output format.""" + mock_df = pd.DataFrame({"id": [1], "name": ["Study 1"]}) + mock_list_studies.return_value = mock_df + + args = argparse.Namespace( + status=None, + uploader=None, + type="study", + size=10, + offset=0, + format="json", + verbose=False, + ) + + cli.studies_list(args) + mock_list_studies.assert_called_once() + + @mock.patch("openml.study.functions.list_studies") + def test_studies_list_verbose(self, mock_list_studies): + """Test verbose output.""" + mock_df = pd.DataFrame({ + "id": [1], + "name": ["Study 1"], + "description": ["A test study"], + }) + mock_list_studies.return_value = mock_df + + args = argparse.Namespace( + status=None, + uploader=None, + type="study", + size=10, + offset=0, + format="list", + verbose=True, + ) + + cli.studies_list(args) + mock_list_studies.assert_called_once() + + @mock.patch("openml.study.functions.get_study") + def test_studies_info_study(self, mock_get_study): + """Test study info display for a study.""" + # Create mock study with all attributes + mock_study = mock.Mock() + mock_study.study_id = 1 + mock_study.alias = "study1" + mock_study.name = "Test Study" + mock_study.description = "A test study" + mock_study.status = "active" + mock_study.creation_date = "2023-01-01" + mock_study.creator = 123 + mock_study.main_entity_type = "run" + mock_study.benchmark_suite = None + mock_study.data = [] + mock_study.tasks = [] + mock_study.flows = [] + mock_study.runs = [1, 2, 3, 4, 5] + mock_study.setups = [] + mock_get_study.return_value = mock_study + + args = argparse.Namespace(study_id="1", verbose=False) + + cli.studies_info(args) + + mock_get_study.assert_called_once_with("1") + + @mock.patch("openml.study.functions.get_study") + @mock.patch("openml.study.functions.get_suite") + def test_studies_info_suite_fallback(self, mock_get_suite, mock_get_study): + """Test suite info display when study fetch fails.""" + # get_study raises exception, should fall back to get_suite + mock_get_study.side_effect = Exception("Not a study") + + mock_suite = mock.Mock() + mock_suite.study_id = 10 + mock_suite.alias = "suite1" + mock_suite.name = "Test Suite" + mock_suite.description = "A test suite" + mock_suite.status = "active" + mock_suite.creation_date = "2023-01-01" + mock_suite.creator = 456 + mock_suite.main_entity_type = "task" + mock_suite.benchmark_suite = None + mock_suite.data = [] + mock_suite.tasks = [10, 11, 12] + mock_suite.flows = [] + mock_suite.runs = [] + mock_suite.setups = [] + mock_get_suite.return_value = mock_suite + + args = argparse.Namespace(study_id="10", verbose=False) + + cli.studies_info(args) + + mock_get_study.assert_called_once_with("10") + mock_get_suite.assert_called_once_with("10") + + @mock.patch("openml.study.functions.get_study") + def test_studies_info_verbose(self, mock_get_study): + """Test verbose study info display.""" + mock_study = mock.Mock() + mock_study.study_id = 1 + mock_study.alias = "study1" + mock_study.name = "Test Study" + mock_study.description = "A test study" + mock_study.status = "active" + mock_study.creation_date = "2023-01-01" + mock_study.creator = 123 + mock_study.main_entity_type = "run" + mock_study.benchmark_suite = None + mock_study.data = [] + mock_study.tasks = [] + mock_study.flows = [] + mock_study.runs = list(range(1, 16)) # 15 runs + mock_study.setups = [] + mock_get_study.return_value = mock_study + + args = argparse.Namespace(study_id="1", verbose=True) + + cli.studies_info(args) + + mock_get_study.assert_called_once_with("1") + + @mock.patch("openml.study.functions.get_study") + @mock.patch("openml.study.functions.get_suite") + def test_studies_info_not_found(self, mock_get_suite, mock_get_study): + """Test study info with invalid ID.""" + mock_get_study.side_effect = Exception("Study not found") + mock_get_suite.side_effect = Exception("Suite not found") + + args = argparse.Namespace(study_id="99999", verbose=False) + + with pytest.raises(SystemExit): + cli.studies_info(args) + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_search_found(self, mock_list_suites, mock_list_studies): + """Test search with results.""" + mock_studies_df = pd.DataFrame({ + "id": [1], + "alias": ["study1"], + "name": ["OpenML100"], + }) + mock_suites_df = pd.DataFrame({ + "id": [10], + "alias": ["suite1"], + "name": ["OpenML-CC18"], + }) + mock_list_studies.return_value = mock_studies_df + mock_list_suites.return_value = mock_suites_df + + args = argparse.Namespace( + query="openml", + status=None, + format="list", + verbose=False, + ) + + cli.studies_search(args) + + mock_list_studies.assert_called_once() + mock_list_suites.assert_called_once() + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_search_case_insensitive(self, mock_list_suites, mock_list_studies): + """Test case-insensitive search.""" + mock_studies_df = pd.DataFrame({ + "id": [1, 2], + "alias": ["study1", "study2"], + "name": ["OpenML100", "OpenML-Benchmarking"], + }) + mock_suites_df = pd.DataFrame({ + "id": [10], + "alias": ["suite1"], + "name": ["OpenML-CC18"], + }) + mock_list_studies.return_value = mock_studies_df + mock_list_suites.return_value = mock_suites_df + + args = argparse.Namespace( + query="OPENML", + status=None, + format="table", + verbose=False, + ) + + cli.studies_search(args) + + mock_list_studies.assert_called_once() + mock_list_suites.assert_called_once() + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_search_with_status_filter(self, mock_list_suites, mock_list_studies): + """Test search with status filter.""" + mock_studies_df = pd.DataFrame({ + "id": [1], + "alias": ["study1"], + "name": ["Test Study"], + }) + mock_suites_df = pd.DataFrame() + mock_list_studies.return_value = mock_studies_df + mock_list_suites.return_value = mock_suites_df + + args = argparse.Namespace( + query="test", + status="active", + format="list", + verbose=False, + ) + + cli.studies_search(args) + + mock_list_studies.assert_called_once_with(status="active") + mock_list_suites.assert_called_once_with(status="active") + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_search_not_found(self, mock_list_suites, mock_list_studies): + """Test search with no results.""" + mock_list_studies.return_value = pd.DataFrame() + mock_list_suites.return_value = pd.DataFrame() + + args = argparse.Namespace( + query="nonexistent", + status=None, + format="list", + verbose=False, + ) + + cli.studies_search(args) + + mock_list_studies.assert_called_once() + mock_list_suites.assert_called_once() + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_search_json_format(self, mock_list_suites, mock_list_studies): + """Test search with JSON output format.""" + mock_studies_df = pd.DataFrame({"id": [1], "name": ["Study 1"]}) + mock_suites_df = pd.DataFrame() + mock_list_studies.return_value = mock_studies_df + mock_list_suites.return_value = mock_suites_df + + args = argparse.Namespace( + query="study", + status=None, + format="json", + verbose=False, + ) + + cli.studies_search(args) + + mock_list_studies.assert_called_once() + mock_list_suites.assert_called_once() + + @mock.patch("openml.study.functions.list_studies") + def test_studies_dispatcher_list(self, mock_list_studies): + """Test studies dispatcher routes list action correctly.""" + mock_list_studies.return_value = pd.DataFrame({"id": [1], "name": ["test"]}) + + args = argparse.Namespace( + studies_subcommand="list", + status=None, + uploader=None, + type="study", + size=10, + offset=0, + format="list", + verbose=False, + ) + + cli.studies(args) + mock_list_studies.assert_called_once() + + @mock.patch("openml.study.functions.get_study") + def test_studies_dispatcher_info(self, mock_get_study): + """Test studies dispatcher routes info action correctly.""" + mock_study = mock.Mock() + mock_study.study_id = 1 + mock_study.name = "Test" + mock_study.status = "active" + mock_study.main_entity_type = "run" + mock_study.creator = 123 + mock_study.creation_date = "2023-01-01" + mock_study.description = "Test description" + mock_study.benchmark_suite = None + mock_study.alias = None + mock_study.data = [] + mock_study.tasks = [] + mock_study.flows = [] + mock_study.runs = [] + mock_study.setups = [] + mock_get_study.return_value = mock_study + + args = argparse.Namespace( + studies_subcommand="info", + study_id="1", + verbose=False, + ) + + cli.studies(args) + mock_get_study.assert_called_once() + + @mock.patch("openml.study.functions.list_studies") + @mock.patch("openml.study.functions.list_suites") + def test_studies_dispatcher_search(self, mock_list_suites, mock_list_studies): + """Test studies dispatcher routes search action correctly.""" + mock_list_studies.return_value = pd.DataFrame({"id": [1], "name": ["test"]}) + mock_list_suites.return_value = pd.DataFrame() + + args = argparse.Namespace( + studies_subcommand="search", + query="test", + status=None, + format="list", + verbose=False, + ) + + cli.studies(args) + mock_list_studies.assert_called_once() + mock_list_suites.assert_called_once() + + def test_studies_dispatcher_no_subcommand(self): + """Test studies dispatcher with no subcommand specified.""" + args = argparse.Namespace(studies_subcommand=None) + + with pytest.raises(SystemExit): + cli.studies(args)