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
21 changes: 21 additions & 0 deletions .github/workflows/ruff.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: pre-commit

on: [push, pull_request]

jobs:
pre-commit:
name: Pre-commit checks
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Run pre-commit
run: |
pip install pre-commit
pre-commit run --config .pre-commit-config.yaml --all-files
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: ^capture_tests/expected_captures\.txt$
Expand All @@ -9,13 +9,13 @@ repos:
- id: check-yaml

- repo: https://github.com/tcort/markdown-link-check
rev: v3.13.6
rev: v3.14.2
hooks:
- id: markdown-link-check
args: [-q]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.5
rev: v0.15.18
hooks:
- id: ruff
args: [--fix]
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ When prompted for "Enter CBRAIN server URL prefix", enter:
## API Reference

This CLI interfaces with the CBRAIN REST API. For complete API documentation and specifications, refer to:
- [CBRAIN API Documentation (Swagger)](https://app.swaggerhub.com/apis/prioux/CBRAIN/7.0.0)
- [CBRAIN API Documentation (Swagger)](https://portal.cbrain.mcgill.ca/swagger)

## CLI Usage

Expand Down Expand Up @@ -197,7 +197,7 @@ The hooks currently:
- run `ruff --fix`;
- run `ruff format`.

The generated capture fixture `capture_tests/expected_captures.txt` is excluded from whitespace and Ruff hooks because exact captured output is intentional there.
The generated capture fixture `capture_tests/expected_captures.txt` is excluded from whitespace hooks and from Ruff (via `pyproject.toml`) because exact captured output is intentional there.

To run the hooks manually:

Expand Down
6 changes: 5 additions & 1 deletion capture_tests/cbrain_cli_commands
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,13 @@ cbrain --json project show 10
cbrain --jsonl project show 10

cbrain project switch 10
cbrain project unswitch

cbrain --json project switch 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 --json project switch all
Expand Down Expand Up @@ -167,4 +172,3 @@ cbrain file move --file-id 2 --dp-id 15

# Logging out
cbrain logout

53 changes: 49 additions & 4 deletions capture_tests/expected_captures.txt
Original file line number Diff line number Diff line change
Expand Up @@ -486,25 +486,70 @@ Current project is "NormTest1" ID=10
Stderr:
(No output)

############################
Command: cbrain project unswitch
Status: 0
Stdout: 42 bytes
Stderr: 0 bytes

Stdout:
Cleared current project "NormTest1" ID=10
Stderr:
(No output)

############################
Command: cbrain --json project switch 10
Status: 0
Stdout: 37 bytes
Stdout: 125 bytes
Stderr: 0 bytes

Stdout:
Current project is "NormTest1" ID=10
{
"id": 10,
"name": "NormTest1",
"description": null,
"type": "WorkGroup",
"site_id": null,
"invisible": false
}
Stderr:
(No output)

############################
Command: cbrain --json project unswitch
Status: 0
Stdout: 126 bytes
Stderr: 0 bytes

Stdout:
{
"previous_group_id": 10,
"previous_group_name": "NormTest1",
"current_group_id": null,
"current_group_name": null
}
Stderr:
(No output)

############################
Command: cbrain --jsonl project switch 10
Status: 0
Stdout: 37 bytes
Stdout: 100 bytes
Stderr: 0 bytes

Stdout:
Current project is "NormTest1" ID=10
{"id":10,"name":"NormTest1","description":null,"type":"WorkGroup","site_id":null,"invisible":false}
Stderr:
(No output)

############################
Command: cbrain --jsonl project unswitch
Status: 0
Stdout: 109 bytes
Stderr: 0 bytes

Stdout:
{"previous_group_id":10,"previous_group_name":"NormTest1","current_group_id":null,"current_group_name":null}
Stderr:
(No output)

Expand Down
33 changes: 16 additions & 17 deletions cbrain_cli/cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,22 @@
import urllib.request

# import importlib.metadata
from cbrain_cli.config import CREDENTIALS_FILE, DEFAULT_HEADERS, auth_headers

try:
# MARK: Credentials.
with open(CREDENTIALS_FILE) as f:
credentials = json.load(f)

# Get credentials.
cbrain_url = credentials.get("cbrain_url")
api_token = credentials.get("api_token")
user_id = credentials.get("user_id")
cbrain_timestamp = credentials.get("timestamp")
except FileNotFoundError:
cbrain_url = None
api_token = None
user_id = None
cbrain_timestamp = None
from cbrain_cli.config import DEFAULT_HEADERS, auth_headers, load_credentials

credentials = load_credentials() or {}
cbrain_url = credentials.get("cbrain_url")
api_token = credentials.get("api_token")
user_id = credentials.get("user_id")
cbrain_timestamp = credentials.get("timestamp")

PAGINATABLE_ACTIONS = {
("file", "list"),
("dataprovider", "list"),
("tool", "list"),
("tool-config", "list"),
("tag", "list"),
("task", "list"),
}


class CliValidationError(Exception):
Expand Down
22 changes: 21 additions & 1 deletion cbrain_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
CBRAIN CLI Configuration
"""

import json
from pathlib import Path

# Default settings.
Expand All @@ -10,7 +11,6 @@
# Session file configuration.
SESSION_FILE_DIR = Path.home() / ".config" / "cbrain"
SESSION_FILE_NAME = "credentials.json"
SESSION_FILE_DIR.mkdir(parents=True, exist_ok=True)
CREDENTIALS_FILE = SESSION_FILE_DIR / SESSION_FILE_NAME

# HTTP headers.
Expand All @@ -35,3 +35,23 @@ def auth_headers(api_token):
Headers dictionary with authorization
"""
return {"Accept": "application/json", "Authorization": f"Bearer {api_token}"}


def load_credentials():
"""
Load credentials from the session file.
"""
try:
with open(CREDENTIALS_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None


def save_credentials(credentials):
"""
Save credentials to the session file.
"""
CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CREDENTIALS_FILE, "w") as f:
json.dump(credentials, f, indent=2)
4 changes: 1 addition & 3 deletions cbrain_cli/data/data_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,5 @@ def delete_unregistered_files(args):
data_provider_id = getattr(args, "id", None)
if not data_provider_id:
raise CliValidationError("Data provider ID is required", field="id")
data, _ = api_send(
f"{cbrain_url}/data_providers/{data_provider_id}/delete", api_token
)
data, _ = api_send(f"{cbrain_url}/data_providers/{data_provider_id}/delete", api_token)
return data
4 changes: 1 addition & 3 deletions cbrain_cli/data/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,7 @@ 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="--dp-id")
payload = {
"file_ids": file_ids,
"data_provider_id_for_mv_cp": dest_provider_id,
Expand Down
69 changes: 50 additions & 19 deletions cbrain_cli/data/projects.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
import urllib.error

from cbrain_cli.cli_utils import (
Expand All @@ -9,7 +8,7 @@
api_token,
cbrain_url,
)
from cbrain_cli.config import CREDENTIALS_FILE
from cbrain_cli.config import load_credentials, save_credentials


def switch_project(args):
Expand Down Expand Up @@ -41,24 +40,58 @@ def switch_project(args):
except ValueError:
raise CliValidationError(
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)
group_data = api_get(f"{cbrain_url}/groups/{group_id}", api_token)

if CREDENTIALS_FILE.exists():
with open(CREDENTIALS_FILE) as f:
credentials = json.load(f)

credentials = load_credentials()
if credentials is not None:
credentials["current_group_id"] = group_id
credentials["current_group_name"] = group_data.get("name", "Unknown")

with open(CREDENTIALS_FILE, "w") as f:
json.dump(credentials, f, indent=2)
save_credentials(credentials)

return group_data


def unswitch_project(args):
"""
Clear the current project/group on the server and in local credentials.

Parameters
----------
args : argparse.Namespace
Command line arguments

Returns
-------
dict
Previous and current group identifiers (all None when no project was set)
"""
credentials = load_credentials()
previous_group_id = None
previous_group_name = None

if credentials is not None:
previous_group_id = credentials.get("current_group_id")
previous_group_name = credentials.get("current_group_name")

if previous_group_id:
api_send(f"{cbrain_url}/groups/switch", api_token)

if credentials is not None:
credentials.pop("current_group_id", None)
credentials.pop("current_group_name", None)
save_credentials(credentials)

return {
"previous_group_id": previous_group_id,
"previous_group_name": previous_group_name,
"current_group_id": None,
"current_group_name": None,
}


def show_project(args):
"""
Get the current project/group from credentials or show a specific project by ID.
Expand All @@ -77,16 +110,17 @@ def show_project(args):
project_id = getattr(args, "project_id", None)

if project_id:
# Show specific project by ID
# Show specific project by ID
try:
return api_get(f"{cbrain_url}/groups/{project_id}", api_token)
except urllib.error.HTTPError as e:
if e.code == 404:
raise CliApiError(f"Project with ID {project_id} not found")
raise CliApiError(f"Project with ID {project_id} not found") from None
raise

with open(CREDENTIALS_FILE) as f:
credentials = json.load(f)
credentials = load_credentials()
if credentials is None:
return None

current_group_id = credentials.get("current_group_id")
if not current_group_id:
Expand All @@ -98,11 +132,8 @@ def show_project(args):
if e.code == 404:
credentials.pop("current_group_id", None)
credentials.pop("current_group_name", None)
with open(CREDENTIALS_FILE, "w") as f:
json.dump(credentials, f, indent=2)
raise CliApiError(
f"Current project (ID {current_group_id}) no longer exists"
)
save_credentials(credentials)
raise CliApiError(f"Current project (ID {current_group_id}) no longer exists") from None
raise


Expand Down
4 changes: 1 addition & 3 deletions cbrain_cli/data/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,7 @@ def update_tag(args):
if not tag_id:
raise CliValidationError("Tag ID is required", field="tag_id")
payload = _tag_payload(args)
data, status = api_send(
f"{cbrain_url}/tags/{tag_id}", api_token, method="PUT", payload=payload
)
data, status = api_send(f"{cbrain_url}/tags/{tag_id}", api_token, method="PUT", payload=payload)
success = status in (200, 201, 204)
return data, success, None, status

Expand Down
4 changes: 1 addition & 3 deletions cbrain_cli/data/tool_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,4 @@ def tool_config_boutiques_descriptor(args):
config_id = getattr(args, "id", None)
if not config_id:
raise CliValidationError("Tool configuration ID is required", field="id")
return api_get(
f"{cbrain_url}/tool_configs/{config_id}/boutiques_descriptor", api_token
)
return api_get(f"{cbrain_url}/tool_configs/{config_id}/boutiques_descriptor", api_token)
Loading
Loading