anyscale 0.26.21__py3-none-any.whl → 0.26.22__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.
@@ -15,9 +15,9 @@ import anyscale
15
15
  from anyscale.aggregated_instance_usage.models import DownloadCSVFilters
16
16
 
17
17
  download_csv_filters = DownloadCSVFilters(
18
- # Start date (inclusive) for the usage CSV.
18
+ # Start date (UTC inclusive) for the usage CSV.
19
19
  start_date="2024-10-01",
20
- # End date (inclusive) for the usage CSV.
20
+ # End date (UTC inclusive) for the usage CSV.
21
21
  end_date="2024-10-31",
22
22
  # Optional cloud name to filter by.
23
23
  cloud="cloud_name",
@@ -39,14 +39,14 @@ download_csv_filters = DownloadCSVFilters(
39
39
  raise ValueError("Incorrect date format, should be YYYY-MM-DD")
40
40
 
41
41
  start_date: str = field(
42
- metadata={"docstring": "Start date (inclusive) for the usage CSV."}
42
+ metadata={"docstring": "Start date (UTC inclusive) for the usage CSV."}
43
43
  )
44
44
 
45
45
  def _validate_start_date(self, start_date: str):
46
46
  self._validate_date(start_date)
47
47
 
48
48
  end_date: str = field(
49
- metadata={"docstring": "End date (inclusive) for the usage CSV."}
49
+ metadata={"docstring": "End date (UTC inclusive) for the usage CSV."}
50
50
  )
51
51
 
52
52
  def _validate_end_date(self, end_date: str):
anyscale/client/README.md CHANGED
@@ -294,6 +294,7 @@ Class | Method | HTTP request | Description
294
294
  *DefaultApi* | [**list_project_collaborators_api_v2_projects_project_id_collaborators_users_get**](docs/DefaultApi.md#list_project_collaborators_api_v2_projects_project_id_collaborators_users_get) | **GET** /api/v2/projects/{project_id}/collaborators/users | List Project Collaborators
295
295
  *DefaultApi* | [**list_projects_api_v2_projects_get**](docs/DefaultApi.md#list_projects_api_v2_projects_get) | **GET** /api/v2/projects/ | List Projects
296
296
  *DefaultApi* | [**list_ray_sessions_api_v2_tasks_dashboard_list_ray_sessions_get**](docs/DefaultApi.md#list_ray_sessions_api_v2_tasks_dashboard_list_ray_sessions_get) | **GET** /api/v2/tasks_dashboard/list_ray_sessions | List Ray Sessions
297
+ *DefaultApi* | [**list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get**](docs/DefaultApi.md#list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get) | **GET** /api/v2/experimental_workspaces/templates/recommended | List Recommended Workspace Templates
297
298
  *DefaultApi* | [**list_services_api_v2_services_v2_get**](docs/DefaultApi.md#list_services_api_v2_services_v2_get) | **GET** /api/v2/services-v2/ | List Services
298
299
  *DefaultApi* | [**list_sessions_api_v2_sessions_get**](docs/DefaultApi.md#list_sessions_api_v2_sessions_get) | **GET** /api/v2/sessions/ | List Sessions
299
300
  *DefaultApi* | [**list_workspace_templates_api_v2_experimental_workspaces_templates_get**](docs/DefaultApi.md#list_workspace_templates_api_v2_experimental_workspaces_templates_get) | **GET** /api/v2/experimental_workspaces/templates | List Workspace Templates
@@ -20980,6 +20980,7 @@ class DefaultApi(object):
20980
20980
 
20981
20981
  :param async_req bool: execute request asynchronously
20982
20982
  :param str cluster_id: (required)
20983
+ :param bool skip_job_details: Skip decorating job details, which can be an expensive operation.
20983
20984
  :param _preload_content: if False, the urllib3.HTTPResponse object will
20984
20985
  be returned without reading/decoding response
20985
20986
  data. Default is True.
@@ -21004,6 +21005,7 @@ class DefaultApi(object):
21004
21005
 
21005
21006
  :param async_req bool: execute request asynchronously
21006
21007
  :param str cluster_id: (required)
21008
+ :param bool skip_job_details: Skip decorating job details, which can be an expensive operation.
21007
21009
  :param _return_http_data_only: response data without head status code
21008
21010
  and headers
21009
21011
  :param _preload_content: if False, the urllib3.HTTPResponse object will
@@ -21021,7 +21023,8 @@ class DefaultApi(object):
21021
21023
  local_var_params = locals()
21022
21024
 
21023
21025
  all_params = [
21024
- 'cluster_id'
21026
+ 'cluster_id',
21027
+ 'skip_job_details'
21025
21028
  ]
21026
21029
  all_params.extend(
21027
21030
  [
@@ -21052,6 +21055,8 @@ class DefaultApi(object):
21052
21055
  query_params = []
21053
21056
  if 'cluster_id' in local_var_params and local_var_params['cluster_id'] is not None: # noqa: E501
21054
21057
  query_params.append(('cluster_id', local_var_params['cluster_id'])) # noqa: E501
21058
+ if 'skip_job_details' in local_var_params and local_var_params['skip_job_details'] is not None: # noqa: E501
21059
+ query_params.append(('skip_job_details', local_var_params['skip_job_details'])) # noqa: E501
21055
21060
 
21056
21061
  header_params = {}
21057
21062
 
@@ -26320,6 +26325,122 @@ class DefaultApi(object):
26320
26325
  _request_timeout=local_var_params.get('_request_timeout'),
26321
26326
  collection_formats=collection_formats)
26322
26327
 
26328
+ def list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get(self, **kwargs): # noqa: E501
26329
+ """List Recommended Workspace Templates # noqa: E501
26330
+
26331
+ Lists all workspace templates ranked by user's organization marketing questions # noqa: E501
26332
+ This method makes a synchronous HTTP request by default. To make an
26333
+ asynchronous HTTP request, please pass async_req=True
26334
+ >>> thread = api.list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get(async_req=True)
26335
+ >>> result = thread.get()
26336
+
26337
+ :param async_req bool: execute request asynchronously
26338
+ :param int count: Maximum number of templates to return
26339
+ :param list[str] oa_group_names: Search for templates that belong to the provided group name
26340
+ :param _preload_content: if False, the urllib3.HTTPResponse object will
26341
+ be returned without reading/decoding response
26342
+ data. Default is True.
26343
+ :param _request_timeout: timeout setting for this request. If one
26344
+ number provided, it will be total request
26345
+ timeout. It can also be a pair (tuple) of
26346
+ (connection, read) timeouts.
26347
+ :return: WorkspacetemplateListResponse
26348
+ If the method is called asynchronously,
26349
+ returns the request thread.
26350
+ """
26351
+ kwargs['_return_http_data_only'] = True
26352
+ return self.list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get_with_http_info(**kwargs) # noqa: E501
26353
+
26354
+ def list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get_with_http_info(self, **kwargs): # noqa: E501
26355
+ """List Recommended Workspace Templates # noqa: E501
26356
+
26357
+ Lists all workspace templates ranked by user's organization marketing questions # noqa: E501
26358
+ This method makes a synchronous HTTP request by default. To make an
26359
+ asynchronous HTTP request, please pass async_req=True
26360
+ >>> thread = api.list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get_with_http_info(async_req=True)
26361
+ >>> result = thread.get()
26362
+
26363
+ :param async_req bool: execute request asynchronously
26364
+ :param int count: Maximum number of templates to return
26365
+ :param list[str] oa_group_names: Search for templates that belong to the provided group name
26366
+ :param _return_http_data_only: response data without head status code
26367
+ and headers
26368
+ :param _preload_content: if False, the urllib3.HTTPResponse object will
26369
+ be returned without reading/decoding response
26370
+ data. Default is True.
26371
+ :param _request_timeout: timeout setting for this request. If one
26372
+ number provided, it will be total request
26373
+ timeout. It can also be a pair (tuple) of
26374
+ (connection, read) timeouts.
26375
+ :return: tuple(WorkspacetemplateListResponse, status_code(int), headers(HTTPHeaderDict))
26376
+ If the method is called asynchronously,
26377
+ returns the request thread.
26378
+ """
26379
+
26380
+ local_var_params = locals()
26381
+
26382
+ all_params = [
26383
+ 'count',
26384
+ 'oa_group_names'
26385
+ ]
26386
+ all_params.extend(
26387
+ [
26388
+ 'async_req',
26389
+ '_return_http_data_only',
26390
+ '_preload_content',
26391
+ '_request_timeout'
26392
+ ]
26393
+ )
26394
+
26395
+ for key, val in six.iteritems(local_var_params['kwargs']):
26396
+ if key not in all_params:
26397
+ raise ApiTypeError(
26398
+ "Got an unexpected keyword argument '%s'"
26399
+ " to method list_recommended_workspace_templates_api_v2_experimental_workspaces_templates_recommended_get" % key
26400
+ )
26401
+ local_var_params[key] = val
26402
+ del local_var_params['kwargs']
26403
+
26404
+ collection_formats = {}
26405
+
26406
+ path_params = {}
26407
+
26408
+ query_params = []
26409
+ if 'count' in local_var_params and local_var_params['count'] is not None: # noqa: E501
26410
+ query_params.append(('count', local_var_params['count'])) # noqa: E501
26411
+ if 'oa_group_names' in local_var_params and local_var_params['oa_group_names'] is not None: # noqa: E501
26412
+ query_params.append(('oa_group_names', local_var_params['oa_group_names'])) # noqa: E501
26413
+ collection_formats['oa_group_names'] = 'multi' # noqa: E501
26414
+
26415
+ header_params = {}
26416
+
26417
+ form_params = []
26418
+ local_var_files = {}
26419
+
26420
+ body_params = None
26421
+ # HTTP header `Accept`
26422
+ header_params['Accept'] = self.api_client.select_header_accept(
26423
+ ['application/json']) # noqa: E501
26424
+
26425
+ # Authentication setting
26426
+ auth_settings = [] # noqa: E501
26427
+
26428
+ return self.api_client.call_api(
26429
+ '/api/v2/experimental_workspaces/templates/recommended', 'GET',
26430
+ path_params,
26431
+ query_params,
26432
+ header_params,
26433
+ body=body_params,
26434
+ post_params=form_params,
26435
+ files=local_var_files,
26436
+ response_type='WorkspacetemplateListResponse', # noqa: E501
26437
+ auth_settings=auth_settings,
26438
+ async_req=local_var_params.get('async_req'),
26439
+ _return_http_data_only=local_var_params.get('_return_http_data_only'), # noqa: E501
26440
+ _preload_content=local_var_params.get('_preload_content', True),
26441
+ _request_timeout=local_var_params.get('_request_timeout'),
26442
+ collection_formats=collection_formats)
26443
+
26323
26444
  def list_services_api_v2_services_v2_get(self, **kwargs): # noqa: E501
26324
26445
  """List Services # noqa: E501
26325
26446
 
@@ -670,3 +670,7 @@ $ anyscale service archive --name my_service
670
670
  SERVICE_DELETE_EXAMPLE = """\
671
671
  $ anyscale service delete --name my_service
672
672
  """
673
+
674
+ SERVICE_LIST_EXAMPLE = """\
675
+ $ anyscale service list --state running --sort -created_at
676
+ """
@@ -0,0 +1,107 @@
1
+ import itertools
2
+ from json import dumps as json_dumps
3
+ from typing import Any, Callable, Dict, Iterator, List, Optional
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from anyscale.util import AnyscaleJSONEncoder
9
+
10
+
11
+ def _paginate(iterator: Iterator[Any], page_size: Optional[int]) -> Iterator[List[Any]]:
12
+ if page_size is None:
13
+ yield list(iterator)
14
+ else:
15
+ while True:
16
+ page = list(itertools.islice(iterator, page_size))
17
+ if not page:
18
+ return
19
+ yield page
20
+
21
+
22
+ def display_list( # noqa: PLR0913
23
+ iterator: Iterator[Any],
24
+ item_formatter: Callable[[Any], Dict[str, Any]],
25
+ table_creator: Callable[[bool], Table],
26
+ json_output: bool,
27
+ page_size: int,
28
+ interactive: bool,
29
+ max_items: Optional[int],
30
+ console: Console,
31
+ ) -> int:
32
+ """Displays a list of items from an iterator, handling pagination and output format.
33
+
34
+ Args:
35
+ iterator: The iterator yielding items to display.
36
+ item_formatter: A callable that takes an item and returns a dictionary
37
+ representing the row data (for table) or the JSON object.
38
+ table_creator: A callable that takes a boolean (is_first_page) and
39
+ returns a rich.Table instance. Used only if json_output is False.
40
+ json_output: If True, output items as a JSON list. Otherwise, display
41
+ them in a table created by table_creator.
42
+ page_size: The number of items to display per page in interactive mode.
43
+ interactive: If True, enables interactive pagination. If False, displays
44
+ up to max_items (or all items if max_items is None) without prompting.
45
+ max_items: The maximum total number of items to display when interactive
46
+ is False. If None, all items are displayed.
47
+ console: The rich.Console object to use for output.
48
+
49
+ Returns:
50
+ The total number of items displayed.
51
+ """
52
+ total_count = 0
53
+ pages = _paginate(iterator, page_size if interactive else max_items)
54
+
55
+ # fetch first page under spinner
56
+ with console.status("Retrieving items…", spinner="dots"):
57
+ try:
58
+ first_page = next(pages)
59
+ except StopIteration:
60
+ first_page = []
61
+
62
+ def _render(page: List[Any], is_first: bool, page_num: int):
63
+ nonlocal total_count
64
+ total_count += len(page)
65
+ if interactive:
66
+ console.print(f"[dim]Page {page_num}[/dim]")
67
+ rows = [item_formatter(item) for item in page]
68
+ if json_output:
69
+ json_str = json_dumps(rows, indent=2, cls=AnyscaleJSONEncoder)
70
+ console.print_json(json=json_str)
71
+ else:
72
+ tbl = table_creator(is_first)
73
+ for row in rows:
74
+ tbl.add_row(*row.values())
75
+ console.print(tbl)
76
+
77
+ # render first page
78
+ if first_page:
79
+ _render(first_page, True, page_num=1)
80
+
81
+ # non-interactive: stop after first page
82
+ if not interactive:
83
+ return total_count
84
+
85
+ # interactive: prompt after full first page
86
+ if len(first_page) == page_size:
87
+ console.print()
88
+ console.print(
89
+ "[dim]Press [bold]Enter[/bold] to continue, [bold]q[/bold] to quit…[/]"
90
+ )
91
+ if input("> ").strip().lower() == "q":
92
+ return total_count
93
+
94
+ # render remaining pages
95
+ page_num = 2
96
+ for page in pages:
97
+ _render(page, False, page_num)
98
+ if len(page) == page_size:
99
+ console.print()
100
+ console.print(
101
+ "[dim]Press [bold]Enter[/bold] to continue, [bold]q[/bold] to quit…[/]"
102
+ )
103
+ if input("> ").strip().lower() == "q":
104
+ break
105
+ page_num += 1
106
+
107
+ return total_count
@@ -2,15 +2,18 @@ from io import StringIO
2
2
  from json import dumps as json_dumps
3
3
  import pathlib
4
4
  import sys
5
- from typing import Any, Dict, Optional, Tuple
5
+ from typing import Any, Dict, List, Optional, Tuple
6
6
 
7
7
  import click
8
+ from rich.console import Console
9
+ from rich.table import Table
8
10
  from typing_extensions import Literal
9
11
  import yaml
10
12
 
11
13
  from anyscale._private.models.image_uri import ImageURI
12
14
  from anyscale.cli_logger import BlockLogger
13
15
  from anyscale.commands import command_examples
16
+ from anyscale.commands.list_util import display_list
14
17
  from anyscale.commands.util import (
15
18
  AnyscaleCommand,
16
19
  convert_kv_strings_to_dict,
@@ -19,8 +22,21 @@ from anyscale.commands.util import (
19
22
  )
20
23
  from anyscale.controllers.service_controller import ServiceController
21
24
  import anyscale.service
22
- from anyscale.service import ServiceConfig, ServiceLogMode, ServiceState, ServiceStatus
23
- from anyscale.util import validate_non_negative_arg
25
+ from anyscale.service.models import (
26
+ ServiceConfig,
27
+ ServiceLogMode,
28
+ ServiceSortField,
29
+ ServiceState,
30
+ ServiceStatus,
31
+ ServiceVersionStatus,
32
+ SortOrder,
33
+ )
34
+ from anyscale.util import (
35
+ AnyscaleJSONEncoder,
36
+ get_endpoint,
37
+ validate_non_negative_arg,
38
+ validate_service_state_filter,
39
+ )
24
40
 
25
41
 
26
42
  log = BlockLogger() # CLI Logger
@@ -389,12 +405,14 @@ def status(
389
405
  status_dict.get("primary_version", {}).pop("config", None)
390
406
  status_dict.get("canary_version", {}).pop("config", None)
391
407
 
408
+ console = Console()
392
409
  if json:
393
- print(json_dumps(status_dict, indent=4, sort_keys=False))
410
+ json_str = json_dumps(status_dict, indent=2, cls=AnyscaleJSONEncoder)
411
+ console.print_json(json=json_str)
394
412
  else:
395
413
  stream = StringIO()
396
414
  yaml.dump(status_dict, stream, sort_keys=False)
397
- print(stream.getvalue(), end="")
415
+ console.print(stream.getvalue(), end="")
398
416
 
399
417
 
400
418
  @service_cli.command(
@@ -624,51 +642,270 @@ def rollout( # noqa: PLR0913
624
642
  )
625
643
 
626
644
 
627
- # TODO(mowen): Add cloud support for this when we refactor to new SDK method
628
- @service_cli.command(name="list", help="Display information about services.")
629
- @click.option(
630
- "--name", "-n", required=False, default=None, help="Filter by service name."
645
+ MAX_PAGE_SIZE = 50
646
+ NON_INTERACTIVE_DEFAULT_MAX_ITEMS = 10
647
+
648
+
649
+ def validate_page_size(ctx, param, value):
650
+ value = validate_non_negative_arg(ctx, param, value)
651
+ if value is not None and value > MAX_PAGE_SIZE:
652
+ raise click.BadParameter(f"must be less than or equal to {MAX_PAGE_SIZE}.")
653
+ return value
654
+
655
+
656
+ def validate_max_items(ctx, param, value):
657
+ if value is None:
658
+ return None
659
+ return validate_non_negative_arg(ctx, param, value)
660
+
661
+
662
+ def _parse_sort_option(sort: Optional[str],) -> Tuple[Optional[str], SortOrder]:
663
+ """
664
+ Given a raw sort string (e.g. "-created_at"), return
665
+ (canonical_field_name, SortOrder).
666
+ """
667
+ if not sort:
668
+ return None, SortOrder.ASC
669
+
670
+ # build case-insensitive map of allowed fields
671
+ allowed = {f.value.lower(): f.value for f in ServiceSortField.__members__.values()}
672
+
673
+ # detect leading '-' for descending
674
+ if sort.startswith("-"):
675
+ raw = sort[1:]
676
+ order = SortOrder.DESC
677
+ else:
678
+ raw = sort
679
+ order = SortOrder.ASC
680
+
681
+ key = raw.lower()
682
+ if key not in allowed:
683
+ allowed_names = ", ".join(sorted(allowed.values()))
684
+ raise click.BadParameter(
685
+ f"Invalid sort field '{raw}'. Allowed fields: {allowed_names}"
686
+ )
687
+
688
+ return allowed[key], order
689
+
690
+
691
+ def _create_service_list_table(show_header: bool) -> Table:
692
+ table = Table(show_header=show_header, expand=True)
693
+ # NAME and ID: larger ratios, can wrap but never truncate
694
+ table.add_column(
695
+ "NAME", no_wrap=False, overflow="fold", ratio=3, min_width=15,
696
+ )
697
+ table.add_column(
698
+ "ID", no_wrap=False, overflow="fold", ratio=2, min_width=12,
699
+ )
700
+ # all other columns will wrap as needed
701
+ for heading in (
702
+ "CURRENT STATE",
703
+ "CREATOR",
704
+ "PROJECT",
705
+ "LAST DEPLOYED AT",
706
+ ):
707
+ table.add_column(
708
+ heading, no_wrap=False, overflow="fold", ratio=1, min_width=8,
709
+ )
710
+
711
+ return table
712
+
713
+
714
+ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
715
+ last_deployed_at = ""
716
+ if isinstance(svc.primary_version, ServiceVersionStatus):
717
+ last_deployed_at = svc.primary_version.created_at.strftime("%Y-%m-%d %H:%M:%S")
718
+
719
+ return {
720
+ "name": svc.name,
721
+ "id": svc.id,
722
+ "current_state": str(svc.state),
723
+ "creator": str(svc.creator or ""),
724
+ "project": str(svc.project or ""),
725
+ "last_deployed_at": last_deployed_at,
726
+ }
727
+
728
+
729
+ @service_cli.command(
730
+ name="list", help="List services.", cls=AnyscaleCommand,
631
731
  )
732
+ @click.option("--service-id", "--id", help="ID of the service to display.")
733
+ @click.option("--name", "-n", help="Name of the service to display.")
632
734
  @click.option(
633
- "--service-id", "--id", required=False, default=None, help="Filter by service id."
735
+ "--cloud",
736
+ type=str,
737
+ help="The Anyscale Cloud of this workload; defaults to your org/workspace cloud.",
634
738
  )
635
739
  @click.option(
636
- "--project-id", required=False, default=None, help="Filter by project id."
740
+ "--project",
741
+ type=str,
742
+ help="Named project to use; defaults to your org/workspace project.",
637
743
  )
638
744
  @click.option(
639
745
  "--created-by-me",
746
+ is_flag=True,
747
+ default=False,
640
748
  help="List services created by me only.",
749
+ )
750
+ @click.option(
751
+ "--state",
752
+ "-s",
753
+ "state_filter",
754
+ multiple=True,
755
+ callback=validate_service_state_filter,
756
+ help=(
757
+ "Filter by service state (repeatable). "
758
+ f"Allowed: {', '.join(s.value for s in ServiceState)}"
759
+ ),
760
+ )
761
+ @click.option(
762
+ "--include-archived",
641
763
  is_flag=True,
642
764
  default=False,
765
+ help="Include archived services.",
643
766
  )
644
767
  @click.option(
645
768
  "--max-items",
646
- required=False,
647
- default=10,
648
769
  type=int,
649
- help="Max items to show in list.",
650
- callback=validate_non_negative_arg,
770
+ callback=validate_max_items,
771
+ help="Max total items (only with --no-interactive).",
651
772
  )
652
- def list( # noqa: A001
653
- name: Optional[str],
773
+ @click.option(
774
+ "--page-size",
775
+ type=int,
776
+ default=10,
777
+ show_default=True,
778
+ callback=validate_page_size,
779
+ help=f"Items per page (max {MAX_PAGE_SIZE}).",
780
+ )
781
+ @click.option(
782
+ "--interactive/--no-interactive",
783
+ default=True,
784
+ show_default=True,
785
+ help="Use interactive paging.",
786
+ )
787
+ @click.option(
788
+ "--sort",
789
+ help=(
790
+ "Sort by FIELD (prefix with '-' for desc). "
791
+ f"Allowed: {', '.join(f.value for f in ServiceSortField.__members__.values())}"
792
+ ),
793
+ )
794
+ @click.option(
795
+ "-v",
796
+ "--verbose",
797
+ is_flag=True,
798
+ default=False,
799
+ help="Include full config in JSON output.",
800
+ )
801
+ @click.option(
802
+ "-j",
803
+ "--json",
804
+ "json_output",
805
+ is_flag=True,
806
+ default=False,
807
+ help="Emit structured JSON to stdout.",
808
+ )
809
+ def list( # noqa: PLR0913, A001
654
810
  service_id: Optional[str],
655
- project_id: Optional[str],
811
+ name: Optional[str],
656
812
  created_by_me: bool,
657
- max_items: int,
813
+ cloud: Optional[str],
814
+ project: Optional[str],
815
+ state_filter: List[str],
816
+ include_archived: bool,
817
+ max_items: Optional[int],
818
+ page_size: int,
819
+ sort: Optional[str],
820
+ json_output: bool,
821
+ interactive: bool,
822
+ verbose: bool,
658
823
  ):
659
- """List services based on the provided filters.
660
-
661
- This returns both v1 and v2 services.
662
- """
663
- service_controller = ServiceController()
664
- service_controller.list(
665
- name=name,
666
- service_id=service_id,
667
- project_id=project_id,
668
- created_by_me=created_by_me,
669
- max_items=max_items,
824
+ """List services based on the provided filters."""
825
+ if max_items is not None and interactive:
826
+ raise click.UsageError("--max-items only allowed with --no-interactive")
827
+
828
+ # parse sort
829
+ sort_field, sort_order = _parse_sort_option(sort)
830
+
831
+ # normalize max_items
832
+ effective_max = max_items
833
+ if not interactive and effective_max is None:
834
+ stderr = Console(stderr=True)
835
+ stderr.print(
836
+ f"Defaulting to {NON_INTERACTIVE_DEFAULT_MAX_ITEMS} items in batch mode; "
837
+ "use --max-items to override."
838
+ )
839
+ effective_max = NON_INTERACTIVE_DEFAULT_MAX_ITEMS
840
+
841
+ console = Console()
842
+ stderr = Console(stderr=True)
843
+
844
+ # diagnostics
845
+ stderr.print("[bold]Listing services with:[/]")
846
+ stderr.print(f"• name = {name or '<any>'}")
847
+ stderr.print(f"• states = {', '.join(state_filter) or '<all>'}")
848
+ stderr.print(f"• created_by_me = {created_by_me}")
849
+ stderr.print(f"• include_archived= {include_archived}")
850
+ stderr.print(f"• sort = {sort or '<none>'}")
851
+ stderr.print(f"• mode = {'interactive' if interactive else 'batch'}")
852
+ stderr.print(f"• per-page limit = {page_size}")
853
+ stderr.print(f"• max-items total = {effective_max or 'all'}")
854
+ stderr.print(f"\nView your Services in the UI at {get_endpoint('/services')}\n")
855
+
856
+ creator_id = (
857
+ ServiceController().get_authenticated_user_id() if created_by_me else None
670
858
  )
671
859
 
860
+ # choose formatter
861
+ if json_output:
862
+
863
+ def json_formatter(svc: ServiceStatus) -> Dict[str, Any]:
864
+ data = svc.to_dict()
865
+ if not verbose:
866
+ data.get("primary_version", {}).pop("config", None)
867
+ data.get("canary_version", {}).pop("config", None)
868
+ return data
869
+
870
+ formatter = json_formatter
871
+ else:
872
+ formatter = _format_service_output_data
873
+
874
+ total = 0
875
+ try:
876
+ iterator = anyscale.service.list(
877
+ service_id=service_id,
878
+ name=name,
879
+ state_filter=state_filter,
880
+ creator_id=creator_id,
881
+ cloud=cloud,
882
+ project=project,
883
+ include_archived=include_archived,
884
+ max_items=None if interactive else effective_max,
885
+ page_size=page_size,
886
+ sort_field=sort_field,
887
+ sort_order=sort_order,
888
+ )
889
+ total = display_list(
890
+ iterator=iter(iterator),
891
+ item_formatter=formatter,
892
+ table_creator=_create_service_list_table,
893
+ json_output=json_output,
894
+ page_size=page_size,
895
+ interactive=interactive,
896
+ max_items=effective_max,
897
+ console=console,
898
+ )
899
+
900
+ if not json_output:
901
+ if total > 0:
902
+ stderr.print(f"\nFetched {total} services.")
903
+ else:
904
+ stderr.print("\nNo services found.")
905
+ except Exception as e: # noqa: BLE001
906
+ log.error(f"Failed to list services: {e}")
907
+ sys.exit(1)
908
+
672
909
 
673
910
  # TODO(mowen): Add cloud support for this when we refactor to new SDK method
674
911
  @service_cli.command(name="rollback", help="Roll back a service.")
@@ -708,7 +945,6 @@ def rollback(
708
945
  service_controller.rollback(service_id, max_surge_percent)
709
946
 
710
947
 
711
- # TODO(mowen): Add cloud support for this when we refactor to new SDK method
712
948
  @service_cli.command(name="terminate", help="Terminate a service.")
713
949
  @click.option(
714
950
  "--service-id", "--id", required=False, help="ID of service.",