Skip to content

Commit

Permalink
[ASP-3966] Implement pagination (#430)
Browse files Browse the repository at this point in the history
* Add pynput dependency

* Add render paginated list results

* Add keyboard listener to list_all applications

* Add prototype 2: navigation using inquirer

* Add pagination module

* Add pagination to list all endpoints

* Remove pynput dependency

* ASP-3966 Move pagination module to subapps folder

* ASP-3966 Add named params to pagination calls

* ASP-3966 Add fixtures for pagination tests

* ASP-3966 Update unit tests

* ASP-3966 Update imports for handle_pagination

* ASP-3966 Fix issue with one page results

* ASP-3966 Sort imports

* ASP-3966 Add pagination unit tests

* ASP-3966 Lint code

* ASP-3966 Fix merge conflict leftover

* ASP-3966 Address code review requested changes

* ASP-3966 Revert changes in poetry.lock

* ASP-3966 Add validation when answer is None

* ASP-3966 Add sorting by descending id as default

* ASP-3966 Add entry to CHANGELOG
  • Loading branch information
julianaklulo authored Dec 8, 2023
1 parent e87dc39 commit f366dc1
Show file tree
Hide file tree
Showing 12 changed files with 389 additions and 149 deletions.
2 changes: 1 addition & 1 deletion jobbergate-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
This file keeps track of all notable changes to jobbergate-cli

## Unreleased

- Fixed the setting `CACHE_DIR` to expand the user home directory, allowing more flexibility on the path [ASP-4053]
- Fixed the question `BooleanList` to allow subquestion to have the same name [ASP-4228]
- Added pagination support for `list` commands [ASP-3966]

## 4.2.0a3 -- 2023-11-30

Expand Down
16 changes: 16 additions & 0 deletions jobbergate-cli/jobbergate_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,19 @@ class FileType(str, Enum):

ENTRYPOINT = "ENTRYPOINT"
SUPPORT = "SUPPORT"


class PaginationChoices(str, Enum):
"""
Enum describing the type of pagination that is available for list commands.
"""

PREVIOUS_PAGE = "Previous page"
NEXT_PAGE = "Next page"
EXIT = "Exit"

def __str__(self) -> str:
"""
Return the string representation of the enum.
"""
return self.value
44 changes: 44 additions & 0 deletions jobbergate-cli/jobbergate_cli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,47 @@ def render_single_result(
if ctx.full_output or hidden_fields is None:
hidden_fields = []
render_dict(result, hidden_fields=hidden_fields, title=title)


def render_paginated_list_results(
ctx: JobbergateContext,
envelope: ListResponseEnvelope,
title: str = "Results List",
style_mapper: StyleMapper = None,
hidden_fields: List[str] = None,
):
if envelope.total == 0:
terminal_message("There are no results to display", subject="Nothing here...")
return

if ctx.raw_output:
serialized = envelope.json()
deserialized = json.loads(serialized)
render_json(deserialized["items"])
return

current_page = envelope.page
total_pages = envelope.pages

if ctx.full_output or hidden_fields is None:
filtered_results = envelope.items
else:
filtered_results = [{k: v for (k, v) in d.items() if k not in hidden_fields} for d in envelope.items]

first_row = filtered_results[0]

table = Table(
title=title,
caption=f"Page: {current_page} of {total_pages} - Items: {len(filtered_results)} of {envelope.total}",
)
if style_mapper is None:
style_mapper = StyleMapper()
for key in first_row.keys():
table.add_column(key, **style_mapper.map_style(key))

for row in filtered_results:
table.add_row(*[str(v) for v in row.values()])

console = Console()
console.print()
console.print(table)
30 changes: 10 additions & 20 deletions jobbergate-cli/jobbergate_cli/subapps/applications/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@

from jobbergate_cli.constants import OV_CONTACT, SortOrder
from jobbergate_cli.exceptions import handle_abort
from jobbergate_cli.render import StyleMapper, render_list_results, render_single_result, terminal_message
from jobbergate_cli.render import StyleMapper, render_single_result, terminal_message
from jobbergate_cli.requests import make_request
from jobbergate_cli.schemas import JobbergateContext, ListResponseEnvelope
from jobbergate_cli.schemas import JobbergateContext
from jobbergate_cli.subapps.applications.tools import fetch_application_data, save_application_files, upload_application
from jobbergate_cli.subapps.pagination import handle_pagination


# TODO: move hidden field logic to the API
Expand Down Expand Up @@ -61,8 +62,8 @@ def list_all(
show_all: bool = typer.Option(False, "--all", help="Show all applications, even the ones without identifier"),
user_only: bool = typer.Option(False, "--user", help="Show only applications owned by the current user"),
search: Optional[str] = typer.Option(None, help="Apply a search term to results"),
sort_order: SortOrder = typer.Option(SortOrder.UNSORTED, help="Specify sort order"),
sort_field: Optional[str] = typer.Option(None, help="The field by which results should be sorted"),
sort_order: SortOrder = typer.Option(SortOrder.DESCENDING, help="Specify sort order"),
sort_field: Optional[str] = typer.Option("id", help="The field by which results should be sorted"),
):
"""
Show available applications
Expand All @@ -84,22 +85,11 @@ def list_all(
if sort_field is not None:
params["sort_field"] = sort_field

envelope = cast(
ListResponseEnvelope,
make_request(
jg_ctx.client,
"/jobbergate/job-script-templates",
"GET",
expected_status=200,
abort_message="Couldn't retrieve applications list from API",
support=True,
response_model_cls=ListResponseEnvelope,
params=params,
),
)
render_list_results(
jg_ctx,
envelope,
handle_pagination(
jg_ctx=jg_ctx,
url_path="/jobbergate/job-script-templates",
abort_message="Couldn't retrieve applications list from API",
params=params,
title="Applications List",
style_mapper=style_mapper,
hidden_fields=HIDDEN_FIELDS,
Expand Down
30 changes: 10 additions & 20 deletions jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
from jobbergate_cli.config import settings
from jobbergate_cli.constants import SortOrder
from jobbergate_cli.exceptions import Abort, handle_abort
from jobbergate_cli.render import StyleMapper, render_list_results, render_single_result, terminal_message
from jobbergate_cli.render import StyleMapper, render_single_result, terminal_message
from jobbergate_cli.requests import make_request
from jobbergate_cli.schemas import JobbergateContext, JobScriptCreateRequest, JobScriptResponse, ListResponseEnvelope
from jobbergate_cli.schemas import JobbergateContext, JobScriptCreateRequest, JobScriptResponse
from jobbergate_cli.subapps.job_scripts.tools import (
download_job_script_files,
fetch_job_script_data,
Expand All @@ -23,6 +23,7 @@
)
from jobbergate_cli.subapps.job_submissions.app import HIDDEN_FIELDS as JOB_SUBMISSION_HIDDEN_FIELDS
from jobbergate_cli.subapps.job_submissions.tools import create_job_submission
from jobbergate_cli.subapps.pagination import handle_pagination
from jobbergate_cli.text_tools import dedent


Expand Down Expand Up @@ -50,8 +51,8 @@ def list_all(
ctx: typer.Context,
show_all: bool = typer.Option(False, "--all", help="Show all job scripts, even the ones owned by others"),
search: Optional[str] = typer.Option(None, help="Apply a search term to results"),
sort_order: SortOrder = typer.Option(SortOrder.UNSORTED, help="Specify sort order"),
sort_field: Optional[str] = typer.Option(None, help="The field by which results should be sorted"),
sort_order: SortOrder = typer.Option(SortOrder.DESCENDING, help="Specify sort order"),
sort_field: Optional[str] = typer.Option("id", help="The field by which results should be sorted"),
from_application_id: Optional[int] = typer.Option(
None,
help="Filter job-scripts by the application-id they were created from.",
Expand All @@ -76,22 +77,11 @@ def list_all(
if from_application_id is not None:
params["from_job_script_template_id"] = from_application_id

envelope = cast(
ListResponseEnvelope,
make_request(
jg_ctx.client,
"/jobbergate/job-scripts",
"GET",
expected_status=200,
abort_message="Couldn't retrieve job scripts list from API",
support=True,
response_model_cls=ListResponseEnvelope,
params=params,
),
)
render_list_results(
jg_ctx,
envelope,
handle_pagination(
jg_ctx=jg_ctx,
url_path="/jobbergate/job-scripts",
abort_message="Couldn't retrieve job scripts list from API",
params=params,
title="Job Scripts List",
style_mapper=style_mapper,
hidden_fields=HIDDEN_FIELDS,
Expand Down
32 changes: 11 additions & 21 deletions jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@

from pathlib import Path
from textwrap import dedent
from typing import Any, Dict, Optional, cast
from typing import Any, Dict, Optional

import typer

from jobbergate_cli.constants import SortOrder
from jobbergate_cli.exceptions import handle_abort
from jobbergate_cli.render import StyleMapper, render_list_results, render_single_result, terminal_message
from jobbergate_cli.render import StyleMapper, render_single_result, terminal_message
from jobbergate_cli.requests import make_request
from jobbergate_cli.schemas import JobbergateContext, ListResponseEnvelope
from jobbergate_cli.schemas import JobbergateContext
from jobbergate_cli.subapps.job_submissions.tools import create_job_submission, fetch_job_submission_data
from jobbergate_cli.subapps.pagination import handle_pagination


# move hidden field logic to the API
Expand Down Expand Up @@ -120,8 +121,8 @@ def list_all(
help="Show all job submissions, even the ones owned by others",
),
search: Optional[str] = typer.Option(None, help="Apply a search term to results"),
sort_order: SortOrder = typer.Option(SortOrder.UNSORTED, help="Specify sort order"),
sort_field: Optional[str] = typer.Option(None, help="The field by which results should be sorted"),
sort_order: SortOrder = typer.Option(SortOrder.DESCENDING, help="Specify sort order"),
sort_field: Optional[str] = typer.Option("id", help="The field by which results should be sorted"),
from_job_script_id: Optional[int] = typer.Option(
None,
help="Filter job-submissions by the job-script-id they were created from.",
Expand All @@ -146,22 +147,11 @@ def list_all(
if from_job_script_id is not None:
params["from_job_script_id"] = from_job_script_id

envelope = cast(
ListResponseEnvelope,
make_request(
jg_ctx.client,
"/jobbergate/job-submissions",
"GET",
expected_status=200,
abort_message="Couldn't retrieve job submissions list from API",
support=True,
response_model_cls=ListResponseEnvelope,
params=params,
),
)
render_list_results(
jg_ctx,
envelope,
handle_pagination(
jg_ctx=jg_ctx,
url_path="/jobbergate/job-submissions",
abort_message="Couldn't retrieve job submissions list from API",
params=params,
title="Job Submission List",
style_mapper=style_mapper,
hidden_fields=HIDDEN_FIELDS,
Expand Down
102 changes: 102 additions & 0 deletions jobbergate-cli/jobbergate_cli/subapps/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from typing import Any, Dict, List, Optional, cast

import inquirer

from jobbergate_cli.constants import PaginationChoices
from jobbergate_cli.render import StyleMapper, render_paginated_list_results
from jobbergate_cli.requests import make_request
from jobbergate_cli.schemas import JobbergateContext, ListResponseEnvelope


def handle_pagination(
jg_ctx: JobbergateContext,
url_path: str,
abort_message: str = "There was an error communicating with the API",
params: Optional[Dict[str, Any]] = None,
title: str = "Results List",
style_mapper: StyleMapper = None,
hidden_fields: Optional[List[str]] = None,
):
assert jg_ctx is not None
assert jg_ctx.client is not None

current_page = 1

while True:
if params is None:
params = {}
params["page"] = current_page

envelope = cast(
ListResponseEnvelope,
make_request(
jg_ctx.client,
url_path,
"GET",
expected_status=200,
abort_message=abort_message,
support=True,
response_model_cls=ListResponseEnvelope,
params=params,
),
)

render_paginated_list_results(
jg_ctx,
envelope,
title=title,
style_mapper=style_mapper,
hidden_fields=hidden_fields,
)

if envelope.pages <= 1:
return

current_page = envelope.page

message = "Which page would you like to view?"
choices = [PaginationChoices.PREVIOUS_PAGE, PaginationChoices.NEXT_PAGE, PaginationChoices.EXIT]

if current_page == 1:
answer = inquirer.prompt(
[
inquirer.List(
"navigation",
message=message,
choices=choices[1:], # remove previous page option
default=PaginationChoices.NEXT_PAGE,
)
]
)
elif current_page == envelope.pages:
answer = inquirer.prompt(
[
inquirer.List(
"navigation",
message=message,
choices=choices[::2], # remove next page option
default=PaginationChoices.EXIT,
)
]
)
else:
answer = inquirer.prompt(
[
inquirer.List(
"navigation",
message=message,
choices=choices,
default=PaginationChoices.NEXT_PAGE,
)
]
)

if not answer:
return

if answer["navigation"] == PaginationChoices.NEXT_PAGE:
current_page += 1
elif answer["navigation"] == PaginationChoices.PREVIOUS_PAGE:
current_page -= 1
else:
return
Loading

0 comments on commit f366dc1

Please sign in to comment.