Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions capture_tests/cbrain_cli_commands
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ cbrain --jsonl tag show 21
cbrain tag show 99
cbrain tag update 99 --name Renamed --user-id 2 --group-id 3
cbrain tag show 99
cbrain tag delete 99
cbrain tag delete 99 --yes

cbrain tag create --name NewTag1 --user-id 2 --group-id 3
cbrain --json tag create --name NewTag2 --user-id 2 --group-id 3
Expand Down Expand Up @@ -165,7 +165,7 @@ cbrain file show 2
cbrain --json file show 2
cbrain --jsonl file show 2

cbrain file delete 5
cbrain file delete 5 --yes

cbrain file move --file-id 2 --dp-id 15
#cbrain --json file move --file-id 2 --dp-id 15
Expand Down
24 changes: 14 additions & 10 deletions capture_tests/expected_captures.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,24 @@ Stderr:
############################
Command: cbrain version
Status: 0
Stdout: 30 bytes
Stdout: 32 bytes
Stderr: 0 bytes

Stdout:
cbrain cli client version 1.0
cbrain cli client version 0.1.0
Stderr:
(No output)

############################
Command: cbrain --json version
Status: 0
Stdout: 30 bytes
Stdout: 25 bytes
Stderr: 0 bytes

Stdout:
cbrain cli client version 1.0
{
"version": "0.1.0"
}
Stderr:
(No output)

Expand Down Expand Up @@ -74,22 +76,24 @@ Stderr:
############################
Command: cbrain version
Status: 0
Stdout: 30 bytes
Stdout: 32 bytes
Stderr: 0 bytes

Stdout:
cbrain cli client version 1.0
cbrain cli client version 0.1.0
Stderr:
(No output)

############################
Command: cbrain --json version
Status: 0
Stdout: 30 bytes
Stdout: 25 bytes
Stderr: 0 bytes

Stdout:
cbrain cli client version 1.0
{
"version": "0.1.0"
}
Stderr:
(No output)

Expand Down Expand Up @@ -744,7 +748,7 @@ Stderr:
(No output)

############################
Command: cbrain tag delete 99
Command: cbrain tag delete 99 --yes
Status: 0
Stdout: 29 bytes
Stderr: 0 bytes
Expand Down Expand Up @@ -1638,7 +1642,7 @@ Stderr:
(No output)

############################
Command: cbrain file delete 5
Command: cbrain file delete 5 --yes
Status: 0
Stdout: 71 bytes
Stderr: 0 bytes
Expand Down
79 changes: 63 additions & 16 deletions cbrain_cli/cli_utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import configparser
import functools
import importlib.metadata
import json
import re
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

# import importlib.metadata
from cbrain_cli.config import DEFAULT_HEADERS, auth_headers, load_credentials
from cbrain_cli.config import DEFAULT_HEADERS, DEFAULT_TIMEOUT, auth_headers, load_credentials

credentials = load_credentials() or {}
cbrain_url = credentials.get("cbrain_url")
Expand Down Expand Up @@ -136,11 +140,11 @@ def handle_connection_error(error):
error_data = json.loads(error_response)
if isinstance(error_data, dict):
# Look for common error message fields
error_msg = (
error_msg = str(
error_data.get("message")
or error_data.get("error")
or error_data.get("notice")
or str(error_data)
or error_data
)
# Check if this looks like a password change redirect
if "change_password" in error_msg:
Expand Down Expand Up @@ -186,7 +190,12 @@ def handle_connection_error(error):
else:
print(f"{status_description}: {error.reason}")
elif isinstance(error, urllib.error.URLError):
if "Connection refused" in str(error):
if isinstance(error.reason, socket.timeout):
print(
f"Error: Request timed out after {DEFAULT_TIMEOUT}s. "
"Check your connection or set CBRAIN_TIMEOUT env var."
)
elif "Connection refused" in str(error):
print(f"Error: Cannot connect to CBRAIN server at {cbrain_url}")
print("Please check if the CBRAIN server is running and accessible.")
else:
Expand Down Expand Up @@ -215,6 +224,10 @@ def wrapper(*args, **kwargs):
except urllib.error.URLError as e:
handle_connection_error(e)
return 1
except socket.timeout as e:
# Read-stage timeouts are raised bare, not wrapped in URLError.
handle_connection_error(urllib.error.URLError(e))
return 1
except json.JSONDecodeError:
print("Failed: Invalid response from server")
return 1
Expand All @@ -235,6 +248,9 @@ def version_info(args):
"""
Display the CLI version information.

Prefer installed package metadata; fall back to setup.cfg when running
from a source tree without install.

Parameters
----------
args : argparse.Namespace
Expand All @@ -245,14 +261,17 @@ def version_info(args):
int
Exit code (0 for success, 1 for failure)
"""
print("cbrain cli client version 1.0")
# try:
# cbrain_cli_version = importlib.metadata.version('cbrain-cli')
# print(f"cbrain cli client version {cbrain_cli_version}")
# return 0
# except importlib.metadata.PackageNotFoundError:
# print("Warning: Could not determine version. Package may not be installed properly.")
# return 1
try:
cbrain_cli_version = importlib.metadata.version("cbrain-cli")
except importlib.metadata.PackageNotFoundError:
cfg = configparser.ConfigParser()
cfg.read(Path(__file__).resolve().parents[1] / "setup.cfg")
cbrain_cli_version = cfg["metadata"]["version"]

if output_json(args, {"version": cbrain_cli_version}):
return 0
print(f"cbrain cli client version {cbrain_cli_version}")
return 0


def api_get(url, token, params=None):
Expand All @@ -262,7 +281,7 @@ def api_get(url, token, params=None):
if params:
url = f"{url}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=auth_headers(token), method="GET")
with urllib.request.urlopen(req) as r:
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
return json.loads(r.read().decode())


Expand All @@ -273,7 +292,7 @@ def api_post_form(url, form_data, headers=None):
headers = headers or DEFAULT_HEADERS
body = urllib.parse.urlencode(form_data).encode()
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req) as r:
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
return json.loads(r.read().decode())


Expand All @@ -287,7 +306,7 @@ def api_send(url, token, method="POST", payload=None):
headers["Content-Type"] = "application/json"
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req) as r:
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
raw = r.read().decode()
return (json.loads(raw) if raw.strip() else {}), r.status

Expand All @@ -305,6 +324,34 @@ def output_json(args, data):
return False


def confirm_destructive(args, prompt):
"""
Gate destructive actions: ``--yes`` skips, TTY asks, otherwise refuse.

Returns
-------
bool
True to proceed, False if the user declined an interactive prompt.
"""
if getattr(args, "yes", False):
return True
# JSON/JSONL must not mix with a prompt; pipes/EOF also never auto-confirm.
if getattr(args, "json", False) or getattr(args, "jsonl", False) or not sys.stdin.isatty():
raise CliValidationError(
"Refusing destructive action without confirmation; pass --yes",
field="--yes",
)
try:
answer = input(f"{prompt} [y/N]: ").strip().lower()
except EOFError:
print("Aborted.")
return False
if answer in ("y", "yes"):
return True
print("Aborted.")
return False


def display_key_value_table(pairs):
"""
Print a (key-value) two-column Field/Value table from a list of (field, value) tuples.
Expand Down
6 changes: 6 additions & 0 deletions cbrain_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
CREDENTIALS_FILE = SESSION_FILE_DIR / SESSION_FILE_NAME
DEFAULT_CREDENTIALS_MODE = 0o600

# HTTP request timeout in seconds; override with CBRAIN_TIMEOUT env var.
try:
DEFAULT_TIMEOUT = max(1, int(os.environ.get("CBRAIN_TIMEOUT", "30")))
except ValueError:
DEFAULT_TIMEOUT = 30

# HTTP headers.
DEFAULT_HEADERS = {
"Content-Type": "application/x-www-form-urlencoded",
Expand Down
6 changes: 3 additions & 3 deletions cbrain_cli/data/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
cbrain_url,
pagination,
)
from cbrain_cli.config import auth_headers
from cbrain_cli.config import DEFAULT_TIMEOUT, auth_headers


def show_file(args):
Expand Down Expand Up @@ -50,7 +50,7 @@ def upload_file(args):
(response_data, response_status, file_name, file_size) or None if error
"""
# Check if file exists.
if not os.path.exists(args.file_path):
if not os.path.isfile(args.file_path):
raise CliValidationError(f"File not found: {args.file_path}", field="file_path")

if args.group_id is None:
Expand Down Expand Up @@ -92,7 +92,7 @@ def upload_file(args):
request = urllib.request.Request(
f"{cbrain_url}/userfiles", data=body, headers=headers, method="POST"
)
with urllib.request.urlopen(request) as response:
with urllib.request.urlopen(request, timeout=DEFAULT_TIMEOUT) as response:
response_data = json.loads(response.read().decode("utf-8"))
return response_data, response.status, file_name, file_size, args.data_provider

Expand Down
4 changes: 3 additions & 1 deletion cbrain_cli/data/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def switch_project(args):
f"Invalid group ID '{group_id}'. Must be a number or 'all'", field="group_id"
) from None

api_send(f"{cbrain_url}/groups/switch?id={group_id}", api_token)
_, switch_status = api_send(f"{cbrain_url}/groups/switch?id={group_id}", api_token)
if switch_status not in (200, 201, 204):
raise CliApiError(f"Failed to switch project (HTTP {switch_status})")
group_data = api_get(f"{cbrain_url}/groups/{group_id}", api_token)

credentials = load_credentials()
Expand Down
4 changes: 2 additions & 2 deletions cbrain_cli/data/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ def show_tool(args):
api_token,
{"page": str(page), "per_page": str(per_page)},
)
if not tools_page:
if not tools_page or not isinstance(tools_page, list):
break
for tool in tools_page:
if tool.get("id") == tool_id:
if str(tool.get("id")) == str(tool_id):
return tool
if len(tools_page) < per_page:
break
Expand Down
6 changes: 1 addition & 5 deletions cbrain_cli/formatter/background_activities_fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,7 @@ def print_activities_list(activities_data, args):
"remote_resource_id": a.get("remote_resource_id", ""),
"status": a.get("status", ""),
"created_at": (
a.get("created_at", "").split("T")[0]
+ " "
+ a.get("created_at", "").split("T")[1].split(".")[0]
if a.get("created_at")
else ""
a["created_at"].replace("T", " ").split(".")[0] if a.get("created_at") else ""
),
"items": ",".join(map(str, a.get("items", []))) if a.get("items") else "",
"num_successes": a.get("num_successes", 0),
Expand Down
16 changes: 14 additions & 2 deletions cbrain_cli/formatter/files_fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ def print_files_list(files_data, args):
dynamic_table_print(files_data, ["id", "type", "name"], ["ID", "Type", "File Name"])


def print_upload_result(response_data, response_status, file_name, file_size, data_provider_id):
def print_upload_result(
response_data, response_status, file_name, file_size, data_provider_id, args=None
):
"""
Print the result of a file upload operation.

Expand All @@ -74,7 +76,12 @@ def print_upload_result(response_data, response_status, file_name, file_size, da
Size of the uploaded file in bytes
data_provider_id : int
ID of the data provider
args : argparse.Namespace, optional
Command line arguments, including the --json flag
"""
if args is not None and output_json(args, response_data):
return

print(f"Uploading {file_name} ({file_size} bytes) to data provider {data_provider_id}...")

if response_status == 200 or response_status == 201:
Expand All @@ -83,7 +90,7 @@ def print_upload_result(response_data, response_status, file_name, file_size, da
print(f"Server response: {response_data['notice']}")


def print_move_copy_result(response_data, response_status, operation="move"):
def print_move_copy_result(response_data, response_status, operation="move", args=None):
"""
Print the result of a file move or copy operation.

Expand All @@ -95,7 +102,12 @@ def print_move_copy_result(response_data, response_status, operation="move"):
HTTP status code
operation : str
Operation type ("move" or "copy")
args : argparse.Namespace, optional
Command line arguments, including the --json flag
"""
if args is not None and output_json(args, response_data):
return

if response_status == 200 or response_status == 201:
# Show the message from the API response
message = response_data.get("message", "").strip()
Expand Down
Loading
Loading