anyscale 0.26.17__py3-none-any.whl → 0.26.19__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.
Files changed (49) hide show
  1. anyscale/_private/docgen/models.md +2 -2
  2. anyscale/anyscale-cloud-setup.yaml +0 -4
  3. anyscale/client/README.md +12 -37
  4. anyscale/client/openapi_client/__init__.py +11 -20
  5. anyscale/client/openapi_client/api/default_api.py +115 -2004
  6. anyscale/client/openapi_client/models/__init__.py +11 -20
  7. anyscale/client/openapi_client/models/aws_config.py +402 -0
  8. anyscale/client/openapi_client/models/baseimagesenum.py +68 -1
  9. anyscale/client/openapi_client/models/cloud_deployment.py +397 -0
  10. anyscale/client/openapi_client/models/{webterminal_list_response.py → clouddeployment_list_response.py} +15 -15
  11. anyscale/client/openapi_client/models/file_storage.py +206 -0
  12. anyscale/client/openapi_client/models/gcp_config.py +402 -0
  13. anyscale/client/openapi_client/models/kubernetes_config.py +150 -0
  14. anyscale/client/openapi_client/models/{monitor_logs_extension.py → networking_mode.py} +7 -7
  15. anyscale/client/openapi_client/models/object_storage.py +178 -0
  16. anyscale/client/openapi_client/models/{sessiondetails_response.py → pcp_config.py} +23 -22
  17. anyscale/client/openapi_client/models/supportedbaseimagesenum.py +68 -1
  18. anyscale/client/openapi_client/models/workspace_template_readme.py +181 -0
  19. anyscale/client/openapi_client/models/{archivedlogsinfo_response.py → workspacetemplatereadme_response.py} +11 -11
  20. anyscale/commands/cloud_commands.py +55 -7
  21. anyscale/connect_utils/prepare_cluster.py +19 -14
  22. anyscale/controllers/cloud_controller.py +60 -3
  23. anyscale/sdk/anyscale_client/models/baseimagesenum.py +68 -1
  24. anyscale/sdk/anyscale_client/models/supportedbaseimagesenum.py +68 -1
  25. anyscale/shared_anyscale_utils/latest_ray_version.py +1 -1
  26. anyscale/version.py +1 -1
  27. {anyscale-0.26.17.dist-info → anyscale-0.26.19.dist-info}/METADATA +1 -1
  28. {anyscale-0.26.17.dist-info → anyscale-0.26.19.dist-info}/RECORD +33 -42
  29. anyscale/client/openapi_client/models/archived_logs_info.py +0 -164
  30. anyscale/client/openapi_client/models/create_experimental_workspace_from_job.py +0 -123
  31. anyscale/client/openapi_client/models/create_session_from_snapshot_options.py +0 -538
  32. anyscale/client/openapi_client/models/create_session_in_db.py +0 -434
  33. anyscale/client/openapi_client/models/create_session_response.py +0 -174
  34. anyscale/client/openapi_client/models/createsessionresponse_response.py +0 -121
  35. anyscale/client/openapi_client/models/external_service_status.py +0 -147
  36. anyscale/client/openapi_client/models/external_service_status_response.py +0 -250
  37. anyscale/client/openapi_client/models/externalservicestatusresponse_response.py +0 -121
  38. anyscale/client/openapi_client/models/session_describe.py +0 -175
  39. anyscale/client/openapi_client/models/session_details.py +0 -148
  40. anyscale/client/openapi_client/models/session_history_item.py +0 -146
  41. anyscale/client/openapi_client/models/sessiondescribe_response.py +0 -121
  42. anyscale/client/openapi_client/models/sessionhistoryitem_list_response.py +0 -147
  43. anyscale/client/openapi_client/models/update_compute_template.py +0 -146
  44. anyscale/client/openapi_client/models/update_compute_template_config.py +0 -464
  45. {anyscale-0.26.17.dist-info → anyscale-0.26.19.dist-info}/LICENSE +0 -0
  46. {anyscale-0.26.17.dist-info → anyscale-0.26.19.dist-info}/NOTICE +0 -0
  47. {anyscale-0.26.17.dist-info → anyscale-0.26.19.dist-info}/WHEEL +0 -0
  48. {anyscale-0.26.17.dist-info → anyscale-0.26.19.dist-info}/entry_points.txt +0 -0
  49. {anyscale-0.26.17.dist-info → anyscale-0.26.19.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,181 @@
1
+ # coding: utf-8
2
+
3
+ """
4
+ Managed Ray API
5
+
6
+ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) # noqa: E501
7
+
8
+ The version of the OpenAPI document: 0.1.0
9
+ Generated by: https://openapi-generator.tech
10
+ """
11
+
12
+
13
+ import pprint
14
+ import re # noqa: F401
15
+
16
+ import six
17
+
18
+ from openapi_client.configuration import Configuration
19
+
20
+
21
+ class WorkspaceTemplateReadme(object):
22
+ """NOTE: This class is auto generated by OpenAPI Generator.
23
+ Ref: https://openapi-generator.tech
24
+
25
+ Do not edit the class manually.
26
+ """
27
+
28
+ """
29
+ Attributes:
30
+ openapi_types (dict): The key is attribute name
31
+ and the value is attribute type.
32
+ attribute_map (dict): The key is attribute name
33
+ and the value is json key in definition.
34
+ """
35
+ openapi_types = {
36
+ 'content': 'str',
37
+ 'title': 'str',
38
+ 'description': 'str'
39
+ }
40
+
41
+ attribute_map = {
42
+ 'content': 'content',
43
+ 'title': 'title',
44
+ 'description': 'description'
45
+ }
46
+
47
+ def __init__(self, content=None, title=None, description=None, local_vars_configuration=None): # noqa: E501
48
+ """WorkspaceTemplateReadme - a model defined in OpenAPI""" # noqa: E501
49
+ if local_vars_configuration is None:
50
+ local_vars_configuration = Configuration()
51
+ self.local_vars_configuration = local_vars_configuration
52
+
53
+ self._content = None
54
+ self._title = None
55
+ self._description = None
56
+ self.discriminator = None
57
+
58
+ self.content = content
59
+ self.title = title
60
+ self.description = description
61
+
62
+ @property
63
+ def content(self):
64
+ """Gets the content of this WorkspaceTemplateReadme. # noqa: E501
65
+
66
+ The content of the readme. # noqa: E501
67
+
68
+ :return: The content of this WorkspaceTemplateReadme. # noqa: E501
69
+ :rtype: str
70
+ """
71
+ return self._content
72
+
73
+ @content.setter
74
+ def content(self, content):
75
+ """Sets the content of this WorkspaceTemplateReadme.
76
+
77
+ The content of the readme. # noqa: E501
78
+
79
+ :param content: The content of this WorkspaceTemplateReadme. # noqa: E501
80
+ :type: str
81
+ """
82
+ if self.local_vars_configuration.client_side_validation and content is None: # noqa: E501
83
+ raise ValueError("Invalid value for `content`, must not be `None`") # noqa: E501
84
+
85
+ self._content = content
86
+
87
+ @property
88
+ def title(self):
89
+ """Gets the title of this WorkspaceTemplateReadme. # noqa: E501
90
+
91
+ The title of the workspace template # noqa: E501
92
+
93
+ :return: The title of this WorkspaceTemplateReadme. # noqa: E501
94
+ :rtype: str
95
+ """
96
+ return self._title
97
+
98
+ @title.setter
99
+ def title(self, title):
100
+ """Sets the title of this WorkspaceTemplateReadme.
101
+
102
+ The title of the workspace template # noqa: E501
103
+
104
+ :param title: The title of this WorkspaceTemplateReadme. # noqa: E501
105
+ :type: str
106
+ """
107
+ if self.local_vars_configuration.client_side_validation and title is None: # noqa: E501
108
+ raise ValueError("Invalid value for `title`, must not be `None`") # noqa: E501
109
+
110
+ self._title = title
111
+
112
+ @property
113
+ def description(self):
114
+ """Gets the description of this WorkspaceTemplateReadme. # noqa: E501
115
+
116
+ The description of the workspace template # noqa: E501
117
+
118
+ :return: The description of this WorkspaceTemplateReadme. # noqa: E501
119
+ :rtype: str
120
+ """
121
+ return self._description
122
+
123
+ @description.setter
124
+ def description(self, description):
125
+ """Sets the description of this WorkspaceTemplateReadme.
126
+
127
+ The description of the workspace template # noqa: E501
128
+
129
+ :param description: The description of this WorkspaceTemplateReadme. # noqa: E501
130
+ :type: str
131
+ """
132
+ if self.local_vars_configuration.client_side_validation and description is None: # noqa: E501
133
+ raise ValueError("Invalid value for `description`, must not be `None`") # noqa: E501
134
+
135
+ self._description = description
136
+
137
+ def to_dict(self):
138
+ """Returns the model properties as a dict"""
139
+ result = {}
140
+
141
+ for attr, _ in six.iteritems(self.openapi_types):
142
+ value = getattr(self, attr)
143
+ if isinstance(value, list):
144
+ result[attr] = list(map(
145
+ lambda x: x.to_dict() if hasattr(x, "to_dict") else x,
146
+ value
147
+ ))
148
+ elif hasattr(value, "to_dict"):
149
+ result[attr] = value.to_dict()
150
+ elif isinstance(value, dict):
151
+ result[attr] = dict(map(
152
+ lambda item: (item[0], item[1].to_dict())
153
+ if hasattr(item[1], "to_dict") else item,
154
+ value.items()
155
+ ))
156
+ else:
157
+ result[attr] = value
158
+
159
+ return result
160
+
161
+ def to_str(self):
162
+ """Returns the string representation of the model"""
163
+ return pprint.pformat(self.to_dict())
164
+
165
+ def __repr__(self):
166
+ """For `print` and `pprint`"""
167
+ return self.to_str()
168
+
169
+ def __eq__(self, other):
170
+ """Returns true if both objects are equal"""
171
+ if not isinstance(other, WorkspaceTemplateReadme):
172
+ return False
173
+
174
+ return self.to_dict() == other.to_dict()
175
+
176
+ def __ne__(self, other):
177
+ """Returns true if both objects are not equal"""
178
+ if not isinstance(other, WorkspaceTemplateReadme):
179
+ return True
180
+
181
+ return self.to_dict() != other.to_dict()
@@ -18,7 +18,7 @@ import six
18
18
  from openapi_client.configuration import Configuration
19
19
 
20
20
 
21
- class ArchivedlogsinfoResponse(object):
21
+ class WorkspacetemplatereadmeResponse(object):
22
22
  """NOTE: This class is auto generated by OpenAPI Generator.
23
23
  Ref: https://openapi-generator.tech
24
24
 
@@ -33,7 +33,7 @@ class ArchivedlogsinfoResponse(object):
33
33
  and the value is json key in definition.
34
34
  """
35
35
  openapi_types = {
36
- 'result': 'ArchivedLogsInfo'
36
+ 'result': 'WorkspaceTemplateReadme'
37
37
  }
38
38
 
39
39
  attribute_map = {
@@ -41,7 +41,7 @@ class ArchivedlogsinfoResponse(object):
41
41
  }
42
42
 
43
43
  def __init__(self, result=None, local_vars_configuration=None): # noqa: E501
44
- """ArchivedlogsinfoResponse - a model defined in OpenAPI""" # noqa: E501
44
+ """WorkspacetemplatereadmeResponse - a model defined in OpenAPI""" # noqa: E501
45
45
  if local_vars_configuration is None:
46
46
  local_vars_configuration = Configuration()
47
47
  self.local_vars_configuration = local_vars_configuration
@@ -53,21 +53,21 @@ class ArchivedlogsinfoResponse(object):
53
53
 
54
54
  @property
55
55
  def result(self):
56
- """Gets the result of this ArchivedlogsinfoResponse. # noqa: E501
56
+ """Gets the result of this WorkspacetemplatereadmeResponse. # noqa: E501
57
57
 
58
58
 
59
- :return: The result of this ArchivedlogsinfoResponse. # noqa: E501
60
- :rtype: ArchivedLogsInfo
59
+ :return: The result of this WorkspacetemplatereadmeResponse. # noqa: E501
60
+ :rtype: WorkspaceTemplateReadme
61
61
  """
62
62
  return self._result
63
63
 
64
64
  @result.setter
65
65
  def result(self, result):
66
- """Sets the result of this ArchivedlogsinfoResponse.
66
+ """Sets the result of this WorkspacetemplatereadmeResponse.
67
67
 
68
68
 
69
- :param result: The result of this ArchivedlogsinfoResponse. # noqa: E501
70
- :type: ArchivedLogsInfo
69
+ :param result: The result of this WorkspacetemplatereadmeResponse. # noqa: E501
70
+ :type: WorkspaceTemplateReadme
71
71
  """
72
72
  if self.local_vars_configuration.client_side_validation and result is None: # noqa: E501
73
73
  raise ValueError("Invalid value for `result`, must not be `None`") # noqa: E501
@@ -108,14 +108,14 @@ class ArchivedlogsinfoResponse(object):
108
108
 
109
109
  def __eq__(self, other):
110
110
  """Returns true if both objects are equal"""
111
- if not isinstance(other, ArchivedlogsinfoResponse):
111
+ if not isinstance(other, WorkspacetemplatereadmeResponse):
112
112
  return False
113
113
 
114
114
  return self.to_dict() == other.to_dict()
115
115
 
116
116
  def __ne__(self, other):
117
117
  """Returns true if both objects are not equal"""
118
- if not isinstance(other, ArchivedlogsinfoResponse):
118
+ if not isinstance(other, WorkspacetemplatereadmeResponse):
119
119
  return True
120
120
 
121
121
  return self.to_dict() != other.to_dict()
@@ -1112,7 +1112,16 @@ def add_collaborators(cloud: str, users_file: str,) -> None:
1112
1112
  type=str,
1113
1113
  required=False,
1114
1114
  )
1115
- def get_cloud(cloud_id: Optional[str], name: Optional[str]) -> None:
1115
+ @click.option(
1116
+ "--output",
1117
+ "-o",
1118
+ help="File to write the full cloud YAML to.",
1119
+ type=str,
1120
+ required=False,
1121
+ )
1122
+ def get_cloud(
1123
+ cloud_id: Optional[str], name: Optional[str], output: Optional[str]
1124
+ ) -> None:
1116
1125
  """
1117
1126
  Retrieve a cloud by its name or ID and display its details.
1118
1127
 
@@ -1131,9 +1140,20 @@ def get_cloud(cloud_id: Optional[str], name: Optional[str]) -> None:
1131
1140
  log.error("Cloud not found.")
1132
1141
  return
1133
1142
 
1134
- cloud_dict = cloud.to_dict() if hasattr(cloud, "to_dict") else cloud.__dict__
1143
+ if output:
1144
+ # Include all cloud deployments for the cloud.
1145
+ result = CloudController().get_cloud_deployments(
1146
+ cloud_id=cloud.id, cloud_name=cloud.name
1147
+ )
1135
1148
 
1136
- print(yaml.dump(cloud_dict, sort_keys=False))
1149
+ with open(output, "w") as f:
1150
+ yaml.dump(result, f, sort_keys=False)
1151
+
1152
+ else:
1153
+ cloud_dict = (
1154
+ cloud.to_dict() if hasattr(cloud, "to_dict") else cloud.__dict__
1155
+ )
1156
+ print(yaml.dump(cloud_dict, sort_keys=False))
1137
1157
 
1138
1158
  except ValueError as e:
1139
1159
  log.error(f"Error retrieving cloud: {e}")
@@ -1189,15 +1209,38 @@ def get_default_cloud() -> None:
1189
1209
  type=bool,
1190
1210
  required=False,
1191
1211
  default=False,
1212
+ is_flag=True,
1192
1213
  )
1193
1214
  @click.option(
1194
1215
  "--out",
1195
- help="Output file name for the report.",
1216
+ help="Output file name for the report. (Default jobs_report.html)",
1196
1217
  type=str,
1197
1218
  required=False,
1198
- default="jobs_report.html",
1219
+ default=None,
1220
+ )
1221
+ @click.option(
1222
+ "--sort-by",
1223
+ help=(
1224
+ "Column to sort by. (Default created_at). "
1225
+ "created_at: Job creation time. "
1226
+ "gpu: Unused GPU hours. "
1227
+ "cpu: Unused CPU hours. "
1228
+ "instances: Number of instances."
1229
+ ),
1230
+ type=click.Choice(["created_at", "gpu", "cpu", "instances"], case_sensitive=False),
1231
+ required=False,
1232
+ default="created_at",
1199
1233
  )
1200
- def generate_jobs_report(cloud_id: str, csv: bool, out: str) -> None:
1234
+ @click.option(
1235
+ "--sort-order",
1236
+ help="Sort order. (Default desc)",
1237
+ type=click.Choice(["asc", "desc"], case_sensitive=False),
1238
+ required=False,
1239
+ default="desc",
1240
+ )
1241
+ def generate_jobs_report(
1242
+ cloud_id: str, csv: bool, out: Optional[str], sort_by: str, sort_order: str
1243
+ ) -> None:
1201
1244
  """
1202
1245
  Generate a report of the jobs created in the last 7 days in HTML format.
1203
1246
  Shows unused CPU-hours, unused GPU-hours, and other data.
@@ -1205,7 +1248,12 @@ def generate_jobs_report(cloud_id: str, csv: bool, out: str) -> None:
1205
1248
  :param csv: Outputs the report in CSV format.
1206
1249
  :param out: Output file name for the report.
1207
1250
  """
1251
+ if out is None:
1252
+ out = "jobs_report.html" if not csv else "jobs_report.csv"
1253
+
1208
1254
  try:
1209
- CloudController().generate_jobs_report(cloud_id, csv, out)
1255
+ CloudController().generate_jobs_report(
1256
+ cloud_id, csv, out, sort_by, sort_order == "asc"
1257
+ )
1210
1258
  except ValueError as e:
1211
1259
  log.error(f"Error generating jobs report: {e}")
@@ -56,6 +56,7 @@ BUILD_STEPS = [
56
56
  # Default minutes for autosuspend.
57
57
  DEFAULT_AUTOSUSPEND_TIMEOUT = 120
58
58
 
59
+
59
60
  # Default docker images to use for connect clusters.
60
61
  def _get_base_image(image: str, ray_version: str, cpu_or_gpu: str) -> str:
61
62
  py_version = "".join(str(x) for x in sys.version_info[0:2])
@@ -557,34 +558,36 @@ class PrepareClusterBlock:
557
558
  """
558
559
  Get the default cluster env build based on the local python and ray versions.
559
560
  """
560
- py_version = "".join(str(x) for x in sys.version_info[0:2])
561
- if sys.version_info.major == 3 and sys.version_info.minor == 10:
562
- py_version = "310"
563
- if py_version not in ["36", "37", "38", "39", "310"]:
561
+ major, minor = sys.version_info[:2]
562
+ MIN_PY_VER = (3, 8)
563
+ MAX_PY_VER = (3, 12)
564
+
565
+ if not (MIN_PY_VER <= (major, minor) <= MAX_PY_VER):
564
566
  raise ValueError(
565
- "No default cluster env for py{}. Please use a version of python between 3.6 and 3.8.".format(
566
- py_version
567
- )
567
+ f"No default container image for python version {major}.{minor}."
568
+ f"Please use a Python version between {MIN_PY_VER[0]}.{MIN_PY_VER[1]} "
569
+ f"and {MAX_PY_VER[0]}.{MAX_PY_VER[1]}."
568
570
  )
571
+
569
572
  ray_version = self._ray.__version__
570
573
  if version.parse(ray_version) < version.parse(MINIMUM_RAY_VERSION):
571
574
  raise ValueError(
572
- f"No default cluster env for Ray version {ray_version}. Please upgrade "
575
+ f"No default container image for Ray version {ray_version}. Please upgrade "
573
576
  f"to a version >= {MINIMUM_RAY_VERSION}."
574
577
  )
575
578
  if "dev0" in ray_version:
576
579
  raise ValueError(
577
580
  f"Your locally installed Ray version is {ray_version}. "
578
- "There is no default cluster environments for nightly versions of Ray."
581
+ "There is no default container image for nightly versions of Ray."
579
582
  )
580
583
  try:
581
584
  build = self.api_client.get_default_cluster_env_build_api_v2_builds_default_py_version_ray_version_get(
582
- f"py{py_version}", ray_version
585
+ f"py{major}{minor}", ray_version
583
586
  ).result
584
587
  return build
585
588
  except Exception: # noqa: BLE001
586
589
  raise RuntimeError(
587
- f"Failed to get default cluster env for Ray: {ray_version} on Python: py{py_version}"
590
+ f"Failed to get default container image for Ray: {ray_version} on Python: py{major}{minor}"
588
591
  )
589
592
 
590
593
  def _get_cluster_build(
@@ -815,14 +818,16 @@ class PrepareClusterBlock:
815
818
  return compute_template_id
816
819
 
817
820
  def _register_compute_template(
818
- self, project_id: str, config_object: ComputeTemplateConfig
821
+ self, project_id: str, config_object: ComputeTemplateConfig # noqa: ARG002
819
822
  ) -> str:
820
823
  """
821
- Register compute template with a default name and return the compute template id."""
824
+ Register compute template with a default name and return the compute template id.
825
+ """
822
826
  created_template = self.api_client.create_compute_template_api_v2_compute_templates_post(
823
827
  create_compute_template=CreateComputeTemplate(
824
828
  name=gen_valid_name("autogenerated-config"),
825
- project_id=project_id,
829
+ # project ID is deprecated in compute config
830
+ project_id=None,
826
831
  config=config_object,
827
832
  anonymous=True,
828
833
  )
@@ -1388,6 +1388,43 @@ class CloudController(BaseController):
1388
1388
  cloud_id, CloudProviders.AWS, functions_to_verify, yes,
1389
1389
  )
1390
1390
 
1391
+ def get_cloud_deployments(self, cloud_id: str, cloud_name: str) -> Dict[str, Any]:
1392
+ cloud = self.api_client.get_cloud_api_v2_clouds_cloud_id_get(
1393
+ cloud_id=cloud_id,
1394
+ ).result
1395
+
1396
+ if cloud.is_aioa:
1397
+ raise ValueError(
1398
+ "Listing cloud deployments is only supported for customer-hosted clouds."
1399
+ )
1400
+
1401
+ try:
1402
+ deployments = self.api_client.get_cloud_deployments_api_v2_clouds_cloud_id_deployments_get(
1403
+ cloud_id=cloud_id,
1404
+ ).results
1405
+ except Exception as e: # noqa: BLE001
1406
+ raise ClickException(
1407
+ f"Failed to get cloud deployments for cloud {cloud_name} ({cloud_id}). Error: {e}"
1408
+ )
1409
+
1410
+ # Avoid displaying fields with empty values (since the values for optional fields default to None).
1411
+ def remove_empty_values(d):
1412
+ if isinstance(d, dict):
1413
+ return {
1414
+ k: remove_empty_values(v)
1415
+ for k, v in d.items()
1416
+ if remove_empty_values(v)
1417
+ }
1418
+ return d
1419
+
1420
+ return {
1421
+ "id": cloud_id,
1422
+ "name": cloud_name,
1423
+ "deployments": [
1424
+ remove_empty_values(deployment.to_dict()) for deployment in deployments
1425
+ ],
1426
+ }
1427
+
1391
1428
  def get_cloud_config(
1392
1429
  self, cloud_name: Optional[str] = None, cloud_id: Optional[str] = None,
1393
1430
  ) -> CloudDeploymentConfig:
@@ -3650,7 +3687,9 @@ class CloudController(BaseController):
3650
3687
 
3651
3688
  ### End of edit cloud ###
3652
3689
 
3653
- def generate_jobs_report(self, cloud_id: str, csv: bool, out_path: str) -> None:
3690
+ def generate_jobs_report(
3691
+ self, cloud_id: str, csv: bool, out_path: str, sort: str, sort_order_asc: bool
3692
+ ) -> None:
3654
3693
  end_time = datetime.now()
3655
3694
  start_time = end_time - timedelta(days=7)
3656
3695
 
@@ -3690,7 +3729,25 @@ class CloudController(BaseController):
3690
3729
  for job in full_results
3691
3730
  if job.job_state in TERMINAL_HA_JOB_STATES and job.job_report is not None
3692
3731
  ]
3693
- filtered_results.sort(key=lambda x: x.created_at)
3732
+ if sort == "created_at":
3733
+ filtered_results.sort(
3734
+ key=lambda x: x.created_at, reverse=not sort_order_asc
3735
+ )
3736
+ elif sort == "gpu":
3737
+ filtered_results.sort(
3738
+ key=lambda x: x.job_report.unused_gpu_hours or 0,
3739
+ reverse=not sort_order_asc,
3740
+ )
3741
+ elif sort == "cpu":
3742
+ filtered_results.sort(
3743
+ key=lambda x: x.job_report.unused_cpu_hours or 0,
3744
+ reverse=not sort_order_asc,
3745
+ )
3746
+ elif sort == "instances":
3747
+ filtered_results.sort(
3748
+ key=lambda x: x.job_report.max_instances_launched or 0,
3749
+ reverse=not sort_order_asc,
3750
+ )
3694
3751
 
3695
3752
  with open(out_path, "w") as out_file:
3696
3753
  if csv:
@@ -3710,7 +3767,7 @@ class CloudController(BaseController):
3710
3767
  max_instances_launched = job.job_report.max_instances_launched or ""
3711
3768
 
3712
3769
  out_file.write(
3713
- f"{job.job_id},{job.job_name},{job_state},{str(job.created_at)},{finished_at},{duration},{unused_cpu_hours},{unused_gpu_hours},{max_instances_launched}\n"
3770
+ f'"{job.job_id}","{job.job_name}","{job_state}","{str(job.created_at)}","{finished_at}","{duration}","{unused_cpu_hours}","{unused_gpu_hours}","{max_instances_launched}"\n'
3714
3771
  )
3715
3772
  else:
3716
3773
  out_file.write(