anyscale 0.26.69__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 (113) hide show
  1. anyscale/_private/anyscale_client/anyscale_client.py +126 -3
  2. anyscale/_private/anyscale_client/common.py +51 -2
  3. anyscale/_private/anyscale_client/fake_anyscale_client.py +103 -11
  4. anyscale/client/README.md +43 -4
  5. anyscale/client/openapi_client/__init__.py +30 -4
  6. anyscale/client/openapi_client/api/default_api.py +1769 -27
  7. anyscale/client/openapi_client/models/__init__.py +30 -4
  8. anyscale/client/openapi_client/models/api_key_info.py +29 -3
  9. anyscale/client/openapi_client/models/apply_autoscaling_config_update_model.py +350 -0
  10. anyscale/client/openapi_client/models/apply_multi_version_update_weights_update_model.py +152 -0
  11. anyscale/client/openapi_client/models/apply_production_service_multi_version_v2_model.py +207 -0
  12. anyscale/client/openapi_client/models/apply_production_service_v2_model.py +31 -3
  13. anyscale/client/openapi_client/models/apply_version_weight_update_model.py +181 -0
  14. anyscale/client/openapi_client/models/backend_server_api_product_models_catalog_client_models_table_metadata.py +546 -0
  15. anyscale/client/openapi_client/models/backend_server_api_product_models_data_catalogs_table_metadata.py +178 -0
  16. anyscale/client/openapi_client/models/baseimagesenum.py +139 -1
  17. anyscale/client/openapi_client/models/catalog_metadata.py +150 -0
  18. anyscale/client/openapi_client/models/cloud_data_bucket_file_type.py +2 -1
  19. anyscale/client/openapi_client/models/{oauthconnectionresponse_response.py → clouddeployment_response.py} +11 -11
  20. anyscale/client/openapi_client/models/column_info.py +265 -0
  21. anyscale/client/openapi_client/models/compute_node_type.py +29 -1
  22. anyscale/client/openapi_client/models/connection_metadata.py +206 -0
  23. anyscale/client/openapi_client/models/create_experimental_workspace.py +29 -1
  24. anyscale/client/openapi_client/models/create_workspace_from_template.py +29 -1
  25. anyscale/client/openapi_client/models/create_workspace_template_version.py +59 -3
  26. anyscale/client/openapi_client/models/data_catalog.py +45 -31
  27. anyscale/client/openapi_client/models/data_catalog_connection.py +74 -58
  28. anyscale/client/openapi_client/models/{ha_job_event_level.py → data_catalog_object_type.py} +7 -8
  29. anyscale/client/openapi_client/models/data_catalog_schema.py +324 -0
  30. anyscale/client/openapi_client/models/data_catalog_table.py +437 -0
  31. anyscale/client/openapi_client/models/data_catalog_volume.py +437 -0
  32. anyscale/client/openapi_client/models/datacatalogschema_list_response.py +147 -0
  33. anyscale/client/openapi_client/models/datacatalogtable_list_response.py +147 -0
  34. anyscale/client/openapi_client/models/datacatalogvolume_list_response.py +147 -0
  35. anyscale/client/openapi_client/models/decorated_list_service_api_model.py +58 -1
  36. anyscale/client/openapi_client/models/decorated_production_service_v2_api_model.py +60 -3
  37. anyscale/client/openapi_client/models/decorated_serve_deployment.py +27 -1
  38. anyscale/client/openapi_client/models/decorated_service_event_api_model.py +3 -3
  39. anyscale/client/openapi_client/models/decoratedproductionservicev2_versionapimodel_response.py +121 -0
  40. anyscale/client/openapi_client/models/describe_machine_pool_machines_filters.py +33 -5
  41. anyscale/client/openapi_client/models/describe_machine_pool_requests_filters.py +33 -5
  42. anyscale/client/openapi_client/models/describe_machine_pool_workloads_filters.py +33 -5
  43. anyscale/client/openapi_client/models/{service_event_level.py → entity_type.py} +9 -9
  44. anyscale/client/openapi_client/models/event_level.py +2 -1
  45. anyscale/client/openapi_client/models/job_event_fields.py +206 -0
  46. anyscale/client/openapi_client/models/machine_type_partition_filter.py +152 -0
  47. anyscale/client/openapi_client/models/partition_info.py +30 -1
  48. anyscale/client/openapi_client/models/physical_resources.py +178 -0
  49. anyscale/client/openapi_client/models/production_job_event.py +3 -3
  50. anyscale/client/openapi_client/models/rollout_strategy.py +2 -1
  51. anyscale/client/openapi_client/models/schema_metadata.py +150 -0
  52. anyscale/client/openapi_client/models/service_event_fields.py +318 -0
  53. anyscale/client/openapi_client/models/sso_config.py +18 -18
  54. anyscale/client/openapi_client/models/supportedbaseimagesenum.py +139 -1
  55. anyscale/client/openapi_client/models/table_data_preview.py +209 -0
  56. anyscale/client/openapi_client/models/task_summary_config.py +29 -3
  57. anyscale/client/openapi_client/models/task_table_config.py +29 -3
  58. anyscale/client/openapi_client/models/unified_event.py +377 -0
  59. anyscale/client/openapi_client/models/unified_origin_filter.py +113 -0
  60. anyscale/client/openapi_client/models/unifiedevent_list_response.py +147 -0
  61. anyscale/client/openapi_client/models/volume_metadata.py +150 -0
  62. anyscale/client/openapi_client/models/worker_node_type.py +29 -1
  63. anyscale/client/openapi_client/models/workspace_event_fields.py +122 -0
  64. anyscale/client/openapi_client/models/workspace_template_version.py +58 -1
  65. anyscale/client/openapi_client/models/workspace_template_version_data_object.py +58 -1
  66. anyscale/cloud/models.py +2 -2
  67. anyscale/commands/cloud_commands.py +133 -2
  68. anyscale/commands/job_commands.py +121 -1
  69. anyscale/commands/job_queue_commands.py +99 -2
  70. anyscale/commands/service_commands.py +267 -67
  71. anyscale/commands/setup_k8s.py +546 -31
  72. anyscale/commands/util.py +104 -1
  73. anyscale/commands/workspace_commands.py +123 -5
  74. anyscale/commands/workspace_commands_v2.py +17 -1
  75. anyscale/compute_config/_private/compute_config_sdk.py +25 -12
  76. anyscale/compute_config/models.py +15 -0
  77. anyscale/controllers/cloud_controller.py +15 -2
  78. anyscale/controllers/job_controller.py +12 -0
  79. anyscale/controllers/kubernetes_verifier.py +80 -66
  80. anyscale/controllers/workspace_controller.py +67 -5
  81. anyscale/job/_private/job_sdk.py +50 -2
  82. anyscale/job/commands.py +3 -0
  83. anyscale/job/models.py +16 -0
  84. anyscale/job_queue/__init__.py +37 -1
  85. anyscale/job_queue/_private/job_queue_sdk.py +28 -1
  86. anyscale/job_queue/commands.py +61 -1
  87. anyscale/sdk/anyscale_client/__init__.py +1 -0
  88. anyscale/sdk/anyscale_client/api/default_api.py +12 -2
  89. anyscale/sdk/anyscale_client/models/__init__.py +1 -0
  90. anyscale/sdk/anyscale_client/models/apply_production_service_v2_model.py +31 -3
  91. anyscale/sdk/anyscale_client/models/apply_service_model.py +31 -3
  92. anyscale/sdk/anyscale_client/models/baseimagesenum.py +139 -1
  93. anyscale/sdk/anyscale_client/models/compute_node_type.py +29 -1
  94. anyscale/sdk/anyscale_client/models/physical_resources.py +178 -0
  95. anyscale/sdk/anyscale_client/models/rollout_strategy.py +2 -1
  96. anyscale/sdk/anyscale_client/models/supportedbaseimagesenum.py +139 -1
  97. anyscale/sdk/anyscale_client/models/worker_node_type.py +29 -1
  98. anyscale/service/__init__.py +51 -3
  99. anyscale/service/_private/service_sdk.py +481 -58
  100. anyscale/service/commands.py +90 -4
  101. anyscale/service/models.py +56 -0
  102. anyscale/shared_anyscale_utils/latest_ray_version.py +1 -1
  103. anyscale/version.py +1 -1
  104. anyscale/workspace/_private/workspace_sdk.py +1 -0
  105. anyscale/workspace/models.py +19 -0
  106. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/METADATA +1 -1
  107. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/RECORD +112 -85
  108. anyscale/client/openapi_client/models/o_auth_connection_response.py +0 -229
  109. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/WHEEL +0 -0
  110. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/entry_points.txt +0 -0
  111. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/licenses/LICENSE +0 -0
  112. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/licenses/NOTICE +0 -0
  113. {anyscale-0.26.69.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
@@ -79,8 +86,9 @@ def _read_name_from_config_file(path: str):
79
86
  "-f",
80
87
  "--config-file",
81
88
  required=False,
82
- default=None,
89
+ default=[],
83
90
  type=str,
91
+ multiple=True,
84
92
  help="Path to a YAML config file to deploy. When deploying from a file, import path and arguments cannot be provided. Command-line flags will overwrite values read from the file.",
85
93
  )
86
94
  @click.option(
@@ -202,8 +210,21 @@ def _read_name_from_config_file(path: str):
202
210
  type=str,
203
211
  help="Named project to use for the service. If not provided, the default project for the cloud will be used (or, if running in a workspace, the project of the workspace).",
204
212
  )
213
+ @click.option(
214
+ "--versions",
215
+ required=False,
216
+ default=None,
217
+ type=str,
218
+ help="Defines the traffic and capacity percents per version. Capacity defaults to traffic.",
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
+ )
205
226
  def deploy( # noqa: PLR0912, PLR0913 C901
206
- config_file: Optional[str],
227
+ config_file: List[str],
207
228
  import_path: Optional[str],
208
229
  arguments: Tuple[str],
209
230
  name: Optional[str],
@@ -222,6 +243,8 @@ def deploy( # noqa: PLR0912, PLR0913 C901
222
243
  py_module: Tuple[str],
223
244
  cloud: Optional[str],
224
245
  project: Optional[str],
246
+ versions: Optional[str],
247
+ tags: Optional[Tuple[str]],
225
248
  ):
226
249
  """Deploy or update a service.
227
250
 
@@ -238,102 +261,165 @@ def deploy( # noqa: PLR0912, PLR0913 C901
238
261
  Command-line flags override values in the config file.
239
262
  """
240
263
 
241
- if config_file is not None:
242
- if import_path is not None or len(arguments) > 0:
264
+ if versions is None:
265
+ if len(config_file) == 1:
266
+ if import_path is not None or len(arguments) > 0:
267
+ raise click.ClickException(
268
+ "When a config file is provided, import path and application arguments can't be."
269
+ )
270
+
271
+ if not pathlib.Path(config_file[0]).is_file():
272
+ raise click.ClickException(f"Config file '{config_file[0]}' not found.")
273
+
274
+ config = ServiceConfig.from_yaml(config_file[0])
275
+ elif len(config_file) > 1:
243
276
  raise click.ClickException(
244
- "When a config file is provided, import path and application arguments can't be."
277
+ "Multiple config files can be provided only when deploying multiple versions with --versions."
245
278
  )
279
+ else:
280
+ # when config_file is not provided.
281
+ if import_path is None:
282
+ raise click.ClickException(
283
+ "Either config file or import path must be provided."
284
+ )
246
285
 
247
- if not pathlib.Path(config_file).is_file():
248
- raise click.ClickException(f"Config file '{config_file}' not found.")
286
+ if (
287
+ import_path.endswith((".yaml", ".yml"))
288
+ or pathlib.Path(import_path).is_file()
289
+ ):
290
+ log.warning(
291
+ f"The provided import path '{import_path}' looks like a config file. Did you mean to use '-f config.yaml'?"
292
+ )
249
293
 
250
- config = ServiceConfig.from_yaml(config_file)
251
- else:
252
- if import_path is None:
294
+ app: Dict[str, Any] = {"import_path": import_path}
295
+ arguments_dict = convert_kv_strings_to_dict(arguments)
296
+ if arguments_dict:
297
+ app["args"] = arguments_dict
298
+
299
+ config = ServiceConfig(applications=[app])
300
+
301
+ if containerfile and image_uri:
253
302
  raise click.ClickException(
254
- "Either config file or import path must be provided."
303
+ "Only one of '--containerfile' and '--image-uri' can be provided."
255
304
  )
256
305
 
257
- if (
258
- import_path.endswith((".yaml", ".yml"))
259
- or pathlib.Path(import_path).is_file()
306
+ if ray_version and (not image_uri and not containerfile):
307
+ raise click.ClickException(
308
+ "Ray version can only be used with an image or containerfile.",
309
+ )
310
+
311
+ if registry_login_secret and (
312
+ not image_uri or ImageURI.from_str(image_uri).is_cluster_env_image()
260
313
  ):
261
- log.warning(
262
- f"The provided import path '{import_path}' looks like a config file. Did you mean to use '-f config.yaml'?"
314
+ raise click.ClickException(
315
+ "Registry login secret can only be used with an image that is not hosted on Anyscale."
263
316
  )
264
317
 
265
- app: Dict[str, Any] = {"import_path": import_path}
266
- arguments_dict = convert_kv_strings_to_dict(arguments)
267
- if arguments_dict:
268
- app["args"] = arguments_dict
318
+ if name is not None:
319
+ config = config.options(name=name)
269
320
 
270
- config = ServiceConfig(applications=[app])
321
+ if image_uri is not None:
322
+ config = config.options(image_uri=image_uri)
271
323
 
272
- if containerfile and image_uri:
273
- raise click.ClickException(
274
- "Only one of '--containerfile' and '--image-uri' can be provided."
275
- )
324
+ if registry_login_secret is not None:
325
+ config = config.options(registry_login_secret=registry_login_secret)
276
326
 
277
- if ray_version and (not image_uri and not containerfile):
278
- raise click.ClickException(
279
- "Ray version can only be used with an image or containerfile.",
280
- )
327
+ if ray_version is not None:
328
+ config = config.options(ray_version=ray_version)
281
329
 
282
- if registry_login_secret and (
283
- not image_uri or ImageURI.from_str(image_uri).is_cluster_env_image()
284
- ):
285
- raise click.ClickException(
286
- "Registry login secret can only be used with an image that is not hosted on Anyscale."
287
- )
330
+ if containerfile is not None:
331
+ config = config.options(containerfile=containerfile)
288
332
 
289
- if name is not None:
290
- config = config.options(name=name)
333
+ if compute_config is not None:
334
+ config = config.options(compute_config=compute_config)
291
335
 
292
- if image_uri is not None:
293
- config = config.options(image_uri=image_uri)
336
+ if working_dir is not None:
337
+ config = config.options(working_dir=working_dir)
294
338
 
295
- if registry_login_secret is not None:
296
- config = config.options(registry_login_secret=registry_login_secret)
339
+ if exclude:
340
+ config = config.options(excludes=[e for e in exclude])
297
341
 
298
- if ray_version is not None:
299
- config = config.options(ray_version=ray_version)
342
+ if requirements is not None:
343
+ config = config.options(requirements=requirements)
300
344
 
301
- if containerfile is not None:
302
- config = config.options(containerfile=containerfile)
345
+ if env:
346
+ config = override_env_vars(config, convert_kv_strings_to_dict(env))
303
347
 
304
- if compute_config is not None:
305
- config = config.options(compute_config=compute_config)
348
+ if py_module:
349
+ for module in py_module:
350
+ if not pathlib.Path(module).is_dir():
351
+ raise click.ClickException(
352
+ f"Python module path '{module}' does not exist or is not a directory."
353
+ )
354
+ config = config.options(py_modules=[*py_module])
306
355
 
307
- if working_dir is not None:
308
- config = config.options(working_dir=working_dir)
356
+ if cloud is not None:
357
+ config = config.options(cloud=cloud)
358
+ if project is not None:
359
+ config = config.options(project=project)
309
360
 
310
- if exclude:
311
- config = config.options(excludes=[e for e in exclude])
361
+ configs = config
362
+ else:
363
+ # When multiple versions are being deployed.
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)
368
+ if name is not None:
369
+ config = config.options(name=name)
312
370
 
313
- if requirements is not None:
314
- config = config.options(requirements=requirements)
371
+ if cloud is not None:
372
+ config = config.options(cloud=cloud)
315
373
 
316
- if env:
317
- config = override_env_vars(config, convert_kv_strings_to_dict(env))
374
+ if project is not None:
375
+ config = config.options(project=project)
318
376
 
319
- if py_module:
320
- for module in py_module:
321
- if not pathlib.Path(module).is_dir():
322
- raise click.ClickException(
323
- f"Python module path '{module}' does not exist or is not a directory."
324
- )
325
- config = config.options(py_modules=[*py_module])
377
+ if image_uri is not None:
378
+ log.warning("--image-uri is ignored.")
379
+
380
+ if registry_login_secret is not None:
381
+ log.warning("--registry-login-secret is ignored.")
382
+
383
+ if ray_version is not None:
384
+ log.warning("--ray-version is ignored.")
385
+
386
+ if containerfile is not None:
387
+ log.warning("--containerfile is ignored.")
388
+
389
+ if compute_config is not None:
390
+ log.warning("--compute-config is ignored.")
391
+
392
+ if working_dir is not None:
393
+ log.warning("--working-dir is ignored.")
394
+
395
+ if exclude:
396
+ log.warning("--exclude is ignored.")
397
+
398
+ if requirements is not None:
399
+ log.warning("--requirements is ignored.")
400
+
401
+ if env:
402
+ log.warning("--env is ignored.")
326
403
 
327
- if cloud is not None:
328
- config = config.options(cloud=cloud)
329
- if project is not None:
330
- config = config.options(project=project)
404
+ if py_module:
405
+ log.warning("--py-module is ignored.")
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)
331
413
 
332
414
  anyscale.service.deploy(
333
- config,
415
+ configs,
334
416
  in_place=in_place,
335
417
  canary_percent=canary_percent,
336
418
  max_surge_percent=max_surge_percent,
419
+ versions=versions,
420
+ name=name,
421
+ cloud=cloud,
422
+ project=project,
337
423
  )
338
424
 
339
425
 
@@ -409,6 +495,10 @@ def status(
409
495
  # becomes a common pattern.
410
496
  status_dict.get("primary_version", {}).pop("config", None)
411
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)
412
502
 
413
503
  console = Console()
414
504
  if json:
@@ -743,6 +833,17 @@ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
743
833
  type=str,
744
834
  help="Named project to use; defaults to your org/workspace project.",
745
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
+ )
746
847
  @click.option(
747
848
  "--created-by-me",
748
849
  is_flag=True,
@@ -811,6 +912,7 @@ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
811
912
  def list( # noqa: PLR0913, A001
812
913
  service_id: Optional[str],
813
914
  name: Optional[str],
915
+ tags: List[str],
814
916
  created_by_me: bool,
815
917
  cloud: Optional[str],
816
918
  project: Optional[str],
@@ -847,6 +949,7 @@ def list( # noqa: PLR0913, A001
847
949
  stderr.print("[bold]Listing services with:[/]")
848
950
  stderr.print(f"• name = {name or '<any>'}")
849
951
  stderr.print(f"• states = {', '.join(state_filter) or '<all>'}")
952
+ stderr.print(f"• tags = {', '.join(tags) or '<none>'}")
850
953
  stderr.print(f"• created_by_me = {created_by_me}")
851
954
  stderr.print(f"• include_archived= {include_archived}")
852
955
  stderr.print(f"• sort = {sort or '<none>'}")
@@ -879,6 +982,7 @@ def list( # noqa: PLR0913, A001
879
982
  service_id=service_id,
880
983
  name=name,
881
984
  state_filter=state_filter,
985
+ tags_filter=parse_repeatable_tags_to_dict(tags) if tags else None,
882
986
  creator_id=creator_id,
883
987
  cloud=cloud,
884
988
  project=project,
@@ -909,6 +1013,102 @@ def list( # noqa: PLR0913, A001
909
1013
  sys.exit(1)
910
1014
 
911
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
+
912
1112
  # TODO(mowen): Add cloud support for this when we refactor to new SDK method
913
1113
  @service_cli.command(name="rollback", help="Roll back a service.")
914
1114
  @click.option(