diff --git a/capture_tests/cbrain_cli_commands b/capture_tests/cbrain_cli_commands index 09154a1..028629f 100755 --- a/capture_tests/cbrain_cli_commands +++ b/capture_tests/cbrain_cli_commands @@ -79,10 +79,10 @@ cbrain --json project unswitch cbrain --jsonl project switch 10 cbrain --jsonl project unswitch -cbrain project switch all # 'all' not yet implemented as of Aug 2025 +cbrain project switch all cbrain --json project switch all cbrain --jsonl project switch all -cbrain project switch 3 # need to reset for rest of test to work +cbrain project switch 3 # reset project filter for task list below # Tags cbrain tag list @@ -103,10 +103,7 @@ cbrain --json tag create --name NewTag2 --user-id 2 --group-id 3 cbrain --jsonl tag create --name NewTag3 --user-id 2 --group-id 3 # Tasks -# NOTE: we needed to reset the current project to 3 above, -# until 'group switch all' is implemented; this means only -# tasks in group 3 are shown below, but that's OK, that's -# what's in the test DB anyway +# Reset above to project 3 so task list matches seeded DB view. cbrain task list cbrain --json task list cbrain --jsonl task list @@ -115,8 +112,11 @@ cbrain task show 2 cbrain --json task show 2 cbrain --jsonl task show 2 -# Not yet implemented -cbrain task operation # should provide error message +# Task operations (hold on Completed task → skipped; deterministic, no BAC) +cbrain task operation hold --task-id 2 +cbrain --json task operation hold --task-id 2 +cbrain --jsonl task operation hold --task-id 2 +cbrain task operation hold # missing --task-id / --batch-id # ToolConfigs, as admin user ./switch_session admin diff --git a/capture_tests/expected_captures.txt b/capture_tests/expected_captures.txt index 23ab5ee..da977c9 100644 --- a/capture_tests/expected_captures.txt +++ b/capture_tests/expected_captures.txt @@ -558,40 +558,43 @@ Stderr: (No output) ############################ -Command: cbrain project switch all # 'all' not yet implemented as of Aug 2025 -Status: 1 -Stdout: 74 bytes +Command: cbrain project switch all +Status: 0 +Stdout: 32 bytes Stderr: 0 bytes Stdout: -Error: Project switch 'all' not yet implemented as of Aug 2025 (group_id) +Current project is "all" ID=all Stderr: (No output) ############################ Command: cbrain --json project switch all -Status: 1 -Stdout: 74 bytes +Status: 0 +Stdout: 35 bytes Stderr: 0 bytes Stdout: -Error: Project switch 'all' not yet implemented as of Aug 2025 (group_id) +{ + "id": "all", + "name": "all" +} Stderr: (No output) ############################ Command: cbrain --jsonl project switch all -Status: 1 -Stdout: 74 bytes +Status: 0 +Stdout: 26 bytes Stderr: 0 bytes Stdout: -Error: Project switch 'all' not yet implemented as of Aug 2025 (group_id) +{"id":"all","name":"all"} Stderr: (No output) ############################ -Command: cbrain project switch 3 # need to reset for rest of test to work +Command: cbrain project switch 3 # reset project filter for task list below Status: 0 Stdout: 31 bytes Stderr: 0 bytes @@ -998,18 +1001,67 @@ Stderr: (No output) ############################ -Command: cbrain task operation # should provide error message +Command: cbrain task operation hold --task-id 2 Status: 0 -Stdout: 41 bytes +Stdout: 182 bytes +Stderr: 0 bytes + +Stdout: +TASK OPERATION RESULT +------------------------------ +Operation: hold +Succeeded: 0 +Skipped: 1 +Failed: 0 + +SKIPPED +------------------------------ +Tasks have incompatible states: 2 +Stderr: +(No output) + +############################ +Command: cbrain --json task operation hold --task-id 2 +Status: 0 +Stdout: 142 bytes Stderr: 0 bytes Stdout: { - "message": "No operation selected" + "skipped_list": { + "Tasks have incompatible states": [ + 2 + ] + }, + "success_list": [], + "failed_list": {}, + "bac_ids": [] } Stderr: (No output) +############################ +Command: cbrain --jsonl task operation hold --task-id 2 +Status: 0 +Stdout: 104 bytes +Stderr: 0 bytes + +Stdout: +{"skipped_list":{"Tasks have incompatible states":[2]},"success_list":[],"failed_list":{},"bac_ids":[]} +Stderr: +(No output) + +############################ +Command: cbrain task operation hold # missing --task-id / --batch-id +Status: 1 +Stdout: 68 bytes +Stderr: 0 bytes + +Stdout: +Error: At least one --task-id or --batch-id is required (--task-id) +Stderr: +(No output) + ############################ Command: ./switch_session admin Status: 0 @@ -1187,36 +1239,36 @@ Stderr: ############################ Command: cbrain tool-config show 19 # visible to user admin Status: 1 -Stdout: 107 bytes +Stdout: 117 bytes Stderr: 0 bytes Stdout: Authentication error (401): Unauthorized -Error: Access denied. Please log in using authorized credentials. +Error: Session expired or invalid. Run 'cbrain logout' then 'cbrain login'. Stderr: (No output) ############################ Command: cbrain --json tool-config show 19 Status: 1 -Stdout: 107 bytes +Stdout: 117 bytes Stderr: 0 bytes Stdout: Authentication error (401): Unauthorized -Error: Access denied. Please log in using authorized credentials. +Error: Session expired or invalid. Run 'cbrain logout' then 'cbrain login'. Stderr: (No output) ############################ Command: cbrain --jsonl tool-config show 19 Status: 1 -Stdout: 107 bytes +Stdout: 117 bytes Stderr: 0 bytes Stdout: Authentication error (401): Unauthorized -Error: Access denied. Please log in using authorized credentials. +Error: Session expired or invalid. Run 'cbrain logout' then 'cbrain login'. Stderr: (No output) diff --git a/cbrain_cli/cli_utils.py b/cbrain_cli/cli_utils.py index 0594645..c8e616d 100644 --- a/cbrain_cli/cli_utils.py +++ b/cbrain_cli/cli_utils.py @@ -13,6 +13,20 @@ from cbrain_cli import config as cbrain_config from cbrain_cli.config import DEFAULT_HEADERS, DEFAULT_TIMEOUT, auth_headers +_debug = False + + +def set_debug(flag: bool) -> None: + """Enable or disable debug output.""" + global _debug + _debug = bool(flag) + + +def debug_log(message: str) -> None: + """Print a debug line to stderr when debug mode is active.""" + if _debug: + print(f"[DEBUG] {message}", file=sys.stderr) + class CbrainClient: """ @@ -46,11 +60,23 @@ def _request(self, method, path, *, headers=None, body=None, params=None): if params: target = f"{target}?{urllib.parse.urlencode(params)}" hdrs = headers or auth_headers(self.token) + # strip host from full URL for debug display; preserve all query params + parsed = urllib.parse.urlsplit(target) + display_path = parsed.path + if display_path.startswith(self.base_url): + display_path = display_path[len(self.base_url) :] + query = urllib.parse.urlencode(params) if params else parsed.query + if query: + display_path = f"{display_path}?{query}" + debug_log(f"{method} {display_path}") req = urllib.request.Request(target, data=body, headers=hdrs, method=method) try: with urllib.request.urlopen(req, timeout=self.timeout) as r: - return r.read(), r.status + status = r.status + debug_log(f"→ HTTP {status}") + return r.read(), status except urllib.error.HTTPError as e: + debug_log(f"→ HTTP {e.code} ({e.reason})") raise CliApiError(e.reason or f"HTTP {e.code}", status=e.code) from e def get(self, path, params=None): @@ -95,7 +121,7 @@ def post_multipart(self, path, body, content_type): PAGINATABLE_ACTIONS = { ("file", "list"), - ("dataprovider", "list"), + ("data-provider", "list"), ("tool", "list"), ("tool-config", "list"), ("tag", "list"), @@ -204,7 +230,7 @@ def handle_connection_error(error): if error.code == 401: print(f"{status_description}: {error.reason}") - print("Error: Access denied. Please log in using authorized credentials.") + print("Error: Session expired or invalid. Run 'cbrain logout' then 'cbrain login'.") elif error.code in (400, 404, 422, 500): # Try to extract specific error message from response try: diff --git a/cbrain_cli/data/files.py b/cbrain_cli/data/files.py index d7e2548..059e728 100644 --- a/cbrain_cli/data/files.py +++ b/cbrain_cli/data/files.py @@ -92,7 +92,9 @@ def _change_provider(args, operation): if not file_ids: raise CliValidationError("File ID(s) are required", field="--file-id") if not dest_provider_id: - raise CliValidationError("Destination data provider ID is required", field="--dp-id") + raise CliValidationError( + "Destination data provider ID is required", field="--data-provider-id" + ) payload = { "file_ids": file_ids, "data_provider_id_for_mv_cp": dest_provider_id, diff --git a/cbrain_cli/data/projects.py b/cbrain_cli/data/projects.py index 95a7b07..66b7fb4 100644 --- a/cbrain_cli/data/projects.py +++ b/cbrain_cli/data/projects.py @@ -25,23 +25,25 @@ def switch_project(args): if not group_id: raise CliValidationError("Group ID is required", field="group_id") - if group_id == "all": - raise CliValidationError( - "Project switch 'all' not yet implemented as of Aug 2025", field="group_id" - ) - - try: - group_id = int(group_id) - except ValueError: - raise CliValidationError( - f"Invalid group ID '{group_id}'. Must be a number or 'all'", field="group_id" - ) from None + # Server accepts numeric id or "all" (no single project filter). + if group_id != "all": + try: + group_id = int(group_id) + except ValueError: + raise CliValidationError( + f"Invalid group ID '{group_id}'. Must be a number or 'all'", field="group_id" + ) from None client = CbrainClient.from_credentials() _, switch_status = client.send("POST", f"/groups/switch?id={group_id}") if switch_status not in (200, 201, 204): raise CliApiError(f"Failed to switch project (HTTP {switch_status})") - group_data = client.get(f"/groups/{group_id}") + + # "all" is session state only — no /groups/all resource. + if group_id == "all": + group_data = {"id": "all", "name": "all"} + else: + group_data = client.get(f"/groups/{group_id}") credentials = load_credentials() if credentials is not None: @@ -124,6 +126,13 @@ def show_project(args): if not current_group_id: return None + # Session "all" has no Group row; mirror switch_project synthetic result. + if current_group_id == "all": + return { + "id": "all", + "name": credentials.get("current_group_name") or "all", + } + try: return CbrainClient.from_credentials().get(f"/groups/{current_group_id}") except CliApiError as e: diff --git a/cbrain_cli/data/tasks.py b/cbrain_cli/data/tasks.py index 672cc3d..524238b 100644 --- a/cbrain_cli/data/tasks.py +++ b/cbrain_cli/data/tasks.py @@ -1,10 +1,28 @@ from cbrain_cli.cli_utils import ( CbrainClient, CliValidationError, - json_printer, pagination, ) +# Names accepted by CBRAIN POST /tasks/operation +TASK_OPERATIONS = ( + "terminate", + "archive", + "archive_file", + "unarchive", + "zap_wd", + "save_wd", + "hold", + "release", + "suspend", + "resume", + "duplicate", + "recover", + "restart_setup", + "restart_cluster", + "restart_postprocess", +) + def list_tasks(args): """ @@ -65,7 +83,39 @@ def show_task(args): def operation_task(args): """ - Operation on a task. + Run a bulk operation on tasks. """ - data, _ = CbrainClient.from_credentials().send("POST", "/tasks/operation") - json_printer(data) + operation = getattr(args, "operation", None) + if not operation: + raise CliValidationError("Operation is required", field="operation") + if operation not in TASK_OPERATIONS: + raise CliValidationError( + f"Unsupported operation: {operation}", + field="operation", + ) + + task_ids = getattr(args, "task_id", None) or [] + batch_ids = getattr(args, "batch_id", None) or [] + if not task_ids and not batch_ids: + raise CliValidationError( + "At least one --task-id or --batch-id is required", + field="--task-id", + ) + + payload = {"operation": operation} + if task_ids: + payload["tasklist"] = list(task_ids) + if batch_ids: + payload["batch_ids"] = list(batch_ids) + + dup_bourreau_id = getattr(args, "dup_bourreau_id", None) + if dup_bourreau_id is not None: + payload["dup_bourreau_id"] = dup_bourreau_id + archive_dp_id = getattr(args, "archive_dp_id", None) + if archive_dp_id is not None: + payload["archive_dp_id"] = archive_dp_id + if getattr(args, "nozip", False): + payload["nozip"] = True + + data, _ = CbrainClient.from_credentials().send("POST", "/tasks/operation", payload=payload) + return data diff --git a/cbrain_cli/formatter/tasks_fmt.py b/cbrain_cli/formatter/tasks_fmt.py index 972606b..14542e7 100644 --- a/cbrain_cli/formatter/tasks_fmt.py +++ b/cbrain_cli/formatter/tasks_fmt.py @@ -1,6 +1,10 @@ import json -from cbrain_cli.cli_utils import display_key_value_table, dynamic_table_print, output_json +from cbrain_cli.cli_utils import ( + display_key_value_table, + dynamic_table_print, + output_json, +) def print_task_data(tasks_data, args): @@ -117,3 +121,68 @@ def print_task_details(task_data, args): print("PARAMETERS") print("-" * 30) print(json.dumps(task_data.get("params"), indent=2)) + + +def print_task_operation_result(result, args): + """ + Print the result of a bulk task operation. + + Parameters + ---------- + result : dict + Operation result payload from the API + args : argparse.Namespace + Command line arguments, including the --json flag + """ + if output_json(args, result): + return + + if not result: + print("No operation result.") + return + + # Server sometimes returns a single message (e.g. no operation / no tasks). + if "message" in result and "success_list" not in result: + print(result["message"]) + return + + operation = getattr(args, "operation", None) + success = result.get("success_list") or [] + failed = result.get("failed_list") or {} + skipped = result.get("skipped_list") or {} + bac_ids = result.get("bac_ids") or [] + + print("TASK OPERATION RESULT") + print("-" * 30) + if operation: + print(f"Operation: {operation}") + print(f"Succeeded: {len(success)}") + print(f"Skipped: {sum(len(v) for v in skipped.values())}") + print(f"Failed: {sum(len(v) for v in failed.values())}") + + if success: + print() + print("SUCCEEDED TASKS") + print("-" * 30) + print(", ".join(str(task_id) for task_id in success)) + + if skipped: + print() + print("SKIPPED") + print("-" * 30) + for reason, ids in skipped.items(): + print(f"{reason}: {', '.join(str(i) for i in ids)}") + + if failed: + print() + print("FAILED") + print("-" * 30) + for reason, ids in failed.items(): + print(f"{reason}: {', '.join(str(i) for i in ids)}") + + if bac_ids: + print() + print("BACKGROUND ACTIVITIES") + print("-" * 30) + print(", ".join(str(bac_id) for bac_id in bac_ids)) + print("Track with: cbrain background show ") diff --git a/cbrain_cli/handlers.py b/cbrain_cli/handlers.py index b64b032..201569d 100644 --- a/cbrain_cli/handlers.py +++ b/cbrain_cli/handlers.py @@ -315,6 +315,12 @@ def handle_task_show(args): tasks_fmt.print_task_details(result, args) +def handle_task_operation(args): + """Run a task operation and display the result.""" + result = tasks.operation_task(args) + tasks_fmt.print_task_operation_result(result, args) + + # Remote resource command handlers def handle_remote_resource_list(args): """Retrieve and display a list of remote computational resources available in CBRAIN.""" diff --git a/cbrain_cli/main.py b/cbrain_cli/main.py index c610304..18ffbfc 100644 --- a/cbrain_cli/main.py +++ b/cbrain_cli/main.py @@ -11,9 +11,10 @@ handle_errors, is_authenticated, pagination, + set_debug, version_info, ) -from cbrain_cli.data.tasks import operation_task +from cbrain_cli.data.tasks import TASK_OPERATIONS from cbrain_cli.handlers import ( handle_background_list, handle_background_show, @@ -39,6 +40,7 @@ handle_tag_show, handle_tag_update, handle_task_list, + handle_task_operation, handle_task_show, handle_tool_config_boutiques_descriptor, handle_tool_config_list, @@ -68,6 +70,13 @@ def build_parser(): action="store_true", help="Output in JSONL format (one JSON object per line)", ) + parser.add_argument( + "--debug", + "--verbose", + dest="debug", + action="store_true", + help="Print sanitized request/response diagnostics to stderr", + ) subparsers = parser.add_subparsers(dest="command", help="Available commands") @@ -100,7 +109,12 @@ def build_parser(): "--group-id", dest="group_id", type=int, help="Filter files by group ID" ) file_list_parser.add_argument( - "--dp-id", dest="dp_id", type=int, help="Filter files by data provider ID" + "--data-provider-id", + "--data-provider", + "--dp-id", + dest="dp_id", + type=int, + help="Filter files by data provider ID", ) file_list_parser.add_argument( "--user-id", dest="user_id", type=int, help="Filter files by user ID" @@ -130,11 +144,13 @@ def build_parser(): file_upload_parser = file_subparsers.add_parser("upload", help="Upload a file to CBRAIN") file_upload_parser.add_argument("file_path", help="Path to the file to upload") file_upload_parser.add_argument( + "--data-provider-id", "--data-provider", + "--dp-id", dest="data_provider", type=int, required=True, - help="Data provider ID", + help="Destination data provider ID", ) file_upload_parser.add_argument("--group-id", dest="group_id", type=int, help="Group ID") @@ -153,6 +169,8 @@ def build_parser(): help="One or more file IDs to copy", ) file_copy_parser.add_argument( + "--data-provider-id", + "--data-provider", "--dp-id", dest="dp_id", type=int, @@ -174,6 +192,8 @@ def build_parser(): help="One or more file IDs to move", ) file_move_parser.add_argument( + "--data-provider-id", + "--data-provider", "--dp-id", dest="dp_id", type=int, @@ -190,53 +210,57 @@ def build_parser(): ) file_delete_parser.set_defaults(func=handle_errors(handle_file_delete)) - # Data provider commands - dataprovider_parser = subparsers.add_parser("dataprovider", help="Data provider operations") - dataprovider_subparsers = dataprovider_parser.add_subparsers( + # Data provider commands (alias: dataprovider) + data_provider_parser = subparsers.add_parser( + "data-provider", + aliases=["dataprovider"], + help="Data provider operations", + ) + data_provider_subparsers = data_provider_parser.add_subparsers( dest="action", help="Data provider actions" ) - # dataprovider list - dataprovider_list_parser = dataprovider_subparsers.add_parser( + # data-provider list + data_provider_list_parser = data_provider_subparsers.add_parser( "list", help="List data providers" ) - dataprovider_list_parser.set_defaults(func=handle_errors(handle_dataprovider_list)) + data_provider_list_parser.set_defaults(func=handle_errors(handle_dataprovider_list)) - dataprovider_list_parser.add_argument( + data_provider_list_parser.add_argument( "--page", type=int, default=1, help="Page number (default: 1)" ) - dataprovider_list_parser.add_argument( + data_provider_list_parser.add_argument( "--per-page", type=int, default=25, help="Number of data providers per page (5-1000, default: 25)", ) - # dataprovider show - dataprovider_show_parser = dataprovider_subparsers.add_parser( + # data-provider show + data_provider_show_parser = data_provider_subparsers.add_parser( "show", help="Show data provider details" ) - dataprovider_show_parser.add_argument("id", type=int, help="Data provider ID") - dataprovider_show_parser.set_defaults(func=handle_errors(handle_dataprovider_show)) + data_provider_show_parser.add_argument("id", type=int, help="Data provider ID") + data_provider_show_parser.set_defaults(func=handle_errors(handle_dataprovider_show)) - # dataprovider is_alive - dataprovider_is_alive_parser = dataprovider_subparsers.add_parser( - "is-alive", help="Check if a data provider is alive" + # data-provider is-alive + data_provider_is_alive_parser = data_provider_subparsers.add_parser( + "is-alive", help="Check if a data provider is reachable" ) - dataprovider_is_alive_parser.add_argument("id", type=int, help="Data provider ID") - dataprovider_is_alive_parser.set_defaults(func=handle_errors(handle_dataprovider_is_alive)) + data_provider_is_alive_parser.add_argument("id", type=int, help="Data provider ID") + data_provider_is_alive_parser.set_defaults(func=handle_errors(handle_dataprovider_is_alive)) - # dataprovider delete-unregistered-files - dataprovider_delete_unregistered_files_parser = dataprovider_subparsers.add_parser( + # data-provider delete-unregistered-files + data_provider_delete_unregistered_files_parser = data_provider_subparsers.add_parser( "delete-unregistered-files", help="Delete unregistered files from a data provider", ) - dataprovider_delete_unregistered_files_parser.add_argument( + data_provider_delete_unregistered_files_parser.add_argument( "id", type=int, help="Data provider ID" ) - dataprovider_delete_unregistered_files_parser.add_argument( + data_provider_delete_unregistered_files_parser.add_argument( "-y", "--yes", action="store_true", help="Skip confirmation prompt" ) - dataprovider_delete_unregistered_files_parser.set_defaults( + data_provider_delete_unregistered_files_parser.set_defaults( func=handle_errors(handle_dataprovider_delete_unregistered) ) @@ -411,7 +435,7 @@ def build_parser(): type=lambda value: value.replace("-", "_"), choices=["bourreau_id"], metavar="bourreau-id", - help="Filter type (optional)", + help="Filter by bourreau (execution server) ID", ) task_list_parser.add_argument("--page", type=int, default=1, help="Page number (default: 1)") task_list_parser.add_argument( @@ -425,7 +449,7 @@ def build_parser(): "bourreau_id", type=int, nargs="?", - help="Bourreau ID (required when filter is bourreau-id)", + help="Bourreau (execution server) ID; required with bourreau-id", ) task_list_parser.set_defaults(func=handle_errors(handle_task_list)) @@ -435,12 +459,51 @@ def build_parser(): task_show_parser.set_defaults(func=handle_errors(handle_task_show)) # task operation - task_operation_parser = task_subparsers.add_parser("operation", help="operation on a task") - task_operation_parser.set_defaults(func=handle_errors(operation_task)) + task_operation_parser = task_subparsers.add_parser( + "operation", help="Run a bulk operation on tasks" + ) + task_operation_parser.add_argument( + "operation", + choices=TASK_OPERATIONS, + help="Operation to run (e.g. terminate, archive, hold, recover)", + ) + task_operation_parser.add_argument( + "--task-id", + dest="task_id", + type=int, + nargs="+", + help="One or more task IDs", + ) + task_operation_parser.add_argument( + "--batch-id", + dest="batch_id", + type=int, + nargs="+", + help="One or more batch IDs (expands to member tasks)", + ) + task_operation_parser.add_argument( + "--dup-bourreau-id", + dest="dup_bourreau_id", + type=int, + help="Target bourreau ID for duplicate", + ) + task_operation_parser.add_argument( + "--archive-dp-id", + dest="archive_dp_id", + type=int, + help="Data provider ID for archive_file", + ) + task_operation_parser.add_argument( + "--nozip", + action="store_true", + help="Archive without compression (admin only)", + ) + task_operation_parser.set_defaults(func=handle_errors(handle_task_operation)) - # Remote resource commands + # Remote resource commands (CBRAIN bourreaux / execution servers) remote_resource_parser = subparsers.add_parser( - "remote-resource", help="Remote resource operations" + "remote-resource", + help="Remote resource (bourreau / execution server) operations", ) remote_resource_subparsers = remote_resource_parser.add_subparsers( dest="action", help="Remote resource actions" @@ -456,12 +519,16 @@ def build_parser(): remote_resource_show_parser = remote_resource_subparsers.add_parser( "show", help="Show remote resource details" ) - remote_resource_show_parser.add_argument("remote_resource", type=int, help="Remote resource ID") + remote_resource_show_parser.add_argument( + "remote_resource", + type=int, + help="Remote resource (bourreau) ID", + ) remote_resource_show_parser.set_defaults(func=handle_errors(handle_remote_resource_show)) command_parsers = { "file": file_parser, - "dataprovider": dataprovider_parser, + "data-provider": data_provider_parser, "project": project_parser, "tool": tool_parser, "tool-config": tool_configs_parser, @@ -490,10 +557,15 @@ def main(argv=None): parser, command_parsers = build_parser() args = parser.parse_args(argv) + set_debug(getattr(args, "debug", False)) + if not args.command: parser.print_help() return + if args.command == "dataprovider": + args.command = "data-provider" + if (args.command, getattr(args, "action", None)) in PAGINATABLE_ACTIONS: try: pagination(args, {}) @@ -518,7 +590,7 @@ def main(argv=None): # Handle authenticated commands. if args.command in [ "file", - "dataprovider", + "data-provider", "project", "tool", "tool-config", diff --git a/cbrain_cli/sessions.py b/cbrain_cli/sessions.py index 76e44de..6d27cec 100644 --- a/cbrain_cli/sessions.py +++ b/cbrain_cli/sessions.py @@ -30,8 +30,27 @@ def create_session(args): if cbrain_config.CREDENTIALS_FILE.exists(): creds = cbrain_config.load_credentials() if creds and creds.get("api_token") and creds.get("cbrain_url"): - print("Already logged in. Use 'cbrain logout' to logout.") - return 1 + # File alone is not enough, probe server to detect expired tokens. + try: + CbrainClient.from_credentials().get("/session") + except CliApiError as e: + if e.status == 401: + print("Saved session expired. Please log in again.") + elif e.status >= 500: + print(f"Server returned HTTP {e.status} during session check.") + print("The server may be temporarily unavailable. Try again later.") + return 1 + else: + print(f"Server returned HTTP {e.status} during session check.") + print("Use 'cbrain logout' to reset local credentials.") + return 1 + except urllib.error.URLError: + print(f"Cannot reach CBRAIN server at {creds['cbrain_url']}.") + print("Check your connection. Use 'cbrain logout' to reset local credentials.") + return 1 + else: + print("Already logged in. Use 'cbrain logout' to logout.") + return 1 # Get user input. cbrain_url = input("Enter CBRAIN server base URL [default: localhost:3000]: ").strip() diff --git a/tests/test_cbrain_client.py b/tests/test_cbrain_client.py index 14942c1..2b5a0dd 100644 --- a/tests/test_cbrain_client.py +++ b/tests/test_cbrain_client.py @@ -2,11 +2,20 @@ import pytest +import cbrain_cli.cli_utils as cu from cbrain_cli.cli_utils import CbrainClient, CliApiError from cbrain_cli.config import DEFAULT_TIMEOUT from tests.conftest import TOKEN, URL, install_auth +@pytest.fixture +def debug_mode(): + """Enable debug mode for one test; always resets to False afterward.""" + cu.set_debug(True) + yield + cu.set_debug(False) + + @pytest.fixture def client(): return CbrainClient(URL, TOKEN, user_id=42) @@ -108,3 +117,22 @@ def test_post_multipart(client, capture_urlopen): assert data == {"id": 1} and status == 201 assert "multipart/form-data" in captured["headers"].get("Content-type", "") assert captured["headers"].get("Authorization") == f"Bearer {TOKEN}" + + +def test_debug_off_by_default(capsys): + cu.debug_log("should not appear") + assert capsys.readouterr().err == "" + + +def test_debug_lines_reach_stderr(debug_mode, capsys): + cu.debug_log("hello world") + assert "[DEBUG] hello world" in capsys.readouterr().err + + +def test_debug_no_token_in_output(debug_mode, client, capture_urlopen, capsys): + configure, _ = capture_urlopen + configure({}) + client.get("/tools") + err = capsys.readouterr().err + assert TOKEN not in err + assert "[DEBUG]" in err diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py index 314b3a1..2d65df4 100644 --- a/tests/test_exit_codes.py +++ b/tests/test_exit_codes.py @@ -103,7 +103,8 @@ def test_handle_connection_error_401(capsys): handle_connection_error(HTTPError(URL, 401, "Unauthorized", {}, io.BytesIO(b""))) out = capsys.readouterr().out assert "Authentication error (401)" in out - assert "authorized credentials" in out + assert "cbrain logout" in out + assert "cbrain login" in out def test_handle_connection_error_json_body(capsys): diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 8a70862..40a8b00 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -296,3 +296,36 @@ def test_print_task_details_normal(capsys): out = capsys.readouterr().out assert "Ready" in out assert "PARAMETERS" in out + + +def test_print_task_operation_result_summary(capsys): + tasks_fmt.print_task_operation_result( + { + "success_list": [1, 2], + "skipped_list": {"Tasks have incompatible states": [3]}, + "failed_list": {}, + "bac_ids": [33], + }, + make_args(operation="terminate"), + ) + out = capsys.readouterr().out + assert "Operation: terminate" in out + assert "Succeeded: 2" in out + assert "Skipped: 1" in out + assert "3" in out + assert "33" in out + assert "cbrain background show" in out + + +def test_print_task_operation_result_message(capsys): + tasks_fmt.print_task_operation_result( + {"message": "No tasks selected"}, + make_args(), + ) + assert capsys.readouterr().out.strip() == "No tasks selected" + + +def test_print_task_operation_result_json(capsys): + data = {"success_list": [1], "bac_ids": [9]} + tasks_fmt.print_task_operation_result(data, make_args(json=True)) + assert parse_json_output(capsys) == data diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 56b7218..4538e6b 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -8,6 +8,7 @@ handle_project_switch, handle_project_unswitch, handle_task_list, + handle_task_operation, handle_task_show, ) from cbrain_cli.users import user_details, whoami_user @@ -153,6 +154,48 @@ def test_list_handler_validation_error_returns_1(monkeypatch): assert handle_errors(handle_task_list)(make_args()) == 1 +def test_handle_task_operation_success(monkeypatch, capsys): + monkeypatch.setattr( + "cbrain_cli.handlers.tasks.operation_task", + lambda _: { + "success_list": [1], + "failed_list": {}, + "skipped_list": {}, + "bac_ids": [33], + }, + ) + assert handle_task_operation(make_args(operation="terminate")) is None + out = capsys.readouterr().out + assert "TASK OPERATION RESULT" in out + assert "Succeeded: 1" in out + assert "33" in out + + +def test_handle_task_operation_empty_data(monkeypatch, capsys): + """Empty dict is domain data — formatter prints it, handler does not return 1.""" + monkeypatch.setattr("cbrain_cli.handlers.tasks.operation_task", lambda _: {}) + assert handle_task_operation(make_args()) is None + assert "No operation result." in capsys.readouterr().out + + +def test_handle_task_operation_validation_error_returns_1(monkeypatch, capsys): + monkeypatch.setattr( + "cbrain_cli.handlers.tasks.operation_task", + MagicMock(side_effect=CliValidationError("bad operation")), + ) + assert handle_errors(handle_task_operation)(make_args()) == 1 + assert "bad operation" in capsys.readouterr().out + + +def test_handle_task_operation_api_error_returns_1(monkeypatch, capsys): + monkeypatch.setattr( + "cbrain_cli.handlers.tasks.operation_task", + MagicMock(side_effect=CliApiError("Forbidden", status=403)), + ) + assert handle_errors(handle_task_operation)(make_args()) == 1 + assert "Forbidden" in capsys.readouterr().out + + def test_user_details_sends_current_token_in_header(monkeypatch, capture_urlopen, creds_file): """auth_headers(api_token) uses call-time credentials, not import-time globals.""" from tests.conftest import write_auth_credentials diff --git a/tests/test_main_dispatch.py b/tests/test_main_dispatch.py index 79c81b2..6df1377 100644 --- a/tests/test_main_dispatch.py +++ b/tests/test_main_dispatch.py @@ -69,3 +69,21 @@ def test_main_no_command_prints_help(capsys): def test_main_missing_subcommand_action_returns_1(monkeypatch, fake_credentials, capsys): result = run_main(monkeypatch, ["cbrain", "file"]) assert result == 1 + + +def test_main_dataprovider_alias_dispatches(monkeypatch, fake_credentials, capture_urlopen): + install_auth() + configure, captured = capture_urlopen + configure([]) + result = run_main(monkeypatch, ["cbrain", "dataprovider", "list"]) + assert result is None + assert "/data_providers" in captured["url"] + + +def test_main_data_provider_canonical_dispatches(monkeypatch, fake_credentials, capture_urlopen): + install_auth() + configure, captured = capture_urlopen + configure([]) + result = run_main(monkeypatch, ["cbrain", "data-provider", "list"]) + assert result is None + assert "/data_providers" in captured["url"] diff --git a/tests/test_parser.py b/tests/test_parser.py index 9a0ed01..b6c9dd0 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -18,6 +18,27 @@ def test_task_list_bourreau_id_args(): assert args.bourreau_id == 7 +def test_task_operation_args(): + parser, _command_parsers = build_parser() + args = parser.parse_args(["task", "operation", "terminate", "--task-id", "1", "2", "--nozip"]) + assert args.action == "operation" + assert args.operation == "terminate" + assert args.task_id == [1, 2] + assert args.nozip is True + + +def test_global_debug_flag(): + parser, _command_parsers = build_parser() + args = parser.parse_args(["--debug", "task", "list"]) + assert args.debug is True + + +def test_global_verbose_alias(): + parser, _command_parsers = build_parser() + args = parser.parse_args(["--verbose", "task", "list"]) + assert args.debug is True + + def test_file_list_kebab_options_normalize_to_snake_case(): parser, _command_parsers = build_parser() args = parser.parse_args( @@ -26,7 +47,7 @@ def test_file_list_kebab_options_normalize_to_snake_case(): "list", "--group-id", "5", - "--dp-id", + "--data-provider-id", "9", "--file-type", "TextFile", @@ -40,6 +61,29 @@ def test_file_list_kebab_options_normalize_to_snake_case(): assert args.per_page == 50 +def test_file_dp_id_aliases(): + parser, _command_parsers = build_parser() + for flag in ("--data-provider-id", "--data-provider", "--dp-id"): + args = parser.parse_args(["file", "list", flag, "3"]) + assert args.dp_id == 3 + + +def test_file_upload_data_provider_aliases(): + parser, _command_parsers = build_parser() + for flag in ("--data-provider-id", "--data-provider", "--dp-id"): + args = parser.parse_args(["file", "upload", "/tmp/x", flag, "15"]) + assert args.data_provider == 15 + + +def test_data_provider_command_and_alias(): + parser, _command_parsers = build_parser() + canonical = parser.parse_args(["data-provider", "list"]) + alias = parser.parse_args(["dataprovider", "list"]) + assert canonical.command == "data-provider" + assert alias.command == "dataprovider" + assert canonical.action == alias.action == "list" + + def test_tag_create_kebab_options_normalize_to_snake_case(): parser, _command_parsers = build_parser() args = parser.parse_args( @@ -97,7 +141,7 @@ def test_command_parsers_include_model_commands(): _parser, command_parsers = build_parser() for command in ( "file", - "dataprovider", + "data-provider", "project", "tool", "tool-config", diff --git a/tests/test_projects.py b/tests/test_projects.py index 00181e5..f97605f 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -69,9 +69,29 @@ def test_switch_project_missing_group_id_raises(): switch_project(make_args(group_id=None)) -def test_switch_project_all_raises(): - with pytest.raises(CliValidationError): - switch_project(make_args(group_id="all")) +def test_switch_project_all_saves_credentials(monkeypatch, creds_file): + from tests.conftest import write_auth_credentials + + write_auth_credentials(creds_file) + + switch_response = MagicMock() + switch_response.__enter__.return_value.read.return_value = b"" + switch_response.__enter__.return_value.status = 200 + monkeypatch.setattr("urllib.request.urlopen", MagicMock(side_effect=[switch_response])) + + result = switch_project(make_args(group_id="all")) + assert result == {"id": "all", "name": "all"} + saved = json.loads(creds_file.read_text()) + assert saved["current_group_id"] == "all" + assert saved["current_group_name"] == "all" + + +def test_show_project_all_returns_synthetic(creds_file): + from tests.conftest import write_auth_credentials + + write_auth_credentials(creds_file, current_group_id="all", current_group_name="all") + result = show_project(make_args(project_id=None)) + assert result == {"id": "all", "name": "all"} def test_switch_project_invalid_string_raises(): diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 85ac15c..e031c31 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1,4 +1,5 @@ import argparse +import urllib.error import pytest @@ -6,17 +7,44 @@ from cbrain_cli.sessions import create_session, logout_session -def test_create_session_already_logged_in(sessions_creds_file, capsys): +def test_create_session_already_logged_in(sessions_creds_file, monkeypatch, capsys): import json sessions_creds_file.write_text( - json.dumps({"api_token": "tok", "cbrain_url": "http://localhost:3000"}) + json.dumps({"api_token": "tok", "cbrain_url": "http://localhost:3000", "user_id": 1}) + ) + monkeypatch.setattr( + "cbrain_cli.cli_utils.CbrainClient.get", + lambda self, *_: {"user_id": 1, "cbrain_api_token": "tok"}, ) result = create_session(argparse.Namespace()) assert result == 1 assert "Already logged in" in capsys.readouterr().out +def test_create_session_expired_token_allows_relogin(sessions_creds_file, monkeypatch, capsys): + import json + + sessions_creds_file.write_text( + json.dumps({"api_token": "dead", "cbrain_url": "http://localhost:3000", "user_id": 1}) + ) + + def _raise_401(self, *_): + raise CliApiError("Unauthorized", status=401) + + monkeypatch.setattr("cbrain_cli.cli_utils.CbrainClient.get", _raise_401) + inputs = iter(["http://localhost:3000", "admin"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + monkeypatch.setattr("getpass.getpass", lambda _: "secret") + monkeypatch.setattr( + "cbrain_cli.cli_utils.CbrainClient.post_form", + lambda self, *_a, **_k: {"cbrain_api_token": "newtok", "user_id": 1}, + ) + result = create_session(argparse.Namespace()) + assert result == 0 + assert "Saved session expired" in capsys.readouterr().out + + def test_create_session_empty_credentials_file_allows_login( sessions_creds_file, monkeypatch, capsys ): @@ -116,6 +144,42 @@ def _raise_401(self, *_, **__): assert "Session already expired" in capsys.readouterr().out +def test_create_session_unreachable_server_reports_connectivity( + sessions_creds_file, monkeypatch, capsys +): + import json + + sessions_creds_file.write_text( + json.dumps({"api_token": "tok", "cbrain_url": "http://localhost:3000", "user_id": 1}) + ) + monkeypatch.setattr( + "cbrain_cli.cli_utils.CbrainClient.get", + lambda self, *_: (_ for _ in ()).throw(urllib.error.URLError("Connection refused")), + ) + result = create_session(argparse.Namespace()) + assert result == 1 + out = capsys.readouterr().out + assert "Cannot reach" in out + assert "Already logged in" not in out + + +def test_create_session_server_error_reports_status(sessions_creds_file, monkeypatch, capsys): + import json + + sessions_creds_file.write_text( + json.dumps({"api_token": "tok", "cbrain_url": "http://localhost:3000", "user_id": 1}) + ) + monkeypatch.setattr( + "cbrain_cli.cli_utils.CbrainClient.get", + lambda self, *_: (_ for _ in ()).throw(CliApiError("Server Error", status=500)), + ) + result = create_session(argparse.Namespace()) + assert result == 1 + out = capsys.readouterr().out + assert "500" in out + assert "Already logged in" not in out + + def test_create_session_uses_default_url(monkeypatch, sessions_creds_file): inputs = iter(["", "admin"]) monkeypatch.setattr("builtins.input", lambda _: next(inputs)) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index ae362c4..89ad54d 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,3 +1,5 @@ +import json + import pytest from cbrain_cli.cli_utils import CliValidationError @@ -71,12 +73,53 @@ def test_list_tasks_unsupported_filter_raises(): list_tasks(make_task_args(filter_name="other", bourreau_id=1)) -def test_operation_task_prints_json(monkeypatch, capsys): - monkeypatch.setattr( - "cbrain_cli.cli_utils.CbrainClient.send", - lambda self, *_, **__: ({"status": "ok"}, 200), - ) +def test_operation_task_returns_data(capture_urlopen): + configure, captured = capture_urlopen + configure({"status": "ok"}) from cbrain_cli.data.tasks import operation_task - operation_task(make_task_args()) - assert '"status": "ok"' in capsys.readouterr().out + args = make_task_args(operation="terminate", task_id=[1, 2]) + assert operation_task(args) == {"status": "ok"} + assert json.loads(captured["data"]) == { + "operation": "terminate", + "tasklist": [1, 2], + } + + +def test_operation_task_requires_operation(): + from cbrain_cli.data.tasks import operation_task + + with pytest.raises(CliValidationError, match="Operation is required"): + operation_task(make_task_args(task_id=[1])) + + +def test_operation_task_requires_task_or_batch(): + from cbrain_cli.data.tasks import operation_task + + with pytest.raises(CliValidationError, match="--task-id"): + operation_task(make_task_args(operation="hold")) + + +def test_operation_task_optional_flags(capture_urlopen): + configure, captured = capture_urlopen + configure({"ok": True}) + from cbrain_cli.data.tasks import operation_task + + operation_task( + make_task_args( + operation="duplicate", + task_id=[5], + batch_id=[9], + dup_bourreau_id=3, + archive_dp_id=15, + nozip=True, + ) + ) + assert json.loads(captured["data"]) == { + "operation": "duplicate", + "tasklist": [5], + "batch_ids": [9], + "dup_bourreau_id": 3, + "archive_dp_id": 15, + "nozip": True, + }