anyscale 0.26.20__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.
Files changed (34) hide show
  1. anyscale/_private/anyscale_client/anyscale_client.py +103 -43
  2. anyscale/_private/anyscale_client/common.py +37 -7
  3. anyscale/_private/anyscale_client/fake_anyscale_client.py +98 -27
  4. anyscale/_private/models/model_base.py +95 -0
  5. anyscale/_private/workload/workload_sdk.py +3 -1
  6. anyscale/aggregated_instance_usage/models.py +4 -4
  7. anyscale/client/README.md +1 -9
  8. anyscale/client/openapi_client/__init__.py +0 -3
  9. anyscale/client/openapi_client/api/default_api.py +151 -715
  10. anyscale/client/openapi_client/models/__init__.py +0 -3
  11. anyscale/commands/cloud_commands.py +15 -4
  12. anyscale/commands/command_examples.py +4 -0
  13. anyscale/commands/list_util.py +107 -0
  14. anyscale/commands/service_commands.py +267 -31
  15. anyscale/commands/util.py +5 -4
  16. anyscale/controllers/cloud_controller.py +358 -49
  17. anyscale/controllers/service_controller.py +7 -86
  18. anyscale/service/__init__.py +53 -3
  19. anyscale/service/_private/service_sdk.py +177 -41
  20. anyscale/service/commands.py +78 -1
  21. anyscale/service/models.py +65 -0
  22. anyscale/util.py +35 -1
  23. anyscale/utils/gcp_utils.py +20 -4
  24. anyscale/version.py +1 -1
  25. {anyscale-0.26.20.dist-info → anyscale-0.26.22.dist-info}/METADATA +1 -1
  26. {anyscale-0.26.20.dist-info → anyscale-0.26.22.dist-info}/RECORD +31 -33
  27. anyscale/client/openapi_client/models/organization_public_identifier.py +0 -121
  28. anyscale/client/openapi_client/models/organization_response.py +0 -121
  29. anyscale/client/openapi_client/models/organizationpublicidentifier_response.py +0 -121
  30. {anyscale-0.26.20.dist-info → anyscale-0.26.22.dist-info}/LICENSE +0 -0
  31. {anyscale-0.26.20.dist-info → anyscale-0.26.22.dist-info}/NOTICE +0 -0
  32. {anyscale-0.26.20.dist-info → anyscale-0.26.22.dist-info}/WHEEL +0 -0
  33. {anyscale-0.26.20.dist-info → anyscale-0.26.22.dist-info}/entry_points.txt +0 -0
  34. {anyscale-0.26.20.dist-info → anyscale-0.26.22.dist-info}/top_level.txt +0 -0
@@ -406,8 +406,6 @@ from openapi_client.models.organization_marketing_questions import OrganizationM
406
406
  from openapi_client.models.organization_permission_level import OrganizationPermissionLevel
407
407
  from openapi_client.models.organization_project_collaborator import OrganizationProjectCollaborator
408
408
  from openapi_client.models.organization_project_collaborator_value import OrganizationProjectCollaboratorValue
409
- from openapi_client.models.organization_public_identifier import OrganizationPublicIdentifier
410
- from openapi_client.models.organization_response import OrganizationResponse
411
409
  from openapi_client.models.organization_summary import OrganizationSummary
412
410
  from openapi_client.models.organization_usage_alert import OrganizationUsageAlert
413
411
  from openapi_client.models.organization_usage_alert_severity import OrganizationUsageAlertSeverity
@@ -418,7 +416,6 @@ from openapi_client.models.organizationinvitation_list_response import Organizat
418
416
  from openapi_client.models.organizationinvitation_response import OrganizationinvitationResponse
419
417
  from openapi_client.models.organizationinvitationbase_response import OrganizationinvitationbaseResponse
420
418
  from openapi_client.models.organizationprojectcollaborator_list_response import OrganizationprojectcollaboratorListResponse
421
- from openapi_client.models.organizationpublicidentifier_response import OrganizationpublicidentifierResponse
422
419
  from openapi_client.models.organizationusagealert_list_response import OrganizationusagealertListResponse
423
420
  from openapi_client.models.pcp_config import PCPConfig
424
421
  from openapi_client.models.page_query import PageQuery
@@ -237,6 +237,7 @@ def cloud_config_group() -> None:
237
237
  @cloud_cli.command(
238
238
  name="update",
239
239
  help=(
240
+ # TODO(janet): Update this help text when the -o option is un-hidden.
240
241
  "Update a managed cloud to the latest configuration. Only applicable for anyscale managed clouds."
241
242
  ),
242
243
  )
@@ -275,7 +276,14 @@ def cloud_config_group() -> None:
275
276
  "are manually granted permissions to access the cloud. No existing cloud permissions are altered by specifying this flag."
276
277
  ),
277
278
  )
278
- def cloud_update(
279
+ @click.option(
280
+ "--file",
281
+ "-f",
282
+ help="YAML file containing the updated cloud spec.",
283
+ required=False,
284
+ hidden=True,
285
+ )
286
+ def cloud_update( # noqa: PLR0913
279
287
  cloud_name: Optional[str],
280
288
  name: Optional[str],
281
289
  cloud_id: Optional[str],
@@ -283,7 +291,12 @@ def cloud_update(
283
291
  enable_head_node_fault_tolerance: bool,
284
292
  yes: bool,
285
293
  enable_auto_add_user: Optional[bool],
294
+ file: Optional[str],
286
295
  ) -> None:
296
+ if file:
297
+ CloudController().update_cloud_deployments(file)
298
+ return
299
+
287
300
  if cloud_name and name and cloud_name != name:
288
301
  raise click.ClickException(
289
302
  "The positional argument CLOUD_NAME and the keyword argument --name "
@@ -1142,9 +1155,7 @@ def get_cloud(
1142
1155
 
1143
1156
  if output:
1144
1157
  # Include all cloud deployments for the cloud.
1145
- result = CloudController().get_cloud_deployments(
1146
- cloud_id=cloud.id, cloud_name=cloud.name
1147
- )
1158
+ result = CloudController().get_cloud_deployments(cloud_id=cloud.id)
1148
1159
 
1149
1160
  with open(output, "w") as f:
1150
1161
  yaml.dump(result, f, sort_keys=False)
@@ -670,3 +670,7 @@ $ anyscale service archive --name my_service
670
670
  SERVICE_DELETE_EXAMPLE = """\
671
671
  $ anyscale service delete --name my_service
672
672
  """
673
+
674
+ SERVICE_LIST_EXAMPLE = """\
675
+ $ anyscale service list --state running --sort -created_at
676
+ """
@@ -0,0 +1,107 @@
1
+ import itertools
2
+ from json import dumps as json_dumps
3
+ from typing import Any, Callable, Dict, Iterator, List, Optional
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from anyscale.util import AnyscaleJSONEncoder
9
+
10
+
11
+ def _paginate(iterator: Iterator[Any], page_size: Optional[int]) -> Iterator[List[Any]]:
12
+ if page_size is None:
13
+ yield list(iterator)
14
+ else:
15
+ while True:
16
+ page = list(itertools.islice(iterator, page_size))
17
+ if not page:
18
+ return
19
+ yield page
20
+
21
+
22
+ def display_list( # noqa: PLR0913
23
+ iterator: Iterator[Any],
24
+ item_formatter: Callable[[Any], Dict[str, Any]],
25
+ table_creator: Callable[[bool], Table],
26
+ json_output: bool,
27
+ page_size: int,
28
+ interactive: bool,
29
+ max_items: Optional[int],
30
+ console: Console,
31
+ ) -> int:
32
+ """Displays a list of items from an iterator, handling pagination and output format.
33
+
34
+ Args:
35
+ iterator: The iterator yielding items to display.
36
+ item_formatter: A callable that takes an item and returns a dictionary
37
+ representing the row data (for table) or the JSON object.
38
+ table_creator: A callable that takes a boolean (is_first_page) and
39
+ returns a rich.Table instance. Used only if json_output is False.
40
+ json_output: If True, output items as a JSON list. Otherwise, display
41
+ them in a table created by table_creator.
42
+ page_size: The number of items to display per page in interactive mode.
43
+ interactive: If True, enables interactive pagination. If False, displays
44
+ up to max_items (or all items if max_items is None) without prompting.
45
+ max_items: The maximum total number of items to display when interactive
46
+ is False. If None, all items are displayed.
47
+ console: The rich.Console object to use for output.
48
+
49
+ Returns:
50
+ The total number of items displayed.
51
+ """
52
+ total_count = 0
53
+ pages = _paginate(iterator, page_size if interactive else max_items)
54
+
55
+ # fetch first page under spinner
56
+ with console.status("Retrieving items…", spinner="dots"):
57
+ try:
58
+ first_page = next(pages)
59
+ except StopIteration:
60
+ first_page = []
61
+
62
+ def _render(page: List[Any], is_first: bool, page_num: int):
63
+ nonlocal total_count
64
+ total_count += len(page)
65
+ if interactive:
66
+ console.print(f"[dim]Page {page_num}[/dim]")
67
+ rows = [item_formatter(item) for item in page]
68
+ if json_output:
69
+ json_str = json_dumps(rows, indent=2, cls=AnyscaleJSONEncoder)
70
+ console.print_json(json=json_str)
71
+ else:
72
+ tbl = table_creator(is_first)
73
+ for row in rows:
74
+ tbl.add_row(*row.values())
75
+ console.print(tbl)
76
+
77
+ # render first page
78
+ if first_page:
79
+ _render(first_page, True, page_num=1)
80
+
81
+ # non-interactive: stop after first page
82
+ if not interactive:
83
+ return total_count
84
+
85
+ # interactive: prompt after full first page
86
+ if len(first_page) == page_size:
87
+ console.print()
88
+ console.print(
89
+ "[dim]Press [bold]Enter[/bold] to continue, [bold]q[/bold] to quit…[/]"
90
+ )
91
+ if input("> ").strip().lower() == "q":
92
+ return total_count
93
+
94
+ # render remaining pages
95
+ page_num = 2
96
+ for page in pages:
97
+ _render(page, False, page_num)
98
+ if len(page) == page_size:
99
+ console.print()
100
+ console.print(
101
+ "[dim]Press [bold]Enter[/bold] to continue, [bold]q[/bold] to quit…[/]"
102
+ )
103
+ if input("> ").strip().lower() == "q":
104
+ break
105
+ page_num += 1
106
+
107
+ return total_count
@@ -2,15 +2,18 @@ from io import StringIO
2
2
  from json import dumps as json_dumps
3
3
  import pathlib
4
4
  import sys
5
- from typing import Any, Dict, Optional, Tuple
5
+ from typing import Any, Dict, List, Optional, Tuple
6
6
 
7
7
  import click
8
+ from rich.console import Console
9
+ from rich.table import Table
8
10
  from typing_extensions import Literal
9
11
  import yaml
10
12
 
11
13
  from anyscale._private.models.image_uri import ImageURI
12
14
  from anyscale.cli_logger import BlockLogger
13
15
  from anyscale.commands import command_examples
16
+ from anyscale.commands.list_util import display_list
14
17
  from anyscale.commands.util import (
15
18
  AnyscaleCommand,
16
19
  convert_kv_strings_to_dict,
@@ -19,8 +22,21 @@ from anyscale.commands.util import (
19
22
  )
20
23
  from anyscale.controllers.service_controller import ServiceController
21
24
  import anyscale.service
22
- from anyscale.service import ServiceConfig, ServiceLogMode, ServiceState, ServiceStatus
23
- from anyscale.util import validate_non_negative_arg
25
+ from anyscale.service.models import (
26
+ ServiceConfig,
27
+ ServiceLogMode,
28
+ ServiceSortField,
29
+ ServiceState,
30
+ ServiceStatus,
31
+ ServiceVersionStatus,
32
+ SortOrder,
33
+ )
34
+ from anyscale.util import (
35
+ AnyscaleJSONEncoder,
36
+ get_endpoint,
37
+ validate_non_negative_arg,
38
+ validate_service_state_filter,
39
+ )
24
40
 
25
41
 
26
42
  log = BlockLogger() # CLI Logger
@@ -389,12 +405,14 @@ def status(
389
405
  status_dict.get("primary_version", {}).pop("config", None)
390
406
  status_dict.get("canary_version", {}).pop("config", None)
391
407
 
408
+ console = Console()
392
409
  if json:
393
- print(json_dumps(status_dict, indent=4, sort_keys=False))
410
+ json_str = json_dumps(status_dict, indent=2, cls=AnyscaleJSONEncoder)
411
+ console.print_json(json=json_str)
394
412
  else:
395
413
  stream = StringIO()
396
414
  yaml.dump(status_dict, stream, sort_keys=False)
397
- print(stream.getvalue(), end="")
415
+ console.print(stream.getvalue(), end="")
398
416
 
399
417
 
400
418
  @service_cli.command(
@@ -624,51 +642,270 @@ def rollout( # noqa: PLR0913
624
642
  )
625
643
 
626
644
 
627
- # TODO(mowen): Add cloud support for this when we refactor to new SDK method
628
- @service_cli.command(name="list", help="Display information about services.")
629
- @click.option(
630
- "--name", "-n", required=False, default=None, help="Filter by service name."
645
+ MAX_PAGE_SIZE = 50
646
+ NON_INTERACTIVE_DEFAULT_MAX_ITEMS = 10
647
+
648
+
649
+ def validate_page_size(ctx, param, value):
650
+ value = validate_non_negative_arg(ctx, param, value)
651
+ if value is not None and value > MAX_PAGE_SIZE:
652
+ raise click.BadParameter(f"must be less than or equal to {MAX_PAGE_SIZE}.")
653
+ return value
654
+
655
+
656
+ def validate_max_items(ctx, param, value):
657
+ if value is None:
658
+ return None
659
+ return validate_non_negative_arg(ctx, param, value)
660
+
661
+
662
+ def _parse_sort_option(sort: Optional[str],) -> Tuple[Optional[str], SortOrder]:
663
+ """
664
+ Given a raw sort string (e.g. "-created_at"), return
665
+ (canonical_field_name, SortOrder).
666
+ """
667
+ if not sort:
668
+ return None, SortOrder.ASC
669
+
670
+ # build case-insensitive map of allowed fields
671
+ allowed = {f.value.lower(): f.value for f in ServiceSortField.__members__.values()}
672
+
673
+ # detect leading '-' for descending
674
+ if sort.startswith("-"):
675
+ raw = sort[1:]
676
+ order = SortOrder.DESC
677
+ else:
678
+ raw = sort
679
+ order = SortOrder.ASC
680
+
681
+ key = raw.lower()
682
+ if key not in allowed:
683
+ allowed_names = ", ".join(sorted(allowed.values()))
684
+ raise click.BadParameter(
685
+ f"Invalid sort field '{raw}'. Allowed fields: {allowed_names}"
686
+ )
687
+
688
+ return allowed[key], order
689
+
690
+
691
+ def _create_service_list_table(show_header: bool) -> Table:
692
+ table = Table(show_header=show_header, expand=True)
693
+ # NAME and ID: larger ratios, can wrap but never truncate
694
+ table.add_column(
695
+ "NAME", no_wrap=False, overflow="fold", ratio=3, min_width=15,
696
+ )
697
+ table.add_column(
698
+ "ID", no_wrap=False, overflow="fold", ratio=2, min_width=12,
699
+ )
700
+ # all other columns will wrap as needed
701
+ for heading in (
702
+ "CURRENT STATE",
703
+ "CREATOR",
704
+ "PROJECT",
705
+ "LAST DEPLOYED AT",
706
+ ):
707
+ table.add_column(
708
+ heading, no_wrap=False, overflow="fold", ratio=1, min_width=8,
709
+ )
710
+
711
+ return table
712
+
713
+
714
+ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
715
+ last_deployed_at = ""
716
+ if isinstance(svc.primary_version, ServiceVersionStatus):
717
+ last_deployed_at = svc.primary_version.created_at.strftime("%Y-%m-%d %H:%M:%S")
718
+
719
+ return {
720
+ "name": svc.name,
721
+ "id": svc.id,
722
+ "current_state": str(svc.state),
723
+ "creator": str(svc.creator or ""),
724
+ "project": str(svc.project or ""),
725
+ "last_deployed_at": last_deployed_at,
726
+ }
727
+
728
+
729
+ @service_cli.command(
730
+ name="list", help="List services.", cls=AnyscaleCommand,
631
731
  )
732
+ @click.option("--service-id", "--id", help="ID of the service to display.")
733
+ @click.option("--name", "-n", help="Name of the service to display.")
632
734
  @click.option(
633
- "--service-id", "--id", required=False, default=None, help="Filter by service id."
735
+ "--cloud",
736
+ type=str,
737
+ help="The Anyscale Cloud of this workload; defaults to your org/workspace cloud.",
634
738
  )
635
739
  @click.option(
636
- "--project-id", required=False, default=None, help="Filter by project id."
740
+ "--project",
741
+ type=str,
742
+ help="Named project to use; defaults to your org/workspace project.",
637
743
  )
638
744
  @click.option(
639
745
  "--created-by-me",
746
+ is_flag=True,
747
+ default=False,
640
748
  help="List services created by me only.",
749
+ )
750
+ @click.option(
751
+ "--state",
752
+ "-s",
753
+ "state_filter",
754
+ multiple=True,
755
+ callback=validate_service_state_filter,
756
+ help=(
757
+ "Filter by service state (repeatable). "
758
+ f"Allowed: {', '.join(s.value for s in ServiceState)}"
759
+ ),
760
+ )
761
+ @click.option(
762
+ "--include-archived",
641
763
  is_flag=True,
642
764
  default=False,
765
+ help="Include archived services.",
643
766
  )
644
767
  @click.option(
645
768
  "--max-items",
646
- required=False,
647
- default=10,
648
769
  type=int,
649
- help="Max items to show in list.",
650
- callback=validate_non_negative_arg,
770
+ callback=validate_max_items,
771
+ help="Max total items (only with --no-interactive).",
651
772
  )
652
- def list( # noqa: A001
653
- name: Optional[str],
773
+ @click.option(
774
+ "--page-size",
775
+ type=int,
776
+ default=10,
777
+ show_default=True,
778
+ callback=validate_page_size,
779
+ help=f"Items per page (max {MAX_PAGE_SIZE}).",
780
+ )
781
+ @click.option(
782
+ "--interactive/--no-interactive",
783
+ default=True,
784
+ show_default=True,
785
+ help="Use interactive paging.",
786
+ )
787
+ @click.option(
788
+ "--sort",
789
+ help=(
790
+ "Sort by FIELD (prefix with '-' for desc). "
791
+ f"Allowed: {', '.join(f.value for f in ServiceSortField.__members__.values())}"
792
+ ),
793
+ )
794
+ @click.option(
795
+ "-v",
796
+ "--verbose",
797
+ is_flag=True,
798
+ default=False,
799
+ help="Include full config in JSON output.",
800
+ )
801
+ @click.option(
802
+ "-j",
803
+ "--json",
804
+ "json_output",
805
+ is_flag=True,
806
+ default=False,
807
+ help="Emit structured JSON to stdout.",
808
+ )
809
+ def list( # noqa: PLR0913, A001
654
810
  service_id: Optional[str],
655
- project_id: Optional[str],
811
+ name: Optional[str],
656
812
  created_by_me: bool,
657
- max_items: int,
813
+ cloud: Optional[str],
814
+ project: Optional[str],
815
+ state_filter: List[str],
816
+ include_archived: bool,
817
+ max_items: Optional[int],
818
+ page_size: int,
819
+ sort: Optional[str],
820
+ json_output: bool,
821
+ interactive: bool,
822
+ verbose: bool,
658
823
  ):
659
- """List services based on the provided filters.
660
-
661
- This returns both v1 and v2 services.
662
- """
663
- service_controller = ServiceController()
664
- service_controller.list(
665
- name=name,
666
- service_id=service_id,
667
- project_id=project_id,
668
- created_by_me=created_by_me,
669
- max_items=max_items,
824
+ """List services based on the provided filters."""
825
+ if max_items is not None and interactive:
826
+ raise click.UsageError("--max-items only allowed with --no-interactive")
827
+
828
+ # parse sort
829
+ sort_field, sort_order = _parse_sort_option(sort)
830
+
831
+ # normalize max_items
832
+ effective_max = max_items
833
+ if not interactive and effective_max is None:
834
+ stderr = Console(stderr=True)
835
+ stderr.print(
836
+ f"Defaulting to {NON_INTERACTIVE_DEFAULT_MAX_ITEMS} items in batch mode; "
837
+ "use --max-items to override."
838
+ )
839
+ effective_max = NON_INTERACTIVE_DEFAULT_MAX_ITEMS
840
+
841
+ console = Console()
842
+ stderr = Console(stderr=True)
843
+
844
+ # diagnostics
845
+ stderr.print("[bold]Listing services with:[/]")
846
+ stderr.print(f"• name = {name or '<any>'}")
847
+ stderr.print(f"• states = {', '.join(state_filter) or '<all>'}")
848
+ stderr.print(f"• created_by_me = {created_by_me}")
849
+ stderr.print(f"• include_archived= {include_archived}")
850
+ stderr.print(f"• sort = {sort or '<none>'}")
851
+ stderr.print(f"• mode = {'interactive' if interactive else 'batch'}")
852
+ stderr.print(f"• per-page limit = {page_size}")
853
+ stderr.print(f"• max-items total = {effective_max or 'all'}")
854
+ stderr.print(f"\nView your Services in the UI at {get_endpoint('/services')}\n")
855
+
856
+ creator_id = (
857
+ ServiceController().get_authenticated_user_id() if created_by_me else None
670
858
  )
671
859
 
860
+ # choose formatter
861
+ if json_output:
862
+
863
+ def json_formatter(svc: ServiceStatus) -> Dict[str, Any]:
864
+ data = svc.to_dict()
865
+ if not verbose:
866
+ data.get("primary_version", {}).pop("config", None)
867
+ data.get("canary_version", {}).pop("config", None)
868
+ return data
869
+
870
+ formatter = json_formatter
871
+ else:
872
+ formatter = _format_service_output_data
873
+
874
+ total = 0
875
+ try:
876
+ iterator = anyscale.service.list(
877
+ service_id=service_id,
878
+ name=name,
879
+ state_filter=state_filter,
880
+ creator_id=creator_id,
881
+ cloud=cloud,
882
+ project=project,
883
+ include_archived=include_archived,
884
+ max_items=None if interactive else effective_max,
885
+ page_size=page_size,
886
+ sort_field=sort_field,
887
+ sort_order=sort_order,
888
+ )
889
+ total = display_list(
890
+ iterator=iter(iterator),
891
+ item_formatter=formatter,
892
+ table_creator=_create_service_list_table,
893
+ json_output=json_output,
894
+ page_size=page_size,
895
+ interactive=interactive,
896
+ max_items=effective_max,
897
+ console=console,
898
+ )
899
+
900
+ if not json_output:
901
+ if total > 0:
902
+ stderr.print(f"\nFetched {total} services.")
903
+ else:
904
+ stderr.print("\nNo services found.")
905
+ except Exception as e: # noqa: BLE001
906
+ log.error(f"Failed to list services: {e}")
907
+ sys.exit(1)
908
+
672
909
 
673
910
  # TODO(mowen): Add cloud support for this when we refactor to new SDK method
674
911
  @service_cli.command(name="rollback", help="Roll back a service.")
@@ -708,7 +945,6 @@ def rollback(
708
945
  service_controller.rollback(service_id, max_surge_percent)
709
946
 
710
947
 
711
- # TODO(mowen): Add cloud support for this when we refactor to new SDK method
712
948
  @service_cli.command(name="terminate", help="Terminate a service.")
713
949
  @click.option(
714
950
  "--service-id", "--id", required=False, help="ID of service.",
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