anyscale 0.26.70__py3-none-any.whl → 0.26.71__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 (78) hide show
  1. anyscale/_private/anyscale_client/anyscale_client.py +63 -6
  2. anyscale/_private/anyscale_client/common.py +33 -3
  3. anyscale/_private/anyscale_client/fake_anyscale_client.py +27 -2
  4. anyscale/client/README.md +29 -0
  5. anyscale/client/openapi_client/__init__.py +19 -0
  6. anyscale/client/openapi_client/api/default_api.py +1307 -4
  7. anyscale/client/openapi_client/models/__init__.py +19 -0
  8. anyscale/client/openapi_client/models/apply_multi_version_update_weights_update_model.py +152 -0
  9. anyscale/client/openapi_client/models/apply_version_weight_update_model.py +181 -0
  10. anyscale/client/openapi_client/models/backend_server_api_product_models_catalog_client_models_table_metadata.py +546 -0
  11. anyscale/client/openapi_client/models/backend_server_api_product_models_data_catalogs_table_metadata.py +178 -0
  12. anyscale/client/openapi_client/models/baseimagesenum.py +70 -1
  13. anyscale/client/openapi_client/models/catalog_metadata.py +150 -0
  14. anyscale/client/openapi_client/models/column_info.py +265 -0
  15. anyscale/client/openapi_client/models/compute_node_type.py +29 -1
  16. anyscale/client/openapi_client/models/connection_metadata.py +206 -0
  17. anyscale/client/openapi_client/models/create_workspace_template_version.py +31 -3
  18. anyscale/client/openapi_client/models/data_catalog.py +45 -31
  19. anyscale/client/openapi_client/models/data_catalog_connection.py +74 -58
  20. anyscale/client/openapi_client/models/data_catalog_object_type.py +100 -0
  21. anyscale/client/openapi_client/models/data_catalog_schema.py +324 -0
  22. anyscale/client/openapi_client/models/data_catalog_table.py +437 -0
  23. anyscale/client/openapi_client/models/data_catalog_volume.py +437 -0
  24. anyscale/client/openapi_client/models/datacatalogschema_list_response.py +147 -0
  25. anyscale/client/openapi_client/models/datacatalogtable_list_response.py +147 -0
  26. anyscale/client/openapi_client/models/datacatalogvolume_list_response.py +147 -0
  27. anyscale/client/openapi_client/models/decorated_serve_deployment.py +27 -1
  28. anyscale/client/openapi_client/models/decoratedproductionservicev2_versionapimodel_response.py +121 -0
  29. anyscale/client/openapi_client/models/describe_machine_pool_machines_filters.py +2 -2
  30. anyscale/client/openapi_client/models/describe_machine_pool_requests_filters.py +33 -5
  31. anyscale/client/openapi_client/models/describe_machine_pool_workloads_filters.py +2 -2
  32. anyscale/client/openapi_client/models/physical_resources.py +178 -0
  33. anyscale/client/openapi_client/models/schema_metadata.py +150 -0
  34. anyscale/client/openapi_client/models/sso_config.py +18 -18
  35. anyscale/client/openapi_client/models/supportedbaseimagesenum.py +70 -1
  36. anyscale/client/openapi_client/models/table_data_preview.py +209 -0
  37. anyscale/client/openapi_client/models/volume_metadata.py +150 -0
  38. anyscale/client/openapi_client/models/worker_node_type.py +29 -1
  39. anyscale/client/openapi_client/models/workspace_template_version.py +29 -1
  40. anyscale/client/openapi_client/models/workspace_template_version_data_object.py +29 -1
  41. anyscale/commands/job_commands.py +120 -0
  42. anyscale/commands/job_queue_commands.py +99 -2
  43. anyscale/commands/service_commands.py +139 -2
  44. anyscale/commands/util.py +104 -1
  45. anyscale/commands/workspace_commands.py +123 -5
  46. anyscale/commands/workspace_commands_v2.py +17 -1
  47. anyscale/compute_config/_private/compute_config_sdk.py +25 -12
  48. anyscale/compute_config/models.py +15 -0
  49. anyscale/controllers/job_controller.py +12 -0
  50. anyscale/controllers/workspace_controller.py +67 -5
  51. anyscale/job/_private/job_sdk.py +3 -1
  52. anyscale/job/models.py +16 -0
  53. anyscale/job_queue/__init__.py +37 -1
  54. anyscale/job_queue/_private/job_queue_sdk.py +28 -1
  55. anyscale/job_queue/commands.py +61 -1
  56. anyscale/sdk/anyscale_client/__init__.py +1 -0
  57. anyscale/sdk/anyscale_client/api/default_api.py +12 -2
  58. anyscale/sdk/anyscale_client/models/__init__.py +1 -0
  59. anyscale/sdk/anyscale_client/models/baseimagesenum.py +70 -1
  60. anyscale/sdk/anyscale_client/models/compute_node_type.py +29 -1
  61. anyscale/sdk/anyscale_client/models/physical_resources.py +178 -0
  62. anyscale/sdk/anyscale_client/models/supportedbaseimagesenum.py +70 -1
  63. anyscale/sdk/anyscale_client/models/worker_node_type.py +29 -1
  64. anyscale/service/__init__.py +40 -0
  65. anyscale/service/_private/service_sdk.py +121 -24
  66. anyscale/service/commands.py +75 -1
  67. anyscale/service/models.py +46 -2
  68. anyscale/shared_anyscale_utils/latest_ray_version.py +1 -1
  69. anyscale/version.py +1 -1
  70. anyscale/workspace/_private/workspace_sdk.py +1 -0
  71. anyscale/workspace/models.py +19 -0
  72. {anyscale-0.26.70.dist-info → anyscale-0.26.71.dist-info}/METADATA +1 -1
  73. {anyscale-0.26.70.dist-info → anyscale-0.26.71.dist-info}/RECORD +78 -58
  74. {anyscale-0.26.70.dist-info → anyscale-0.26.71.dist-info}/WHEEL +0 -0
  75. {anyscale-0.26.70.dist-info → anyscale-0.26.71.dist-info}/entry_points.txt +0 -0
  76. {anyscale-0.26.70.dist-info → anyscale-0.26.71.dist-info}/licenses/LICENSE +0 -0
  77. {anyscale-0.26.70.dist-info → anyscale-0.26.71.dist-info}/licenses/NOTICE +0 -0
  78. {anyscale-0.26.70.dist-info → anyscale-0.26.71.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,11 @@ from typing_extensions import Literal
11
11
  import yaml
12
12
 
13
13
  from anyscale._private.models.image_uri import ImageURI
14
+ from anyscale.authenticate import get_auth_api_client
14
15
  from anyscale.cli_logger import BlockLogger
16
+ from anyscale.client.openapi_client.models.resource_tag_resource_type import (
17
+ ResourceTagResourceType,
18
+ )
15
19
  from anyscale.commands import command_examples
16
20
  from anyscale.commands.list_util import (
17
21
  display_list,
@@ -21,9 +25,12 @@ from anyscale.commands.list_util import (
21
25
  )
22
26
  from anyscale.commands.util import (
23
27
  AnyscaleCommand,
28
+ build_kv_table,
24
29
  convert_kv_strings_to_dict,
25
30
  DeprecatedAnyscaleCommand,
26
31
  override_env_vars,
32
+ parse_repeatable_tags_to_dict,
33
+ parse_tags_kv_to_str_map,
27
34
  )
28
35
  from anyscale.controllers.service_controller import ServiceController
29
36
  import anyscale.service
@@ -210,6 +217,12 @@ def _read_name_from_config_file(path: str):
210
217
  type=str,
211
218
  help="Defines the traffic and capacity percents per version. Capacity defaults to traffic.",
212
219
  )
220
+ @click.option(
221
+ "--tag",
222
+ "tags",
223
+ multiple=True,
224
+ help="Tag in key=value (or key:value) format. Repeat to add multiple.",
225
+ )
213
226
  def deploy( # noqa: PLR0912, PLR0913 C901
214
227
  config_file: List[str],
215
228
  import_path: Optional[str],
@@ -231,6 +244,7 @@ def deploy( # noqa: PLR0912, PLR0913 C901
231
244
  cloud: Optional[str],
232
245
  project: Optional[str],
233
246
  versions: Optional[str],
247
+ tags: Optional[Tuple[str]],
234
248
  ):
235
249
  """Deploy or update a service.
236
250
 
@@ -347,8 +361,10 @@ def deploy( # noqa: PLR0912, PLR0913 C901
347
361
  configs = config
348
362
  else:
349
363
  # When multiple versions are being deployed.
350
- configs = [ServiceConfig.from_yaml(config) for config in config_file]
351
- for config in configs:
364
+ # configs = [ServiceConfig.from_yaml(config) for config in config_file]
365
+ configs = []
366
+ for config in config_file:
367
+ config = ServiceConfig.from_yaml(config)
352
368
  if name is not None:
353
369
  config = config.options(name=name)
354
370
 
@@ -388,6 +404,13 @@ def deploy( # noqa: PLR0912, PLR0913 C901
388
404
  if py_module:
389
405
  log.warning("--py-module is ignored.")
390
406
 
407
+ configs.append(config)
408
+
409
+ if tags:
410
+ tag_map = parse_tags_kv_to_str_map(tags)
411
+ if tag_map:
412
+ config = config.options(tags=tag_map)
413
+
391
414
  anyscale.service.deploy(
392
415
  configs,
393
416
  in_place=in_place,
@@ -472,6 +495,10 @@ def status(
472
495
  # becomes a common pattern.
473
496
  status_dict.get("primary_version", {}).pop("config", None)
474
497
  status_dict.get("canary_version", {}).pop("config", None)
498
+ # Remove config from all versions in multi-version services
499
+ if status_dict.get("versions"):
500
+ for version in status_dict.get("versions", []):
501
+ version.pop("config", None)
475
502
 
476
503
  console = Console()
477
504
  if json:
@@ -806,6 +833,17 @@ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
806
833
  type=str,
807
834
  help="Named project to use; defaults to your org/workspace project.",
808
835
  )
836
+ @click.option(
837
+ "--tag",
838
+ "tags",
839
+ multiple=True,
840
+ help=(
841
+ "This option can be repeated to filter by multiple tags. "
842
+ "Tags with the same key are ORed, whereas tags with different keys are ANDed. "
843
+ "Example: --tag team:mlops --tag team:infra --tag env:prod. "
844
+ "Filters with team: (mlops OR infra) AND env:prod."
845
+ ),
846
+ )
809
847
  @click.option(
810
848
  "--created-by-me",
811
849
  is_flag=True,
@@ -874,6 +912,7 @@ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
874
912
  def list( # noqa: PLR0913, A001
875
913
  service_id: Optional[str],
876
914
  name: Optional[str],
915
+ tags: List[str],
877
916
  created_by_me: bool,
878
917
  cloud: Optional[str],
879
918
  project: Optional[str],
@@ -910,6 +949,7 @@ def list( # noqa: PLR0913, A001
910
949
  stderr.print("[bold]Listing services with:[/]")
911
950
  stderr.print(f"• name = {name or '<any>'}")
912
951
  stderr.print(f"• states = {', '.join(state_filter) or '<all>'}")
952
+ stderr.print(f"• tags = {', '.join(tags) or '<none>'}")
913
953
  stderr.print(f"• created_by_me = {created_by_me}")
914
954
  stderr.print(f"• include_archived= {include_archived}")
915
955
  stderr.print(f"• sort = {sort or '<none>'}")
@@ -942,6 +982,7 @@ def list( # noqa: PLR0913, A001
942
982
  service_id=service_id,
943
983
  name=name,
944
984
  state_filter=state_filter,
985
+ tags_filter=parse_repeatable_tags_to_dict(tags) if tags else None,
945
986
  creator_id=creator_id,
946
987
  cloud=cloud,
947
988
  project=project,
@@ -972,6 +1013,102 @@ def list( # noqa: PLR0913, A001
972
1013
  sys.exit(1)
973
1014
 
974
1015
 
1016
+ @service_cli.group("tags", help="Manage tags for services.")
1017
+ def service_tags_cli() -> None:
1018
+ pass
1019
+
1020
+
1021
+ @service_tags_cli.command(name="add", help="Add or update tags on a service.")
1022
+ @click.option("--service-id", "--id", help="ID of the service.")
1023
+ @click.option("--name", "-n", help="Name of the service.")
1024
+ @click.option("--cloud", type=str, help="Cloud name (for name resolution).")
1025
+ @click.option("--project", type=str, help="Project name (for name resolution).")
1026
+ @click.option(
1027
+ "--tag",
1028
+ "tags",
1029
+ multiple=True,
1030
+ help="Tag in key=value (or key:value) format. Repeat to add multiple.",
1031
+ )
1032
+ def tags_add(
1033
+ service_id: Optional[str],
1034
+ name: Optional[str],
1035
+ cloud: Optional[str],
1036
+ project: Optional[str],
1037
+ tags: Tuple[str],
1038
+ ) -> None:
1039
+ if not service_id and not name:
1040
+ raise click.ClickException("Provide either --service-id/--id or --name.")
1041
+ tag_map = parse_tags_kv_to_str_map(tags)
1042
+ if not tag_map:
1043
+ raise click.ClickException("Provide at least one --tag key=value.")
1044
+ anyscale.service.add_tags(
1045
+ id=service_id, name=name, cloud=cloud, project=project, tags=tag_map
1046
+ )
1047
+ stderr = Console(stderr=True)
1048
+ ident = service_id or name or "<unknown>"
1049
+ stderr.print(f"Tags updated for service '{ident}'.")
1050
+
1051
+
1052
+ @service_tags_cli.command(name="remove", help="Remove tags by key from a service.")
1053
+ @click.option("--service-id", "--id", help="ID of the service.")
1054
+ @click.option("--name", "-n", help="Name of the service.")
1055
+ @click.option("--cloud", type=str, help="Cloud name (for name resolution).")
1056
+ @click.option("--project", type=str, help="Project name (for name resolution).")
1057
+ @click.option("--key", "keys", multiple=True, help="Tag key to remove. Repeatable.")
1058
+ def tags_remove(
1059
+ service_id: Optional[str],
1060
+ name: Optional[str],
1061
+ cloud: Optional[str],
1062
+ project: Optional[str],
1063
+ keys: Tuple[str],
1064
+ ) -> None:
1065
+ if not service_id and not name:
1066
+ raise click.ClickException("Provide either --service-id/--id or --name.")
1067
+ key_list = [k for k in keys if k and k.strip()]
1068
+ if not key_list:
1069
+ raise click.ClickException("Provide at least one --key to remove.")
1070
+ anyscale.service.remove_tags(
1071
+ id=service_id, name=name, cloud=cloud, project=project, keys=key_list
1072
+ )
1073
+ stderr = Console(stderr=True)
1074
+ ident = service_id or name or "<unknown>"
1075
+ stderr.print(f"Removed tag keys {key_list} from service '{ident}'.")
1076
+
1077
+
1078
+ @service_tags_cli.command(name="list", help="List tags for a service.")
1079
+ @click.option("--service-id", "--id", help="ID of the service.")
1080
+ @click.option("--name", "-n", help="Name of the service.")
1081
+ @click.option("--cloud", type=str, help="Cloud name (for name resolution).")
1082
+ @click.option("--project", type=str, help="Project name (for name resolution).")
1083
+ @click.option("--json", "json_output", is_flag=True, default=False)
1084
+ def tags_list(
1085
+ service_id: Optional[str],
1086
+ name: Optional[str],
1087
+ cloud: Optional[str],
1088
+ project: Optional[str],
1089
+ json_output: bool,
1090
+ ) -> None:
1091
+ if not service_id and not name:
1092
+ raise click.ClickException("Provide either --service-id/--id or --name.")
1093
+ if not service_id:
1094
+ svc: ServiceStatus = anyscale.service.status(name=name, cloud=cloud, project=project) # type: ignore
1095
+ service_id = svc.id
1096
+ auth = get_auth_api_client()
1097
+ resp = auth.api_client.get_tags_for_resource_api_v2_tags_resource_get(
1098
+ ResourceTagResourceType.SERVICE, service_id
1099
+ )
1100
+ tags = getattr(resp.result, "tags", [])
1101
+ if json_output:
1102
+ Console().print_json(json=json_dumps([t.to_dict() for t in tags], indent=2))
1103
+ else:
1104
+ stderr = Console(stderr=True)
1105
+ if not tags:
1106
+ stderr.print("No tags found.")
1107
+ return
1108
+ pairs = [(t.key, t.value) for t in tags]
1109
+ stderr.print(build_kv_table(pairs, title="Tags"))
1110
+
1111
+
975
1112
  # TODO(mowen): Add cloud support for this when we refactor to new SDK method
976
1113
  @service_cli.command(name="rollback", help="Roll back a service.")
977
1114
  @click.option(
anyscale/commands/util.py CHANGED
@@ -1,10 +1,11 @@
1
1
  from copy import deepcopy
2
2
  from datetime import date, datetime
3
3
  import sys
4
- from typing import Dict, Optional, Tuple, TypeVar
4
+ from typing import Dict, Iterable, List, Optional, Tuple, TypeVar
5
5
 
6
6
  import click
7
7
  import colorama
8
+ from rich.table import Table
8
9
 
9
10
  from anyscale._private.workload import WorkloadConfig
10
11
  from anyscale.cli_logger import BlockLogger
@@ -160,6 +161,108 @@ def override_env_vars(config: T, overrides: Dict[str, str]) -> T:
160
161
  return config.options(env_vars=final_env_vars)
161
162
 
162
163
 
164
+ def parse_repeatable_tags_to_dict(strings: Iterable[str]) -> Dict[str, List[str]]:
165
+ """Parse repeatable --tag args into dict[key] -> list[values].
166
+
167
+ Accepts both "key:value" and "key=value". Ignores malformed entries.
168
+ Values for the same key are ORed; different keys are ANDed by the backend.
169
+ """
170
+ result: Dict[str, List[str]] = {}
171
+ for raw in strings or []:
172
+ if ":" in raw:
173
+ key, value = raw.split(":", 1)
174
+ elif "=" in raw:
175
+ key, value = raw.split("=", 1)
176
+ else:
177
+ continue
178
+ key = key.strip()
179
+ value = value.strip()
180
+ if not key or not value:
181
+ continue
182
+ result.setdefault(key, []).append(value)
183
+ return result
184
+
185
+
186
+ def normalize_tags_to_api_list(strings: Iterable[str]) -> List[str]:
187
+ """Normalize repeatable --tag args into API wire format list[str] "key:value".
188
+
189
+ Accepts both "key:value" and "key=value". Ignores malformed entries.
190
+ """
191
+ flattened: List[str] = []
192
+ for raw in strings or []:
193
+ if ":" in raw:
194
+ key, value = raw.split(":", 1)
195
+ elif "=" in raw:
196
+ key, value = raw.split("=", 1)
197
+ else:
198
+ continue
199
+ key = key.strip()
200
+ value = value.strip()
201
+ if not key or not value:
202
+ continue
203
+ flattened.append(f"{key}:{value}")
204
+ return flattened
205
+
206
+
207
+ def flatten_tag_dict_to_api_list(
208
+ tags: Optional[Dict[str, List[str]]]
209
+ ) -> Optional[List[str]]:
210
+ """Flatten dict[key] -> list[values] into list[str] "key:value" for API.
211
+
212
+ Returns None if input is None or empty after normalization.
213
+ """
214
+ if not tags:
215
+ return None
216
+ out: List[str] = []
217
+ for key, values in tags.items():
218
+ if not key:
219
+ continue
220
+ for value in values or []:
221
+ if value:
222
+ out.append(f"{key}:{value}")
223
+ return out if out else None
224
+
225
+
226
+ def parse_tags_kv_to_str_map(pairs: Iterable[str]) -> Dict[str, str]:
227
+ """Parse repeatable key=value (or key:value) into a simple {key: value} map.
228
+
229
+ Last occurrence wins for duplicate keys. Malformed entries are ignored.
230
+ """
231
+ result: Dict[str, str] = {}
232
+ for raw in pairs or []:
233
+ if ":" in raw:
234
+ key, value = raw.split(":", 1)
235
+ elif "=" in raw:
236
+ key, value = raw.split("=", 1)
237
+ else:
238
+ continue
239
+ key = key.strip()
240
+ value = value.strip()
241
+ if not key or not value:
242
+ continue
243
+ result[key] = value
244
+ return result
245
+
246
+
247
+ def build_kv_table(
248
+ pairs: Iterable[Tuple[str, str]], *, title: Optional[str] = None
249
+ ) -> Table:
250
+ """Build a Rich table for key/value pairs.
251
+
252
+ - Sorts rows by key then value for stable output
253
+ - Columns wrap to fit smaller terminals
254
+ """
255
+ table = Table(show_header=True, header_style="bold", expand=False, title=title)
256
+ table.add_column("KEY", overflow="fold")
257
+ table.add_column("VALUE", overflow="fold")
258
+ sorted_pairs = sorted(
259
+ [(str(k), str(v)) for k, v in (pairs or [])], key=lambda kv: (kv[0], kv[1])
260
+ )
261
+ for key, value in sorted_pairs:
262
+ table.add_row(key, value)
263
+ return table
264
+
265
+
163
266
  class DeprecatedAnyscaleCommand(click.Command):
164
267
  """
165
268
  DeprecatedAnyscaleCommand is a subclass of click.Command that shows deprecation warnings.
@@ -1,17 +1,28 @@
1
1
  import json
2
+ from json import dumps as json_dumps
2
3
  import os
3
4
  import sys
4
- from typing import Any, Tuple
5
+ from typing import Any, Optional, Tuple
5
6
 
6
7
  import click
7
8
  import requests
9
+ from rich.console import Console
8
10
 
9
11
  from anyscale.authenticate import get_auth_api_client
10
12
  from anyscale.cli_logger import BlockLogger
11
13
  from anyscale.client.openapi_client.models.execute_interactive_command_options import (
12
14
  ExecuteInteractiveCommandOptions,
13
15
  )
14
- from anyscale.commands.util import DeprecatedAnyscaleCommand, LegacyAnyscaleCommand
16
+ from anyscale.client.openapi_client.models.resource_tag_resource_type import (
17
+ ResourceTagResourceType,
18
+ )
19
+ from anyscale.commands.util import (
20
+ build_kv_table,
21
+ DeprecatedAnyscaleCommand,
22
+ LegacyAnyscaleCommand,
23
+ parse_repeatable_tags_to_dict,
24
+ parse_tags_kv_to_str_map,
25
+ )
15
26
  from anyscale.controllers.cluster_controller import ClusterController
16
27
  from anyscale.controllers.workspace_controller import WorkspaceController
17
28
  from anyscale.project_utils import find_project_root
@@ -373,7 +384,9 @@ def run(command: str, web_terminal: bool, as_job: bool, no_push: bool,) -> None:
373
384
  dir_name = workspace_controller.get_workspace_dir_name()
374
385
  if web_terminal:
375
386
  cluster_id = workspace_controller.get_activated_workspace().cluster_id
376
- _execute_shell_command(cluster_id, f"cd ~/{dir_name} && {command}")
387
+ if not cluster_id:
388
+ raise click.ClickException("Workspace is not running; no active cluster.")
389
+ _execute_shell_command(str(cluster_id), f"cd ~/{dir_name} && {command}")
377
390
  # TODO(ekl) show the workspace URL here and also block on completion.
378
391
  print()
379
392
  print(
@@ -412,9 +425,114 @@ def ssh(args: Tuple[str]) -> None:
412
425
  @workspace_cli.command(
413
426
  name="list", help="prints information about existing workspaces", hidden=True,
414
427
  )
415
- def list_command() -> None:
428
+ @click.option(
429
+ "--tag",
430
+ "tags",
431
+ multiple=True,
432
+ help=(
433
+ "This option can be repeated to filter by multiple tags. "
434
+ "Tags with the same key are ORed, whereas tags with different keys are ANDed. "
435
+ "Example: --tag team:mlops --tag team:infra --tag env:prod. "
436
+ "Filters with team: (mlops OR infra) AND env:prod."
437
+ ),
438
+ )
439
+ def list_command(tags: Tuple[str]) -> None:
416
440
  workspace_controller = WorkspaceController()
417
- workspace_controller.list()
441
+ workspace_controller.list(
442
+ tags_filter=parse_repeatable_tags_to_dict(tags) if tags else None
443
+ )
444
+
445
+
446
+ @workspace_cli.group("tags", help="Manage tags for workspaces.")
447
+ def workspace_tags_cli() -> None:
448
+ pass
449
+
450
+
451
+ @workspace_tags_cli.command(name="add", help="Add or update tags on a workspace.")
452
+ @click.option(
453
+ "--id", "workspace_id", required=False, help="Unique ID of the workspace."
454
+ )
455
+ @click.option("--name", "-n", required=False, help="Name of the workspace.")
456
+ @click.option(
457
+ "--tag",
458
+ "tags",
459
+ multiple=True,
460
+ help="Tag in key=value (or key:value) format. Repeat to add multiple.",
461
+ )
462
+ def workspace_tags_add(
463
+ workspace_id: Optional[str], name: Optional[str], tags: Tuple[str]
464
+ ) -> None:
465
+ if not workspace_id and not name:
466
+ raise click.ClickException("Provide either --id or --name.")
467
+ tag_map = parse_tags_kv_to_str_map(tags)
468
+ if not tag_map:
469
+ raise click.ClickException("Provide at least one --tag key=value.")
470
+ WorkspaceController().add_tags(workspace_id=workspace_id, name=name, tags=tag_map)
471
+ stderr = Console(stderr=True)
472
+ ident = workspace_id or name or "<unknown>"
473
+ stderr.print(f"Tags updated for workspace '{ident}'.")
474
+
475
+
476
+ @workspace_tags_cli.command(name="remove", help="Remove tags by key from a workspace.")
477
+ @click.option(
478
+ "--id", "workspace_id", required=False, help="Unique ID of the workspace."
479
+ )
480
+ @click.option("--name", "-n", required=False, help="Name of the workspace.")
481
+ @click.option("--key", "keys", multiple=True, help="Tag key to remove. Repeatable.")
482
+ def workspace_tags_remove(
483
+ workspace_id: Optional[str], name: Optional[str], keys: Tuple[str]
484
+ ) -> None:
485
+ if not workspace_id and not name:
486
+ raise click.ClickException("Provide either --id or --name.")
487
+ key_list = [k for k in keys if k and k.strip()]
488
+ if not key_list:
489
+ raise click.ClickException("Provide at least one --key to remove.")
490
+ WorkspaceController().remove_tags(
491
+ workspace_id=workspace_id, name=name, keys=key_list
492
+ )
493
+ stderr = Console(stderr=True)
494
+ ident = workspace_id or name or "<unknown>"
495
+ stderr.print(f"Removed tag keys {key_list} from workspace '{ident}'.")
496
+
497
+
498
+ @workspace_tags_cli.command(name="list", help="List tags for a workspace.")
499
+ @click.option(
500
+ "--id", "workspace_id", required=False, help="Unique ID of the workspace."
501
+ )
502
+ @click.option("--name", "-n", required=False, help="Name of the workspace.")
503
+ @click.option("--json", "json_output", is_flag=True, default=False)
504
+ def workspace_tags_list(
505
+ workspace_id: Optional[str], name: Optional[str], json_output: bool
506
+ ) -> None:
507
+ if not workspace_id and not name:
508
+ raise click.ClickException("Provide either --id or --name.")
509
+ if not workspace_id:
510
+ # Resolve via name
511
+ auth = get_auth_api_client()
512
+ results = auth.api_client.list_workspaces_api_v2_experimental_workspaces_get(
513
+ name=name
514
+ ).results
515
+ if len(results) == 0:
516
+ raise click.ClickException(f"No workspace with name '{name}' found.")
517
+ if len(results) > 1:
518
+ raise click.ClickException(
519
+ f"Multiple workspaces with name '{name}' found. Please use --id."
520
+ )
521
+ workspace_id = results[0].id
522
+ auth = get_auth_api_client()
523
+ resp = auth.api_client.get_tags_for_resource_api_v2_tags_resource_get(
524
+ ResourceTagResourceType.WORKSPACE, workspace_id
525
+ )
526
+ tags = getattr(resp.result, "tags", [])
527
+ if json_output:
528
+ Console().print_json(json=json_dumps([t.to_dict() for t in tags], indent=2))
529
+ else:
530
+ stderr = Console(stderr=True)
531
+ if not tags:
532
+ stderr.print("No tags found.")
533
+ return
534
+ pairs = [(t.key, t.value) for t in tags]
535
+ stderr.print(build_kv_table(pairs, title="Tags"))
418
536
 
419
537
 
420
538
  # TODO(vigneshka): Migrate to v2 if there is usage, then deprecate
@@ -18,7 +18,11 @@ from anyscale._private.models.image_uri import ImageURI
18
18
  from anyscale._private.sdk import _LAZY_SDK_SINGLETONS
19
19
  from anyscale.cli_logger import BlockLogger
20
20
  from anyscale.commands import command_examples
21
- from anyscale.commands.util import AnyscaleCommand, convert_kv_strings_to_dict
21
+ from anyscale.commands.util import (
22
+ AnyscaleCommand,
23
+ convert_kv_strings_to_dict,
24
+ parse_tags_kv_to_str_map,
25
+ )
22
26
  import anyscale.workspace
23
27
  from anyscale.workspace._private.workspace_sdk import ANYSCALE_WORKSPACES_SSH_OPTIONS
24
28
  from anyscale.workspace.commands import _WORKSPACE_SDK_SINGLETON_KEY
@@ -491,6 +495,12 @@ def workspace_cli() -> None:
491
495
  type=str,
492
496
  help="Environment variables to set for the workspace. The format is 'key=value'. This argument can be specified multiple times. When the same key is also specified in the config file, the value from the command-line flag will overwrite the value from the config file.",
493
497
  )
498
+ @click.option(
499
+ "--tag",
500
+ "tags",
501
+ multiple=True,
502
+ help="Tag in key=value (or key:value) format. Repeat to add multiple.",
503
+ )
494
504
  def create( # noqa: PLR0913, PLR0912, C901
495
505
  config_file: Optional[str],
496
506
  name: Optional[str],
@@ -503,6 +513,7 @@ def create( # noqa: PLR0913, PLR0912, C901
503
513
  project: Optional[str],
504
514
  requirements: Optional[str],
505
515
  env: Optional[Tuple[str]],
516
+ tags: Optional[Tuple[str]],
506
517
  ) -> None:
507
518
  """Creates a new workspace.
508
519
 
@@ -576,6 +587,11 @@ def create( # noqa: PLR0913, PLR0912, C901
576
587
  if env_dict:
577
588
  config = config.options(env_vars=env_dict)
578
589
 
590
+ if tags:
591
+ tag_map = parse_tags_kv_to_str_map(tags)
592
+ if tag_map:
593
+ config = config.options(tags=tag_map)
594
+
579
595
  anyscale.workspace.create(config,)
580
596
 
581
597
 
@@ -53,12 +53,21 @@ class PrivateComputeConfigSDK(BaseSDK):
53
53
  config: Union[None, Dict, HeadNodeConfig],
54
54
  *,
55
55
  cloud: Cloud,
56
+ cloud_resource_name: Optional[str] = None,
56
57
  schedulable_by_default: bool,
57
58
  ) -> ComputeNodeType:
58
59
  if config is None:
59
- # If no head node config is provided, use the cloud default.
60
+ cloud_resource_id = None
61
+ if cloud_resource_name:
62
+ cloud_resource = self._client.get_cloud_resource_by_name(
63
+ cloud_id=cloud.id, cloud_resource_name=cloud_resource_name
64
+ )
65
+ if cloud_resource:
66
+ cloud_resource_id = cloud_resource.cloud_resource_id
67
+
68
+ # If no head node config is provided, use the cloud/cloud resource default.
60
69
  default: ClusterComputeConfig = self._client.get_default_compute_config(
61
- cloud_id=cloud.id
70
+ cloud_id=cloud.id, cloud_resource_id=cloud_resource_id,
62
71
  ).config
63
72
 
64
73
  api_model = ComputeNodeType(
@@ -125,7 +134,7 @@ class PrivateComputeConfigSDK(BaseSDK):
125
134
  return api_models
126
135
 
127
136
  def _convert_single_deployment_compute_config_to_api_model(
128
- self, compute_config: ComputeConfig
137
+ self, compute_config: ComputeConfig, base_flags: Optional[Dict[str, Any]] = None
129
138
  ) -> CloudDeploymentComputeConfig:
130
139
  # We should only make the head node schedulable when it's the *only* node in the cluster.
131
140
  # `worker_nodes=None` uses the default serverless config, so this only happens if `worker_nodes`
@@ -139,9 +148,10 @@ class PrivateComputeConfigSDK(BaseSDK):
139
148
  "This should never happen; please reach out to Anyscale support."
140
149
  )
141
150
 
142
- flags: Dict[str, Any] = deepcopy(
143
- compute_config.flags
144
- ) if compute_config.flags else {}
151
+ # Merge the MultiResourceComputeConfig flags (which apply to all cloud resources) with the individual compute config flags.
152
+ # The individual compute config flags will override the base flags.
153
+ flags = deepcopy(base_flags) if base_flags else {}
154
+ flags.update(compute_config.flags or {})
145
155
  flags["allow-cross-zone-autoscaling"] = compute_config.enable_cross_zone_scaling
146
156
 
147
157
  if compute_config.min_resources:
@@ -155,6 +165,7 @@ class PrivateComputeConfigSDK(BaseSDK):
155
165
  head_node_type=self._convert_head_node_config_to_api_model(
156
166
  compute_config.head_node,
157
167
  cloud=cloud,
168
+ cloud_resource_name=compute_config.cloud_resource,
158
169
  schedulable_by_default=(
159
170
  not compute_config.worker_nodes
160
171
  and not compute_config.auto_select_worker_config
@@ -240,7 +251,9 @@ class PrivateComputeConfigSDK(BaseSDK):
240
251
  for config in compute_config.configs:
241
252
  assert isinstance(config, ComputeConfig)
242
253
  deployment_configs.append(
243
- self._convert_single_deployment_compute_config_to_api_model(config)
254
+ self._convert_single_deployment_compute_config_to_api_model(
255
+ config, compute_config.flags
256
+ )
244
257
  )
245
258
  default_config = deployment_configs[0]
246
259
 
@@ -261,11 +274,10 @@ class PrivateComputeConfigSDK(BaseSDK):
261
274
  )
262
275
  self.logger.info(f"Created compute config: '{full_name}'")
263
276
 
264
- # TODO(janet): add this back after the UI has been updated to support multi-deployment compute configs.
265
- # ui_url = self.client.get_compute_config_ui_url(
266
- # compute_config_id, cloud_id=cloud_id
267
- # )
268
- # self.logger.info(f"View the compute config in the UI: '{ui_url}'")
277
+ ui_url = self.client.get_compute_config_ui_url(
278
+ compute_config_id, cloud_id=cloud_id
279
+ )
280
+ self.logger.info(f"View the compute config in the UI: '{ui_url}'")
269
281
 
270
282
  return full_name, compute_config_id
271
283
 
@@ -428,6 +440,7 @@ class PrivateComputeConfigSDK(BaseSDK):
428
440
  min_resources=min_resources,
429
441
  max_resources=max_resources or None,
430
442
  flags=flags,
443
+ auto_select_worker_config=api_model.auto_select_worker_config or False,
431
444
  )
432
445
 
433
446
  def _convert_api_model_to_compute_config_version(
@@ -742,6 +742,21 @@ configs:
742
742
 
743
743
  return config_models
744
744
 
745
+ flags: Optional[Dict[str, Any]] = field(
746
+ default=None,
747
+ repr=False,
748
+ metadata={
749
+ "docstring": "Flags specifying advanced or experimental options that should be applied to all cloud resources. Flags specified in the individual compute configurations will override these flags.",
750
+ },
751
+ )
752
+
753
+ def _validate_flags(self, flags: Optional[Dict[str, Any]]):
754
+ if flags is None:
755
+ return
756
+
757
+ if not isinstance(flags, dict):
758
+ raise TypeError("'flags' must be a dict")
759
+
745
760
 
746
761
  ComputeConfigType = Union[ComputeConfig, MultiResourceComputeConfig]
747
762