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.
anyscale/commands/util.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from copy import deepcopy
2
- from typing import Dict, Tuple
2
+ from typing import Dict, Tuple, TypeVar
3
3
 
4
4
  import click
5
5
 
@@ -136,9 +136,10 @@ def convert_kv_strings_to_dict(strings: Tuple[str]) -> Dict[str, str]:
136
136
  return ret_dict
137
137
 
138
138
 
139
- def override_env_vars(
140
- config: WorkloadConfig, overrides: Dict[str, str]
141
- ) -> WorkloadConfig:
139
+ T = TypeVar("T", bound=WorkloadConfig)
140
+
141
+
142
+ def override_env_vars(config: T, overrides: Dict[str, str]) -> T:
142
143
  """Returns a new copy of the WorkloadConfig with env vars overridden.
143
144
 
144
145
  This is a per-key override, so keys already in the config that are not specified in
@@ -2,7 +2,6 @@ import os
2
2
  from typing import Any, Dict, List, Optional
3
3
 
4
4
  import click
5
- import tabulate
6
5
  import yaml
7
6
 
8
7
  from anyscale import AnyscaleSDK
@@ -14,9 +13,6 @@ from anyscale.client.openapi_client.api.default_api import DefaultApi as Interna
14
13
  from anyscale.client.openapi_client.models.decorated_list_service_api_model import (
15
14
  DecoratedListServiceAPIModel,
16
15
  )
17
- from anyscale.client.openapi_client.models.decorated_production_service_v2_api_model import (
18
- DecoratedProductionServiceV2APIModel,
19
- )
20
16
  from anyscale.controllers.base_controller import BaseController
21
17
  from anyscale.models.service_model import ServiceConfig
22
18
  from anyscale.project_utils import infer_project_id
@@ -81,6 +77,12 @@ class ServiceController(BaseController):
81
77
  max_surge_percent=config.max_surge_percent,
82
78
  )
83
79
 
80
+ def get_authenticated_user_id(self) -> str:
81
+ user_info_response = (
82
+ self.internal_api_client.get_user_info_api_v2_userinfo_get()
83
+ )
84
+ return user_info_response.result.id
85
+
84
86
  def _get_services_by_name(
85
87
  self,
86
88
  *,
@@ -93,12 +95,7 @@ class ServiceController(BaseController):
93
95
 
94
96
  Note that "name" is an *exact match* (different from the REST API semantics).
95
97
  """
96
- creator_id = None
97
- if created_by_me:
98
- user_info_response = (
99
- self.internal_api_client.get_user_info_api_v2_userinfo_get()
100
- )
101
- creator_id = user_info_response.result.id
98
+ creator_id = self.get_authenticated_user_id() if created_by_me else None
102
99
 
103
100
  paging_token = None
104
101
  services_list: List[DecoratedListServiceAPIModel] = []
@@ -364,75 +361,6 @@ class ServiceController(BaseController):
364
361
  log=self.log,
365
362
  )
366
363
 
367
- def list(
368
- self,
369
- *,
370
- name: Optional[str] = None,
371
- service_id: Optional[str] = None,
372
- project_id: Optional[str] = None,
373
- created_by_me: bool = False,
374
- max_items: int = 10,
375
- ) -> None:
376
- self._list_via_service_list_endpoint(
377
- name=name,
378
- service_id=service_id,
379
- project_id=project_id,
380
- created_by_me=created_by_me,
381
- max_items=max_items,
382
- )
383
-
384
- def _list_via_service_list_endpoint(
385
- self,
386
- *,
387
- name: Optional[str] = None,
388
- service_id: Optional[str] = None,
389
- project_id: Optional[str] = None,
390
- created_by_me: bool = False,
391
- max_items: int,
392
- ):
393
- services = []
394
- if service_id:
395
- service_v2_result: DecoratedProductionServiceV2APIModel = (
396
- self.internal_api_client.get_service_api_v2_services_v2_service_id_get(
397
- service_id
398
- ).result
399
- )
400
- services.append(
401
- [
402
- service_v2_result.name,
403
- service_v2_result.id,
404
- service_v2_result.current_state,
405
- # TODO: change to email once https://github.com/anyscale/product/pull/18189 is merged
406
- service_v2_result.creator_id,
407
- ]
408
- )
409
-
410
- else:
411
- services_data = self._get_services_by_name(
412
- name=name,
413
- project_id=project_id,
414
- created_by_me=created_by_me,
415
- max_items=max_items,
416
- )
417
-
418
- services = [
419
- [
420
- service.name,
421
- service.id,
422
- service.current_state,
423
- service.creator.email,
424
- ]
425
- for service in services_data
426
- ]
427
-
428
- table = tabulate.tabulate(
429
- services,
430
- headers=["NAME", "ID", "CURRENT STATE", "CREATOR"],
431
- tablefmt="plain",
432
- )
433
- self.log.info(f'View your Services in the UI at {get_endpoint("/services")}')
434
- self.log.info(f"Services:\n{table}")
435
-
436
364
  def rollback(self, service_id: str, max_surge_percent: Optional[int] = None):
437
365
  service: ServiceModel = self.sdk.rollback_service(
438
366
  service_id,
@@ -445,10 +373,3 @@ class ServiceController(BaseController):
445
373
  self.log.info(
446
374
  f'View the service in the UI at {get_endpoint(f"/services/{service.id}")}'
447
375
  )
448
-
449
- def terminate(self, service_id: str):
450
- service: ServiceModel = self.sdk.terminate_service(service_id).result
451
- self.log.info(f"Service {service.id} terminate initiated.")
452
- self.log.info(
453
- f'View the service in the UI at {get_endpoint(f"/services/{service.id}")}'
454
- )
@@ -1,6 +1,7 @@
1
- from typing import Optional, Union
1
+ from typing import List, Optional, Union
2
2
 
3
3
  from anyscale._private.anyscale_client import AnyscaleClientInterface
4
+ from anyscale._private.models.model_base import ResultIterator
4
5
  from anyscale._private.sdk import sdk_docs
5
6
  from anyscale._private.sdk.base_sdk import Timer
6
7
  from anyscale.cli_logger import BlockLogger
@@ -15,6 +16,8 @@ from anyscale.service.commands import (
15
16
  _DELETE_EXAMPLE,
16
17
  _DEPLOY_ARG_DOCSTRINGS,
17
18
  _DEPLOY_EXAMPLE,
19
+ _LIST_ARG_DOCSTRINGS,
20
+ _LIST_EXAMPLE,
18
21
  _ROLLBACK_ARG_DOCSTRINGS,
19
22
  _ROLLBACK_EXAMPLE,
20
23
  _STATUS_ARG_DOCSTRINGS,
@@ -26,6 +29,7 @@ from anyscale.service.commands import (
26
29
  archive,
27
30
  delete,
28
31
  deploy,
32
+ list,
29
33
  rollback,
30
34
  status,
31
35
  terminate,
@@ -34,8 +38,10 @@ from anyscale.service.commands import (
34
38
  from anyscale.service.models import (
35
39
  ServiceConfig,
36
40
  ServiceLogMode,
41
+ ServiceSortField,
37
42
  ServiceState,
38
43
  ServiceStatus,
44
+ SortOrder,
39
45
  )
40
46
 
41
47
 
@@ -122,6 +128,7 @@ class ServiceSDK:
122
128
  )
123
129
  def archive( # noqa: F811
124
130
  self,
131
+ id: Optional[str] = None, # noqa: A002
125
132
  name: Optional[str] = None,
126
133
  *,
127
134
  cloud: Optional[str] = None,
@@ -133,13 +140,14 @@ class ServiceSDK:
133
140
 
134
141
  Returns the ID of the archived service.
135
142
  """
136
- return self._private_sdk.archive(name=name, cloud=cloud, project=project)
143
+ return self._private_sdk.archive(id=id, name=name, cloud=cloud, project=project)
137
144
 
138
145
  @sdk_docs(
139
146
  doc_py_example=_DELETE_EXAMPLE, arg_docstrings=_DELETE_ARG_DOCSTRINGS,
140
147
  )
141
148
  def delete( # noqa: F811
142
149
  self,
150
+ id: Optional[str] = None, # noqa: A002
143
151
  name: Optional[str] = None,
144
152
  *,
145
153
  cloud: Optional[str] = None,
@@ -149,7 +157,49 @@ class ServiceSDK:
149
157
 
150
158
  This command is asynchronous, so it always returns immediately.
151
159
  """
152
- return self._private_sdk.delete(name=name, cloud=cloud, project=project)
160
+ return self._private_sdk.delete(id=id, name=name, cloud=cloud, project=project)
161
+
162
+ def list( # noqa: F811, A001
163
+ self,
164
+ *,
165
+ # Single-item lookup
166
+ service_id: Optional[str] = None,
167
+ # Filters
168
+ name: Optional[str] = None,
169
+ state_filter: Optional[Union[List[ServiceState], List[str]]] = None,
170
+ creator_id: Optional[str] = None,
171
+ cloud: Optional[str] = None,
172
+ project: Optional[str] = None,
173
+ include_archived: bool = False,
174
+ # Paging
175
+ max_items: Optional[int] = None,
176
+ page_size: Optional[int] = None,
177
+ # Sorting
178
+ sort_field: Optional[Union[str, ServiceSortField]] = None,
179
+ sort_order: Optional[Union[str, SortOrder]] = None,
180
+ ) -> ResultIterator[ServiceStatus]:
181
+ """List services.
182
+
183
+ Returns
184
+ -------
185
+ ResultIterator[ServiceStatus]
186
+ An iterator yielding ServiceStatus objects. Fetches pages
187
+ lazily as needed. Use a `for` loop to iterate or `list()`
188
+ to consume all at once.
189
+ """
190
+ return self._private_sdk.list(
191
+ service_id=service_id,
192
+ name=name,
193
+ state_filter=state_filter,
194
+ creator_id=creator_id,
195
+ cloud=cloud,
196
+ project=project,
197
+ include_archived=include_archived,
198
+ max_items=max_items,
199
+ page_size=page_size,
200
+ sort_field=sort_field,
201
+ sort_order=sort_order,
202
+ )
153
203
 
154
204
  @sdk_docs(
155
205
  doc_py_example=_STATUS_EXAMPLE, arg_docstrings=_STATUS_ARG_DOCSTRINGS,
@@ -1,18 +1,30 @@
1
+ import asyncio
1
2
  import copy
2
- from typing import Any, Dict, Optional, Union
3
+ from typing import Any, Dict, List, Optional, Union
3
4
 
5
+ from anyscale._private.models.model_base import ResultIterator
4
6
  from anyscale._private.workload import WorkloadSDK
7
+ from anyscale.client.openapi_client.models.decorated_production_service_v2_api_model import (
8
+ DecoratedProductionServiceV2APIModel,
9
+ )
10
+ from anyscale.client.openapi_client.models.decoratedlistserviceapimodel_list_response import (
11
+ DecoratedlistserviceapimodelListResponse,
12
+ )
13
+ from anyscale.client.openapi_client.models.list_response_metadata import (
14
+ ListResponseMetadata,
15
+ )
5
16
  from anyscale.compute_config.models import ComputeConfig
6
17
  from anyscale.sdk.anyscale_client.models import (
7
18
  AccessConfig,
8
- ApplyServiceModel,
19
+ ApplyProductionServiceV2Model,
9
20
  GrpcProtocolConfig,
10
21
  ProductionServiceV2VersionModel,
11
22
  Protocols,
12
23
  RayGCSExternalStorageConfig as APIRayGCSExternalStorageConfig,
13
24
  ServiceConfig as ExternalAPIServiceConfig,
14
25
  ServiceEventCurrentState,
15
- ServiceModel,
26
+ ServiceSortField,
27
+ SortOrder,
16
28
  TracingConfig as APITracingConfg,
17
29
  )
18
30
  from anyscale.service.models import (
@@ -32,6 +44,9 @@ from anyscale.utils.workspace_notification import (
32
44
  )
33
45
 
34
46
 
47
+ MAX_PAGE_SIZE = 50
48
+
49
+
35
50
  class PrivateServiceSDK(WorkloadSDK):
36
51
  def _override_application_runtime_envs(
37
52
  self,
@@ -101,7 +116,10 @@ class PrivateServiceSDK(WorkloadSDK):
101
116
  return name
102
117
 
103
118
  def _log_deployed_service_info(
104
- self, service: ServiceModel, *, canary_percent: Optional[int]
119
+ self,
120
+ service: DecoratedProductionServiceV2APIModel,
121
+ *,
122
+ canary_percent: Optional[int],
105
123
  ):
106
124
  """Log user-facing information about a deployed service."""
107
125
  version_id_info = "version ID: {version_id}".format(
@@ -177,9 +195,9 @@ class PrivateServiceSDK(WorkloadSDK):
177
195
  *,
178
196
  canary_percent: Optional[int] = None,
179
197
  max_surge_percent: Optional[int] = None,
180
- existing_service: Optional[ServiceModel] = None,
181
- ) -> ApplyServiceModel:
182
- """Build the ApplyServiceModel for an in_place update.
198
+ existing_service: Optional[DecoratedProductionServiceV2APIModel] = None,
199
+ ) -> ApplyProductionServiceV2Model:
200
+ """Build the ApplyProductionServiceV2Model for an in_place update.
183
201
 
184
202
  in_place updates:
185
203
  - must be performed on an existing service.
@@ -246,7 +264,7 @@ class PrivateServiceSDK(WorkloadSDK):
246
264
  project_id = self.client.get_project_id(
247
265
  parent_cloud_id=cloud_id, name=config.project
248
266
  )
249
- return ApplyServiceModel(
267
+ return ApplyProductionServiceV2Model(
250
268
  name=name,
251
269
  project_id=project_id,
252
270
  ray_serve_config=self._build_ray_serve_config(config),
@@ -269,9 +287,9 @@ class PrivateServiceSDK(WorkloadSDK):
269
287
  *,
270
288
  canary_percent: Optional[int] = None,
271
289
  max_surge_percent: Optional[int] = None,
272
- existing_service: Optional[ServiceModel] = None,
273
- ) -> ApplyServiceModel:
274
- """Build the ApplyServiceModel for a rolling update."""
290
+ existing_service: Optional[DecoratedProductionServiceV2APIModel] = None,
291
+ ) -> ApplyProductionServiceV2Model:
292
+ """Build the ApplyProductionServiceV2Model for a rolling update."""
275
293
 
276
294
  build_id = None
277
295
  if config.containerfile is not None:
@@ -375,7 +393,7 @@ class PrivateServiceSDK(WorkloadSDK):
375
393
  if config.tracing_config.sampling_ratio is not None:
376
394
  tracing_config.sampling_ratio = config.tracing_config.sampling_ratio
377
395
 
378
- return ApplyServiceModel(
396
+ return ApplyProductionServiceV2Model(
379
397
  name=name,
380
398
  project_id=project_id,
381
399
  ray_serve_config=self._build_ray_serve_config(config),
@@ -417,7 +435,9 @@ class PrivateServiceSDK(WorkloadSDK):
417
435
  raise ValueError("max_surge_percent must be between 0 and 100.")
418
436
 
419
437
  name = config.name or self._get_default_name()
420
- existing_service: Optional[ServiceModel] = self.client.get_service(
438
+ existing_service: Optional[
439
+ DecoratedProductionServiceV2APIModel
440
+ ] = self.client.get_service(
421
441
  name=name, cloud=config.cloud, project=config.project
422
442
  )
423
443
  if existing_service is None:
@@ -444,7 +464,7 @@ class PrivateServiceSDK(WorkloadSDK):
444
464
  existing_service=existing_service,
445
465
  )
446
466
 
447
- service: ServiceModel = self.client.rollout_service(model)
467
+ service = self.client.rollout_service(model)
448
468
  self._log_deployed_service_info(service, canary_percent=canary_percent)
449
469
 
450
470
  return service.id
@@ -456,11 +476,11 @@ class PrivateServiceSDK(WorkloadSDK):
456
476
  cloud: Optional[str] = None,
457
477
  project: Optional[str] = None,
458
478
  include_archived: bool = False,
459
- ) -> ServiceModel:
479
+ ) -> DecoratedProductionServiceV2APIModel:
460
480
  if name is None:
461
481
  name = self._get_default_name()
462
482
 
463
- model: Optional[ServiceModel] = self.client.get_service(
483
+ model = self.client.get_service(
464
484
  name=name, cloud=cloud, project=project, include_archived=include_archived
465
485
  )
466
486
  if model is None:
@@ -539,7 +559,7 @@ class PrivateServiceSDK(WorkloadSDK):
539
559
  # in the DB to avoid breakages, but for now I'm copying the existing logic.
540
560
  return model.id[-SERVICE_VERSION_ID_TRUNCATED_LEN:]
541
561
 
542
- def _service_version_model_to_status(
562
+ async def _service_version_model_to_status_async(
543
563
  self,
544
564
  model: ProductionServiceV2VersionModel,
545
565
  *,
@@ -547,10 +567,20 @@ class PrivateServiceSDK(WorkloadSDK):
547
567
  project_id: str,
548
568
  query_auth_token_enabled: bool,
549
569
  ) -> ServiceVersionStatus:
550
- image_uri = self._image_sdk.get_image_uri_from_build_id(model.build_id)
570
+
571
+ image_uri, image_build, project, compute_config = await asyncio.gather(
572
+ asyncio.to_thread(
573
+ self._image_sdk.get_image_uri_from_build_id, model.build_id
574
+ ),
575
+ asyncio.to_thread(self._image_sdk.get_image_build, model.build_id),
576
+ asyncio.to_thread(self.client.get_project, project_id),
577
+ asyncio.to_thread(
578
+ self.get_user_facing_compute_config, model.compute_config_id
579
+ ),
580
+ )
581
+
551
582
  if image_uri is None:
552
583
  raise RuntimeError(f"Failed to get image URI for ID {model.build_id}.")
553
- image_build = self._image_sdk.get_image_build(model.build_id)
554
584
  if image_build is None:
555
585
  raise RuntimeError(f"Failed to get image build for ID {model.build_id}.")
556
586
 
@@ -562,8 +592,6 @@ class PrivateServiceSDK(WorkloadSDK):
562
592
  certificate_path=model.ray_gcs_external_storage_config.redis_certificate_path,
563
593
  )
564
594
 
565
- project = self.client.get_project(project_id)
566
- compute_config = self.get_user_facing_compute_config(model.compute_config_id)
567
595
  tracing_config = None
568
596
  if model.tracing_config is not None:
569
597
  tracing_config = TracingConfig(
@@ -574,6 +602,7 @@ class PrivateServiceSDK(WorkloadSDK):
574
602
 
575
603
  return ServiceVersionStatus(
576
604
  id=self._get_user_facing_service_version_id(model),
605
+ created_at=model.created_at,
577
606
  state=model.current_state,
578
607
  # NOTE(edoakes): there is also a "current_weight" field but it does not match the UI.
579
608
  weight=model.weight,
@@ -615,50 +644,137 @@ class PrivateServiceSDK(WorkloadSDK):
615
644
 
616
645
  return state
617
646
 
618
- def _service_model_to_status(self, model: ServiceModel) -> ServiceStatus:
619
- # TODO(edoakes): for some reason the primary_version is populated
620
- # when the service is terminated. This should be fixed in the backend.
621
- is_terminated = model.current_state == ServiceEventCurrentState.TERMINATED
622
-
647
+ async def _service_model_to_status_async(
648
+ self, model: DecoratedProductionServiceV2APIModel
649
+ ) -> ServiceStatus:
623
650
  # TODO(edoakes): this is currently only exposed at the service level in the API,
624
651
  # which means that the per-version `query_auth_token_enabled` field will lie if
625
652
  # it's changed.
626
653
  query_auth_token_enabled = model.auth_token is not None
627
654
 
628
- primary_version = None
629
- if not is_terminated and model.primary_version is not None:
630
- primary_version = self._service_version_model_to_status(
631
- model.primary_version,
632
- service_name=model.name,
633
- project_id=model.project_id,
634
- query_auth_token_enabled=query_auth_token_enabled,
655
+ primary_version_task = None
656
+ if model.primary_version is not None:
657
+ primary_version_task = asyncio.create_task(
658
+ self._service_version_model_to_status_async(
659
+ model.primary_version,
660
+ service_name=model.name,
661
+ project_id=model.project_id,
662
+ query_auth_token_enabled=query_auth_token_enabled,
663
+ )
635
664
  )
636
665
 
637
- canary_version = None
638
- if not is_terminated and model.canary_version is not None:
639
- canary_version = self._service_version_model_to_status(
640
- model.canary_version,
641
- service_name=model.name,
642
- project_id=model.project_id,
643
- query_auth_token_enabled=query_auth_token_enabled,
666
+ canary_version_task = None
667
+ if model.canary_version is not None:
668
+ canary_version_task = asyncio.create_task(
669
+ self._service_version_model_to_status_async(
670
+ model.canary_version,
671
+ service_name=model.name,
672
+ project_id=model.project_id,
673
+ query_auth_token_enabled=query_auth_token_enabled,
674
+ )
644
675
  )
645
676
 
677
+ primary_version = await primary_version_task if primary_version_task else None
678
+ canary_version = await canary_version_task if canary_version_task else None
679
+
680
+ project_name = None
681
+ if primary_version and isinstance(primary_version.config, ServiceConfig):
682
+ project_name = primary_version.config.project
683
+
646
684
  return ServiceStatus(
647
685
  id=model.id,
648
686
  name=model.name,
687
+ creator=model.creator.email if model.creator else None,
649
688
  state=self._model_state_to_state(model.current_state),
650
689
  query_url=model.base_url,
651
690
  query_auth_token=model.auth_token,
652
691
  primary_version=primary_version,
653
692
  canary_version=canary_version,
693
+ project=project_name,
654
694
  )
655
695
 
656
696
  def status(
657
697
  self, name: str, *, cloud: Optional[str] = None, project: Optional[str] = None
658
698
  ) -> ServiceStatus:
659
699
  model = self._resolve_to_service_model(name=name, cloud=cloud, project=project)
700
+ return asyncio.run(self._service_model_to_status_async(model))
660
701
 
661
- return self._service_model_to_status(model)
702
+ def list( # noqa: PLR0912
703
+ self,
704
+ *,
705
+ # Single-item lookup
706
+ service_id: Optional[str] = None,
707
+ # Filters
708
+ name: Optional[str] = None,
709
+ state_filter: Optional[Union[List[ServiceState], List[str]]] = None,
710
+ creator_id: Optional[str] = None,
711
+ cloud: Optional[str] = None,
712
+ project: Optional[str] = None,
713
+ include_archived: bool = False,
714
+ # Paging
715
+ max_items: Optional[int] = None, # Controls total items yielded by iterator
716
+ page_size: Optional[int] = None, # Controls items fetched per API call
717
+ # Sorting
718
+ sort_field: Optional[Union[str, ServiceSortField]] = None,
719
+ sort_order: Optional[Union[str, SortOrder]] = None,
720
+ ) -> ResultIterator[ServiceStatus]:
721
+
722
+ if page_size is not None and (page_size <= 0 or page_size > MAX_PAGE_SIZE):
723
+ raise ValueError(
724
+ f"page_size must be between 1 and {MAX_PAGE_SIZE}, inclusive."
725
+ )
726
+
727
+ if service_id is not None:
728
+ raw = self.client.get_service_by_id(service_id)
729
+
730
+ def _fetch_single_page(
731
+ _token: Optional[str],
732
+ ) -> DecoratedlistserviceapimodelListResponse:
733
+ # Only return data on the first call (token=None), simulate single-item page
734
+ if _token is None and raw is not None:
735
+ results = [raw]
736
+ metadata = ListResponseMetadata(total=1, next_paging_token=None)
737
+ else:
738
+ results = []
739
+ metadata = ListResponseMetadata(total=0, next_paging_token=None)
740
+
741
+ return DecoratedlistserviceapimodelListResponse(
742
+ results=results, metadata=metadata,
743
+ )
744
+
745
+ return ResultIterator(
746
+ page_token=None,
747
+ max_items=1,
748
+ fetch_page=_fetch_single_page,
749
+ async_parse_fn=self._service_model_to_status_async,
750
+ parse_fn=None,
751
+ )
752
+
753
+ normalised_states = _normalize_state_filter(state_filter)
754
+
755
+ def _fetch_page(
756
+ token: Optional[str],
757
+ ) -> DecoratedlistserviceapimodelListResponse:
758
+ return self.client.list_services(
759
+ name=name,
760
+ state_filter=normalised_states,
761
+ creator_id=creator_id,
762
+ cloud=cloud,
763
+ project=project,
764
+ include_archived=include_archived,
765
+ count=page_size,
766
+ paging_token=token,
767
+ sort_field=sort_field,
768
+ sort_order=sort_order,
769
+ )
770
+
771
+ return ResultIterator(
772
+ page_token=None,
773
+ max_items=max_items,
774
+ fetch_page=_fetch_page,
775
+ async_parse_fn=self._service_model_to_status_async,
776
+ parse_fn=None,
777
+ )
662
778
 
663
779
  def wait(
664
780
  self,
@@ -738,3 +854,23 @@ class PrivateServiceSDK(WorkloadSDK):
738
854
  return self.client.controller_logs_for_service_version(
739
855
  model.primary_version, head, max_lines
740
856
  )
857
+
858
+
859
+ def _normalize_state_filter(
860
+ states: Optional[Union[List[ServiceState], List[str]]]
861
+ ) -> Optional[List[str]]:
862
+ if states is None:
863
+ return None
864
+
865
+ normalized: List[str] = []
866
+ for s in states:
867
+ if isinstance(s, ServiceState):
868
+ normalized.append(s.value)
869
+ elif isinstance(s, str):
870
+ normalized.append(s.upper())
871
+ else:
872
+ raise TypeError(
873
+ "'state_filter' entries must be ServiceState or str, "
874
+ f"got {type(s).__name__}"
875
+ )
876
+ return normalized