divbase-cli 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- divbase_cli/__init__.py +1 -0
- divbase_cli/cli_commands/__init__.py +4 -0
- divbase_cli/cli_commands/auth_cli.py +95 -0
- divbase_cli/cli_commands/dimensions_cli.py +472 -0
- divbase_cli/cli_commands/file_cli.py +632 -0
- divbase_cli/cli_commands/query_cli.py +300 -0
- divbase_cli/cli_commands/shared_args_options.py +23 -0
- divbase_cli/cli_commands/task_history_cli.py +125 -0
- divbase_cli/cli_commands/user_config_cli.py +145 -0
- divbase_cli/cli_commands/version_cli.py +214 -0
- divbase_cli/cli_config.py +71 -0
- divbase_cli/cli_exceptions.py +166 -0
- divbase_cli/config_resolver.py +86 -0
- divbase_cli/display_task_history.py +197 -0
- divbase_cli/divbase_cli.py +76 -0
- divbase_cli/retries.py +34 -0
- divbase_cli/services/__init__.py +0 -0
- divbase_cli/services/announcements.py +46 -0
- divbase_cli/services/pre_signed_urls.py +460 -0
- divbase_cli/services/project_versions.py +103 -0
- divbase_cli/services/s3_files.py +444 -0
- divbase_cli/user_auth.py +380 -0
- divbase_cli/user_config.py +177 -0
- divbase_cli/utils.py +28 -0
- divbase_cli-0.1.0a1.dist-info/METADATA +44 -0
- divbase_cli-0.1.0a1.dist-info/RECORD +28 -0
- divbase_cli-0.1.0a1.dist-info/WHEEL +4 -0
- divbase_cli-0.1.0a1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from zoneinfo import ZoneInfo
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from divbase_cli.utils import print_rich_table_as_tsv
|
|
9
|
+
from divbase_lib.api_schemas.task_history import (
|
|
10
|
+
BcftoolsQueryTaskResult,
|
|
11
|
+
DimensionUpdateTaskResult,
|
|
12
|
+
SampleMetadataQueryTaskResult,
|
|
13
|
+
TaskHistoryResult,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskHistoryDisplayManager:
|
|
20
|
+
"""
|
|
21
|
+
A class that manages displaying task history results to the user's terminal.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
STATE_COLOURS = {
|
|
25
|
+
"SUCCESS": "green",
|
|
26
|
+
"FAILURE": "red",
|
|
27
|
+
"PENDING": "yellow",
|
|
28
|
+
"STARTED": "blue",
|
|
29
|
+
"RETRY": "blue",
|
|
30
|
+
"REVOKED": "magenta",
|
|
31
|
+
}
|
|
32
|
+
# These are the states known by the worker. The state when a task is in the queue is handled by the broker and PENDING is typically used for that purpose.
|
|
33
|
+
# For user display purposes, we want to show QUEUING instead of PENDING for tasks that are not yet started.
|
|
34
|
+
# To avoid having separate CRUD logic for the enqueued state, check task status against the worker state and return QUEUING to user's terminal.
|
|
35
|
+
CELERY_STATES_EXCLUDING_PENDING = {"STARTED", "SUCCESS", "FAILURE", "RETRY", "REVOKED"}
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
task_items: list[TaskHistoryResult],
|
|
40
|
+
user_email: str | None,
|
|
41
|
+
project_name: str | None,
|
|
42
|
+
mode: str,
|
|
43
|
+
display_limit: int = 10,
|
|
44
|
+
format_output_as_tsv: bool = False,
|
|
45
|
+
):
|
|
46
|
+
self.task_items = task_items
|
|
47
|
+
self.user_email = user_email
|
|
48
|
+
self.project_name = project_name
|
|
49
|
+
self.mode = mode
|
|
50
|
+
self.display_limit = display_limit
|
|
51
|
+
self.format_output_as_tsv = format_output_as_tsv
|
|
52
|
+
|
|
53
|
+
def print_task_history(self) -> None:
|
|
54
|
+
"""Display the task history fetched from the results backend in a formatted table."""
|
|
55
|
+
|
|
56
|
+
sorted_tasks = sorted(self.task_items, key=lambda x: x.created_at or "", reverse=True)
|
|
57
|
+
display_limit = self.display_limit
|
|
58
|
+
limited_tasks = sorted_tasks[:display_limit]
|
|
59
|
+
|
|
60
|
+
table = self._create_task_history_table()
|
|
61
|
+
|
|
62
|
+
for task in limited_tasks:
|
|
63
|
+
raw_status = task.status
|
|
64
|
+
if raw_status and raw_status.upper() in self.CELERY_STATES_EXCLUDING_PENDING:
|
|
65
|
+
state = raw_status.upper()
|
|
66
|
+
else:
|
|
67
|
+
state = "QUEUING"
|
|
68
|
+
|
|
69
|
+
if self.format_output_as_tsv:
|
|
70
|
+
state_with_colour = state
|
|
71
|
+
else:
|
|
72
|
+
colour = self.STATE_COLOURS.get(state, "white")
|
|
73
|
+
state_with_colour = f"[{colour}]{state}[/{colour}]"
|
|
74
|
+
|
|
75
|
+
submitter = task.submitter_email or "Unknown"
|
|
76
|
+
result = self._format_result(task, state)
|
|
77
|
+
|
|
78
|
+
table.add_row(
|
|
79
|
+
submitter,
|
|
80
|
+
str(task.id),
|
|
81
|
+
state_with_colour,
|
|
82
|
+
self._to_cet(task.created_at),
|
|
83
|
+
self._to_cet(task.started_at),
|
|
84
|
+
str(task.runtime if task.runtime is not None else "N/A"),
|
|
85
|
+
result,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if self.format_output_as_tsv:
|
|
89
|
+
print_rich_table_as_tsv(table=table)
|
|
90
|
+
else:
|
|
91
|
+
console = Console()
|
|
92
|
+
console.print(table)
|
|
93
|
+
|
|
94
|
+
def _create_task_history_table(self):
|
|
95
|
+
"""
|
|
96
|
+
Use the Rich library to initiate a table for displaying task history.
|
|
97
|
+
"""
|
|
98
|
+
title_prefix = "DivBase Task History"
|
|
99
|
+
if self.mode == "user":
|
|
100
|
+
title = f"{title_prefix} for User: {self.user_email or 'Unknown'}"
|
|
101
|
+
elif self.mode == "user_project":
|
|
102
|
+
title = (
|
|
103
|
+
f"{title_prefix} for User: {self.user_email or 'Unknown'} and Project: {self.project_name or 'Unknown'}"
|
|
104
|
+
)
|
|
105
|
+
elif self.mode == "id":
|
|
106
|
+
title = f"{title_prefix} for Task ID: {self.task_items[0].id if self.task_items else 'Unknown'}"
|
|
107
|
+
elif self.mode == "project":
|
|
108
|
+
title = f"{title_prefix} for Project: {self.project_name or 'Unknown'}"
|
|
109
|
+
else:
|
|
110
|
+
title = title_prefix
|
|
111
|
+
|
|
112
|
+
table = Table(title=title, show_lines=True)
|
|
113
|
+
table.add_column("Submitting user", width=12, overflow="fold")
|
|
114
|
+
table.add_column("Task ID", style="cyan")
|
|
115
|
+
table.add_column("State", width=8)
|
|
116
|
+
table.add_column("Created at", style="yellow", width=19, overflow="fold")
|
|
117
|
+
table.add_column("Started at", style="yellow", width=19, overflow="fold")
|
|
118
|
+
table.add_column("Runtime (s)", style="blue", width=10, overflow="fold")
|
|
119
|
+
table.add_column("Result", style="white", width=35, overflow="fold")
|
|
120
|
+
return table
|
|
121
|
+
|
|
122
|
+
def _format_result(self, task, state):
|
|
123
|
+
"""
|
|
124
|
+
Format the result message based on the task state and type.
|
|
125
|
+
"""
|
|
126
|
+
colour = self.STATE_COLOURS.get(state, "white")
|
|
127
|
+
|
|
128
|
+
if state == "FAILURE":
|
|
129
|
+
if isinstance(task.result, dict):
|
|
130
|
+
error_msg = task.result.get("error")
|
|
131
|
+
if not error_msg:
|
|
132
|
+
exc_message = task.result.get("exc_message")
|
|
133
|
+
if isinstance(exc_message, list) and exc_message:
|
|
134
|
+
error_msg = " ".join(str(msg) for msg in exc_message)
|
|
135
|
+
elif exc_message:
|
|
136
|
+
error_msg = str(exc_message)
|
|
137
|
+
if not error_msg:
|
|
138
|
+
exc_type = task.result.get("exc_type")
|
|
139
|
+
error_msg = exc_type if exc_type else "Unknown error"
|
|
140
|
+
else:
|
|
141
|
+
error_msg = str(task.result) or "Unknown error"
|
|
142
|
+
|
|
143
|
+
return self._colourize_if_enabled(str(error_msg), colour)
|
|
144
|
+
|
|
145
|
+
if isinstance(task.result, BcftoolsQueryTaskResult):
|
|
146
|
+
result_message = f"Output file ready for download: {task.result.output_file}"
|
|
147
|
+
return self._colourize_if_enabled(result_message, colour)
|
|
148
|
+
|
|
149
|
+
elif isinstance(task.result, SampleMetadataQueryTaskResult):
|
|
150
|
+
result_message = (
|
|
151
|
+
f"Unique sample IDs:\n {task.result.unique_sample_ids}\n"
|
|
152
|
+
f"VCF files containing the sample IDs:\n {task.result.unique_filenames}\n"
|
|
153
|
+
f"Sample metadata query:\n {task.result.query_message}"
|
|
154
|
+
)
|
|
155
|
+
return self._colourize_if_enabled(result_message, colour)
|
|
156
|
+
|
|
157
|
+
elif isinstance(task.result, DimensionUpdateTaskResult):
|
|
158
|
+
result_message = (
|
|
159
|
+
f"VCF file dimensions index added or updated:\n {task.result.VCF_files_added}\n"
|
|
160
|
+
f"VCF files skipped by this job (previous DivBase-generated result VCFs):\n {task.result.VCF_files_skipped}\n"
|
|
161
|
+
f"VCF files that have been deleted from the project and now are dropped from the index:\n {task.result.VCF_files_deleted}"
|
|
162
|
+
)
|
|
163
|
+
return self._colourize_if_enabled(result_message, colour)
|
|
164
|
+
|
|
165
|
+
if isinstance(task.result, dict) and ("exc_type" in task.result or "error" in task.result):
|
|
166
|
+
# Handle any remaining error dicts that weren't caught by FAILURE state check
|
|
167
|
+
error_msg = task.result.get("error")
|
|
168
|
+
if not error_msg:
|
|
169
|
+
exc_message = task.result.get("exc_message")
|
|
170
|
+
if isinstance(exc_message, list) and exc_message:
|
|
171
|
+
error_msg = " ".join(str(msg) for msg in exc_message)
|
|
172
|
+
elif exc_message:
|
|
173
|
+
error_msg = str(exc_message)
|
|
174
|
+
else:
|
|
175
|
+
error_msg = task.result.get("exc_type", "Unknown error")
|
|
176
|
+
return self._colourize_if_enabled(str(error_msg), colour)
|
|
177
|
+
|
|
178
|
+
result_message = str(task.result)
|
|
179
|
+
return self._colourize_if_enabled(result_message, colour)
|
|
180
|
+
|
|
181
|
+
def _colourize_if_enabled(self, text: str, colour: str) -> str:
|
|
182
|
+
if self.format_output_as_tsv:
|
|
183
|
+
return text
|
|
184
|
+
return f"[{colour}]{text}[/{colour}]"
|
|
185
|
+
|
|
186
|
+
def _to_cet(self, timestamp_str):
|
|
187
|
+
"""
|
|
188
|
+
Convert a UTC timestamp string in '%Y-%m-%d %H:%M:%S UTC' format to CET.
|
|
189
|
+
"""
|
|
190
|
+
if not timestamp_str or timestamp_str == "N/A":
|
|
191
|
+
return "N/A"
|
|
192
|
+
try:
|
|
193
|
+
dt = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S %Z")
|
|
194
|
+
cet_dt = dt.astimezone(ZoneInfo("Europe/Stockholm"))
|
|
195
|
+
return cet_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
196
|
+
except Exception:
|
|
197
|
+
return timestamp_str
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The entry point for the DivBase CLI tool that lets users interact with DivBase project.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from divbase_cli import __version__
|
|
11
|
+
from divbase_cli.cli_commands.auth_cli import auth_app
|
|
12
|
+
from divbase_cli.cli_commands.dimensions_cli import dimensions_app
|
|
13
|
+
from divbase_cli.cli_commands.file_cli import file_app
|
|
14
|
+
from divbase_cli.cli_commands.query_cli import query_app
|
|
15
|
+
from divbase_cli.cli_commands.task_history_cli import task_history_app
|
|
16
|
+
from divbase_cli.cli_commands.user_config_cli import config_app
|
|
17
|
+
from divbase_cli.cli_commands.version_cli import version_app
|
|
18
|
+
from divbase_cli.cli_config import cli_settings
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
help="""
|
|
25
|
+
This tool lets you interact with your DivBase project(s) in order to: \n
|
|
26
|
+
- Query the metadata for the VCF files stored in the project. \n
|
|
27
|
+
- Upload/download files to/from the project. \n
|
|
28
|
+
- Version the state of all files in the entire project at a given timestamp.
|
|
29
|
+
""",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def version_callback(show_version: bool) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Callback function to display the installed version of the divbase-cli package.
|
|
38
|
+
See https://typer.tiangolo.com/tutorial/options/version/#fix-with-is_eager
|
|
39
|
+
"""
|
|
40
|
+
if show_version:
|
|
41
|
+
typer.echo(f"divbase-cli version: {__version__}")
|
|
42
|
+
raise typer.Exit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def show_installed_version(
|
|
47
|
+
show_version: bool = typer.Option(
|
|
48
|
+
None,
|
|
49
|
+
"--version",
|
|
50
|
+
"-v",
|
|
51
|
+
callback=version_callback,
|
|
52
|
+
is_eager=True,
|
|
53
|
+
help="Show the currently installed version of the divbase-cli package.",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app.add_typer(version_app, name="version")
|
|
60
|
+
app.add_typer(file_app, name="files")
|
|
61
|
+
app.add_typer(config_app, name="config")
|
|
62
|
+
app.add_typer(query_app, name="query")
|
|
63
|
+
app.add_typer(dimensions_app, name="dimensions")
|
|
64
|
+
app.add_typer(auth_app, name="auth")
|
|
65
|
+
app.add_typer(task_history_app, name="task-history")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
if cli_settings.LOGGING_ON:
|
|
70
|
+
logging.basicConfig(level=cli_settings.LOG_LEVEL, handlers=[logging.StreamHandler(sys.stderr)])
|
|
71
|
+
logger.info(f"Starting divbase_cli CLI application with logging level: {cli_settings.LOG_LEVEL}")
|
|
72
|
+
app()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
divbase_cli/retries.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry logic for the divbase-cli package.
|
|
3
|
+
|
|
4
|
+
Defines functions to determine whether to retry based on the type of exceptions raised.
|
|
5
|
+
Functions are used in stamina (package) decorators.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from divbase_cli.cli_exceptions import DivBaseAPIConnectionError, DivBaseAPIError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def retry_only_on_retryable_http_errors(exc: Exception) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Used by stamina's (library for retries) decorators to determine whether to retry the function or not.
|
|
16
|
+
We avoid retrying on HTTPStatusError for 4xx errors as no point (e.g. 404 Not Found or 403 Forbidden etc...).
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
19
|
+
return exc.response.status_code >= 500
|
|
20
|
+
|
|
21
|
+
# Want to retry on other HTTPError (parent of HTTPStatusError),
|
|
22
|
+
# as this includes timeouts, connection errors, etc.
|
|
23
|
+
return isinstance(exc, httpx.HTTPError)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def retry_only_on_retryable_divbase_api_errors(exception: Exception) -> bool:
|
|
27
|
+
"""
|
|
28
|
+
Retry condition function for stamina to only retry on retryable DivBase API errors.
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(exception, DivBaseAPIConnectionError):
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
# Retry only for server errors (5xx) and rate limiting (429)
|
|
34
|
+
return isinstance(exception, DivBaseAPIError) and (exception.status_code == 429 or exception.status_code >= 500)
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Get announcements from divbase API and display them to the user.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from rich import print
|
|
6
|
+
|
|
7
|
+
from divbase_cli.user_auth import make_unauthenticated_request
|
|
8
|
+
from divbase_lib.api_schemas.announcements import AnnouncementResponse
|
|
9
|
+
|
|
10
|
+
COLOR_MAPPING = {
|
|
11
|
+
"info": "blue",
|
|
12
|
+
"success": "green",
|
|
13
|
+
"warning": "yellow",
|
|
14
|
+
"danger": "red",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_and_display_announcements(divbase_base_url: str) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Get announcements from the API and display them to the user.
|
|
21
|
+
Used when logging in to display any important messages to the user.
|
|
22
|
+
"""
|
|
23
|
+
response = make_unauthenticated_request(
|
|
24
|
+
method="GET",
|
|
25
|
+
divbase_base_url=divbase_base_url,
|
|
26
|
+
api_route="/v1/core/announcements",
|
|
27
|
+
)
|
|
28
|
+
announcements_data = response.json()
|
|
29
|
+
if not announcements_data:
|
|
30
|
+
return
|
|
31
|
+
announcements = [AnnouncementResponse.model_validate(ann) for ann in announcements_data]
|
|
32
|
+
|
|
33
|
+
if len(announcements) == 1:
|
|
34
|
+
print("[bold]An Announcement from DivBase Server:[/bold]")
|
|
35
|
+
color = COLOR_MAPPING.get(announcements[0].level)
|
|
36
|
+
print(f"[bold {color}]{announcements[0].heading}[/bold {color}]")
|
|
37
|
+
if announcements[0].message:
|
|
38
|
+
print(f"{announcements[0].message}\n")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
print("[bold]Announcements from DivBase Server:[/bold]")
|
|
42
|
+
for idx, ann in enumerate(announcements, start=1):
|
|
43
|
+
color = COLOR_MAPPING.get(ann.level)
|
|
44
|
+
print(f"[bold {color}]{idx}) {ann.heading}[/bold {color}]")
|
|
45
|
+
if ann.message:
|
|
46
|
+
print(f"{ann.message}\n")
|