nextmv 1.0.0.dev5__py3-none-any.whl → 1.0.0.dev6__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.
@@ -2,18 +2,18 @@
2
2
  This module defines the community list command for the Nextmv CLI.
3
3
  """
4
4
 
5
- from typing import Annotated, Any
5
+ from typing import Annotated
6
6
 
7
- import requests
8
7
  import rich
9
8
  import typer
10
- import yaml
11
9
  from rich.console import Console
12
10
  from rich.table import Table
13
11
 
14
12
  from nextmv.cli.configuration.config import build_client
15
13
  from nextmv.cli.message import error
16
14
  from nextmv.cli.options import ProfileOption
15
+ from nextmv.cloud.client import Client
16
+ from nextmv.cloud.community import CommunityApp, list_community_apps
17
17
 
18
18
  # Set up subcommand application.
19
19
  app = typer.Typer()
@@ -62,96 +62,73 @@ def list(
62
62
  if app is not None and app == "":
63
63
  error("The --app flag cannot be an empty string.")
64
64
 
65
- manifest = download_manifest(profile=profile)
65
+ client = build_client(profile)
66
66
  if flat and app is None:
67
- apps_list(manifest)
67
+ _apps_list(client)
68
68
  raise typer.Exit()
69
69
  elif not flat and app is None:
70
- apps_table(manifest)
70
+ _apps_table(client)
71
71
  raise typer.Exit()
72
72
  elif flat and app is not None and app != "":
73
- versions_list(manifest, app)
73
+ _versions_list(client, app)
74
74
  raise typer.Exit()
75
75
  elif not flat and app is not None and app != "":
76
- versions_table(manifest, app)
76
+ _versions_table(client, app)
77
77
  raise typer.Exit()
78
78
 
79
79
 
80
- def download_manifest(profile: str | None = None) -> dict:
80
+ def _apps_table(client: Client) -> None:
81
81
  """
82
- Downloads and returns the community apps manifest.
82
+ This function prints a table of community apps.
83
83
 
84
84
  Parameters
85
85
  ----------
86
- profile : str | None
87
- The profile name to use. If None, the default profile is used.
88
-
89
- Returns
90
- -------
91
- dict
92
- The community apps manifest as a dictionary.
93
-
94
- Raises
95
- requests.HTTPError
96
- If the response status code is not 2xx.
97
- """
98
-
99
- response = download_file(directory="community-apps", file="manifest.yml", profile=profile)
100
- manifest = yaml.safe_load(response.text)
101
-
102
- return manifest
103
-
104
-
105
- def apps_table(manifest: dict[str, Any]) -> None:
106
- """
107
- This function prints a table of community apps from the manifest.
108
-
109
- Parameters
110
- ----------
111
- manifest : dict[str, Any]
112
- The community apps manifest.
86
+ client : Client
87
+ The Nextmv Cloud client to use for the request.
113
88
  """
114
89
 
90
+ apps = list_community_apps(client)
115
91
  table = Table("Name", "Type", "Latest", "Description", border_style="cyan", header_style="cyan")
116
- for app in manifest.get("apps", []):
92
+ for app in apps:
117
93
  table.add_row(
118
- app.get("name", ""),
119
- app.get("type", ""),
120
- app.get("latest_app_version", ""),
121
- app.get("description", ""),
94
+ app.name,
95
+ app.app_type,
96
+ app.latest_app_version if app.latest_app_version is not None else "",
97
+ app.description,
122
98
  )
123
99
 
124
100
  console.print(table)
125
101
 
126
102
 
127
- def apps_list(manifest: dict[str, Any]) -> None:
103
+ def _apps_list(client: Client) -> None:
128
104
  """
129
- This function prints a flat list of community app names from the manifest.
105
+ This function prints a flat list of community app names.
130
106
 
131
107
  Parameters
132
108
  ----------
133
- manifest : dict[str, Any]
134
- The community apps manifest.
109
+ client : Client
110
+ The Nextmv Cloud client to use for the request.
135
111
  """
136
112
 
137
- names = [app.get("name", "") for app in manifest.get("apps", [])]
113
+ apps = list_community_apps(client)
114
+ names = [app.name for app in apps]
138
115
  print("\n".join(names))
139
116
 
140
117
 
141
- def versions_table(manifest: dict[str, Any], app: str) -> None:
118
+ def _versions_table(client: Client, app: str) -> None:
142
119
  """
143
120
  This function prints a table of versions for a specific community app.
144
121
 
145
122
  Parameters
146
123
  ----------
147
- manifest : dict[str, Any]
148
- The community apps manifest.
124
+ client : Client
125
+ The Nextmv Cloud client to use for the request.
149
126
  app : str
150
127
  The name of the community app.
151
128
  """
152
129
 
153
- app_obj = find_app(manifest, app)
154
- latest_version = app_obj.get("latest_app_version", "")
130
+ comm_app = _find_app(client, app)
131
+ latest_version = comm_app.latest_app_version if comm_app.latest_app_version is not None else ""
155
132
 
156
133
  # Add the latest version with indicator
157
134
  table = Table("Version", "Latest?", border_style="cyan", header_style="cyan")
@@ -159,7 +136,7 @@ def versions_table(manifest: dict[str, Any], app: str) -> None:
159
136
  table.add_row("", "") # Empty row to separate latest from others.
160
137
 
161
138
  # Add all other versions (excluding the latest)
162
- versions = app_obj.get("app_versions", [])
139
+ versions = comm_app.app_versions if comm_app.app_versions is not None else []
163
140
  for version in versions:
164
141
  if version != latest_version:
165
142
  table.add_row(version, "")
@@ -167,99 +144,52 @@ def versions_table(manifest: dict[str, Any], app: str) -> None:
167
144
  console.print(table)
168
145
 
169
146
 
170
- def versions_list(manifest: dict[str, Any], app: str) -> None:
147
+ def _versions_list(client: Client, app: str) -> None:
171
148
  """
172
149
  This function prints a flat list of versions for a specific community app.
173
150
 
174
151
  Parameters
175
152
  ----------
176
- manifest : dict[str, Any]
177
- The community apps manifest.
153
+ client : Client
154
+ The Nextmv Cloud client to use for the request.
178
155
  app : str
179
156
  The name of the community app.
180
157
  """
181
158
 
182
- app_obj = find_app(manifest, app)
183
- versions = app_obj.get("app_versions", [])
159
+ comm_app = _find_app(client, app)
160
+ versions = comm_app.app_versions if comm_app.app_versions is not None else []
184
161
 
185
162
  versions_output = ""
186
163
  for version in versions:
187
164
  versions_output += f"{version}\n"
188
165
 
189
- print("\n".join(app_obj.get("app_versions", [])))
190
-
191
-
192
- def download_file(
193
- directory: str,
194
- file: str,
195
- profile: str | None = None,
196
- ) -> requests.Response:
197
- """
198
- Gets a file from an internal bucket and return it.
199
-
200
- Parameters
201
- ----------
202
- directory : str
203
- The directory in the bucket where the file is located.
204
- file : str
205
- The name of the file to download.
206
- profile : str | None
207
- The profile name to use. If None, the default profile is used.
208
-
209
- Returns
210
- -------
211
- requests.Response
212
- The response object containing the file data.
213
-
214
- Raises
215
- requests.HTTPError
216
- If the response status code is not 2xx.
217
- """
218
-
219
- client = build_client(profile)
220
-
221
- # Request the download URL for the file.
222
- response = client.request(
223
- method="GET",
224
- endpoint="v0/internal/tools",
225
- headers=client.headers | {"request-source": "cli"}, # Pass `client.headers` to preserve auth.
226
- query_params={"file": f"{directory}/{file}"},
227
- )
228
-
229
- # Use the URL obtained to download the file.
230
- body = response.json()
231
- download_response = client.request(
232
- method="GET",
233
- endpoint=body.get("url"),
234
- headers={"Content-Type": "application/json"},
235
- )
236
-
237
- return download_response
166
+ print("\n".join(versions_output))
238
167
 
239
168
 
240
- def find_app(manifest: dict[str, Any], app: str) -> dict[str, Any] | None:
169
+ def _find_app(client: Client, app: str) -> CommunityApp | None:
241
170
  """
242
171
  Finds and returns a community app from the manifest by its name.
243
172
 
244
173
  Parameters
245
174
  ----------
246
- manifest : dict[str, Any]
247
- The community apps manifest.
175
+ client : Client
176
+ The Nextmv Cloud client to use for the request.
248
177
  app : str
249
178
  The name of the community app to find.
250
179
 
251
180
  Returns
252
181
  -------
253
- dict[str, Any] | None
254
- The community app dictionary if found, otherwise None.
182
+ CommunityApp | None
183
+ The community app if found, otherwise None.
255
184
  """
256
185
 
257
- for manifest_app in manifest.get("apps", []):
258
- if manifest_app.get("name", "") == app:
259
- return manifest_app
186
+ comm_apps = list_community_apps(client)
187
+ for comm_app in comm_apps:
188
+ if comm_app.name == app:
189
+ return comm_app
260
190
 
261
191
  # We don't use error() here to allow printing something before exiting.
262
192
  rich.print(f"[red]Error:[/red] Community app [magenta]{app}[/magenta] was not found. Here are the available apps:")
263
- apps_table(manifest)
193
+ _apps_table(client)
264
194
 
265
195
  raise typer.Exit(code=1)
nextmv/cloud/__init__.py CHANGED
@@ -30,6 +30,9 @@ from .batch_experiment import BatchExperimentRun as BatchExperimentRun
30
30
  from .batch_experiment import ExperimentStatus as ExperimentStatus
31
31
  from .client import Client as Client
32
32
  from .client import get_size as get_size
33
+ from .community import CommunityApp as CommunityApp
34
+ from .community import clone_community_app as clone_community_app
35
+ from .community import list_community_apps as list_community_apps
33
36
  from .ensemble import EnsembleDefinition as EnsembleDefinition
34
37
  from .ensemble import EvaluationRule as EvaluationRule
35
38
  from .ensemble import RuleObjective as RuleObjective
@@ -55,6 +58,7 @@ from .secrets import SecretType as SecretType
55
58
  from .shadow import ShadowTest as ShadowTest
56
59
  from .shadow import ShadowTestMetadata as ShadowTestMetadata
57
60
  from .shadow import StartEvents as StartEvents
61
+ from .shadow import StopIntent as StopIntent
58
62
  from .shadow import TerminationEvents as TerminationEvents
59
63
  from .shadow import TestComparison as TestComparison
60
64
  from .switchback import SwitchbackPlan as SwitchbackPlan
@@ -21,7 +21,7 @@ list_application
21
21
  import json
22
22
  import shutil
23
23
  import sys
24
- from datetime import datetime, timezone
24
+ from datetime import datetime
25
25
  from enum import Enum
26
26
  from typing import Any
27
27
 
@@ -46,9 +46,7 @@ from nextmv.cloud.application._switchback import ApplicationSwitchbackMixin
46
46
  from nextmv.cloud.application._utils import _is_not_exist_error
47
47
  from nextmv.cloud.application._version import ApplicationVersionMixin
48
48
  from nextmv.cloud.client import Client
49
- from nextmv.cloud.instance import Instance
50
49
  from nextmv.cloud.url import UploadURL
51
- from nextmv.cloud.version import Version
52
50
  from nextmv.logger import log
53
51
  from nextmv.manifest import Manifest
54
52
  from nextmv.model import Model, ModelConfiguration
@@ -407,14 +405,6 @@ class Application(
407
405
  model: Model | None = None,
408
406
  model_configuration: ModelConfiguration | None = None,
409
407
  rich_print: bool = False,
410
- auto_create: bool = False,
411
- version_id: str | None = None,
412
- version_name: str | None = None,
413
- version_description: str | None = None,
414
- instance_id: str | None = None,
415
- instance_name: str | None = None,
416
- instance_description: str | None = None,
417
- update_default_instance: bool = False,
418
408
  ) -> None:
419
409
  """
420
410
  Push an app to Nextmv Cloud.
@@ -432,17 +422,6 @@ class Application(
432
422
  `nextmv.Model`. The model is encoded, some dependencies and
433
423
  accompanying files are packaged, and the app is pushed to Nextmv Cloud.
434
424
 
435
- By default, this function only pushes the app. If you want to
436
- automatically create a new version and instance after pushing, set
437
- `auto_create=True`. The `version_id`, `version_name`, and
438
- `version_description` arguments allow you to customize the new version
439
- that is created. If not specified, defaults will be generated (random
440
- ID, timestamped name/description). Similarly, `instance_id`,
441
- `instance_name`, and `instance_description` can be used to customize
442
- the new instance. If `update_default_instance` is True, the
443
- application's default instance will be updated to the newly created
444
- instance.
445
-
446
425
  Parameters
447
426
  ----------
448
427
  manifest : Optional[Manifest], default=None
@@ -461,30 +440,6 @@ class Application(
461
440
  with `model`.
462
441
  rich_print : bool, default=False
463
442
  Whether to use rich printing when verbose output is enabled.
464
- auto_create : bool, default=False
465
- If True, automatically create a new version and instance after
466
- pushing the app.
467
- version_id : Optional[str], default=None
468
- ID of the version to create after pushing the app. If None, a unique
469
- ID will be generated.
470
- version_name : Optional[str], default=None
471
- Name of the version to create after pushing the app. If None, a
472
- name will be generated.
473
- version_description : Optional[str], default=None
474
- Description of the version to create after pushing the app. If None, a
475
- generic description with a timestamp will be generated.
476
- instance_id : Optional[str], default=None
477
- ID of the instance to create after pushing the app. If None, a unique
478
- ID will be generated.
479
- instance_name : Optional[str], default=None
480
- Name of the instance to create after pushing the app. If None, a
481
- name will be generated.
482
- instance_description : Optional[str], default=None
483
- Description of the instance to create after pushing the app. If None,
484
- a generic description with a timestamp will be generated.
485
- update_default_instance : bool, default=False
486
- If True, update the application's default instance to the newly
487
- created instance.
488
443
 
489
444
  Raises
490
445
  ------
@@ -588,44 +543,6 @@ class Application(
588
543
  except OSError as e:
589
544
  raise Exception(f"error deleting output directory: {e}") from e
590
545
 
591
- if not auto_create:
592
- return
593
-
594
- if verbose:
595
- if rich_print:
596
- rich.print(
597
- f":hourglass_flowing_sand: Push completed for Nextmv application [magenta]{self.id}[/magenta], "
598
- "creating a new version and instance...",
599
- file=sys.stderr,
600
- )
601
- else:
602
- log("⌛️ Push completed for the Nextmv application, creating a new version and instance...")
603
-
604
- now = datetime.now(timezone.utc)
605
- version = self.__version_on_push(
606
- now=now,
607
- version_id=version_id,
608
- version_name=version_name,
609
- version_description=version_description,
610
- verbose=verbose,
611
- rich_print=rich_print,
612
- )
613
- instance = self.__instance_on_push(
614
- now=now,
615
- version_id=version.id,
616
- instance_id=instance_id,
617
- instance_name=instance_name,
618
- instance_description=instance_description,
619
- verbose=verbose,
620
- rich_print=rich_print,
621
- )
622
- self.__update_on_push(
623
- instance=instance,
624
- update_default_instance=update_default_instance,
625
- verbose=verbose,
626
- rich_print=rich_print,
627
- )
628
-
629
546
  def update(
630
547
  self,
631
548
  name: str | None = None,
@@ -867,8 +784,6 @@ class Application(
867
784
  output_config = multi_config["output_configuration"] = {}
868
785
  if content.multi_file.output.statistics:
869
786
  output_config["statistics_path"] = content.multi_file.output.statistics
870
- if content.multi_file.output.metrics:
871
- output_config["metrics_path"] = content.multi_file.output.metrics
872
787
  if content.multi_file.output.assets:
873
788
  output_config["assets_path"] = content.multi_file.output.assets
874
789
  if content.multi_file.output.solutions:
@@ -943,120 +858,6 @@ class Application(
943
858
  log(f'💥️ Successfully pushed to application: "{self.id}".')
944
859
  log(json.dumps(data, indent=2))
945
860
 
946
- def __version_on_push(
947
- self,
948
- now: datetime,
949
- version_id: str | None = None,
950
- version_name: str | None = None,
951
- version_description: str | None = None,
952
- verbose: bool = False,
953
- rich_print: bool = False,
954
- ) -> Version:
955
- if version_description is None or version_description == "":
956
- version_description = f"Version created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
957
-
958
- version = self.new_version(
959
- id=version_id,
960
- name=version_name,
961
- description=version_description,
962
- )
963
- version_dict = version.to_dict()
964
-
965
- if not verbose:
966
- return version
967
-
968
- if rich_print:
969
- rich.print(
970
- f":white_check_mark: Automatically created new version [magenta]{version.id}[/magenta].",
971
- file=sys.stderr,
972
- )
973
- rich.print_json(data=version_dict)
974
-
975
- return version
976
-
977
- log(f'✅ Automatically created new version "{version.id}".')
978
- log(json.dumps(version_dict, indent=2))
979
-
980
- return version
981
-
982
- def __instance_on_push(
983
- self,
984
- now: datetime,
985
- version_id: str,
986
- instance_id: str | None = None,
987
- instance_name: str | None = None,
988
- instance_description: str | None = None,
989
- verbose: bool = False,
990
- rich_print: bool = False,
991
- ) -> Instance:
992
- if instance_description is None or instance_description == "":
993
- instance_description = f"Instance created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
994
-
995
- instance = self.new_instance(
996
- version_id=version_id,
997
- id=instance_id,
998
- name=instance_name,
999
- description=instance_description,
1000
- )
1001
- instance_dict = instance.to_dict()
1002
-
1003
- if not verbose:
1004
- return instance
1005
-
1006
- if rich_print:
1007
- rich.print(
1008
- f":white_check_mark: Automatically created new instance [magenta]{instance.id}[/magenta] "
1009
- f"using version [magenta]{version_id}[/magenta].",
1010
- file=sys.stderr,
1011
- )
1012
- rich.print_json(data=instance_dict)
1013
-
1014
- return instance
1015
-
1016
- log(f'✅ Automatically created new instance "{instance.id}" using version "{version_id}".')
1017
- log(json.dumps(instance_dict, indent=2))
1018
-
1019
- return instance
1020
-
1021
- def __update_on_push(
1022
- self,
1023
- instance: Instance,
1024
- update_default_instance: bool = False,
1025
- verbose: bool = False,
1026
- rich_print: bool = False,
1027
- ) -> None:
1028
- if not update_default_instance:
1029
- return
1030
-
1031
- if verbose:
1032
- if rich_print:
1033
- rich.print(
1034
- f":hourglass_flowing_sand: Updating default instance for app [magenta]{self.id}[/magenta]...",
1035
- file=sys.stderr,
1036
- )
1037
- else:
1038
- log("⌛️ Updating default instance for the Nextmv application...")
1039
-
1040
- updated_app = self.update(default_instance_id=instance.id)
1041
- if not verbose:
1042
- return
1043
-
1044
- if rich_print:
1045
- rich.print(
1046
- f":white_check_mark: Updated default instance to "
1047
- f"[magenta]{updated_app.default_instance_id}[/magenta] for application "
1048
- f"[magenta]{self.id}[/magenta].",
1049
- file=sys.stderr,
1050
- )
1051
- rich.print_json(data=updated_app.to_dict())
1052
-
1053
- return
1054
-
1055
- log(
1056
- f'✅ Updated default instance to "{updated_app.default_instance_id}" for application "{self.id}".',
1057
- )
1058
- log(json.dumps(updated_app.to_dict(), indent=2))
1059
-
1060
861
 
1061
862
  def list_applications(client: Client) -> list[Application]:
1062
863
  """
@@ -6,6 +6,7 @@ from datetime import datetime
6
6
  from typing import TYPE_CHECKING
7
7
 
8
8
  from nextmv.cloud.input_set import InputSet, ManagedInput
9
+ from nextmv.safe import safe_id
9
10
 
10
11
  if TYPE_CHECKING:
11
12
  from . import Application
@@ -16,6 +17,32 @@ class ApplicationInputSetMixin:
16
17
  Mixin class for managing app input sets within an application.
17
18
  """
18
19
 
20
+ def delete_input_set(self: "Application", input_set_id: str) -> None:
21
+ """
22
+ Delete an input set.
23
+
24
+ Deletes an input set along with all the associated information.
25
+
26
+ Parameters
27
+ ----------
28
+ input_set_id : str
29
+ ID of the input set to delete.
30
+
31
+ Raises
32
+ ------
33
+ requests.HTTPError
34
+ If the response status code is not 2xx.
35
+
36
+ Examples
37
+ --------
38
+ >>> app.delete_input_set("input-set-123")
39
+ """
40
+
41
+ _ = self.client.request(
42
+ method="DELETE",
43
+ endpoint=f"{self.experiments_endpoint}/inputsets/{input_set_id}",
44
+ )
45
+
19
46
  def input_set(self: "Application", input_set_id: str) -> InputSet:
20
47
  """
21
48
  Get an input set.
@@ -81,8 +108,8 @@ class ApplicationInputSetMixin:
81
108
 
82
109
  def new_input_set(
83
110
  self: "Application",
84
- id: str,
85
- name: str,
111
+ id: str | None = None,
112
+ name: str | None = None,
86
113
  description: str | None = None,
87
114
  end_time: datetime | None = None,
88
115
  instance_id: str | None = None,
@@ -107,10 +134,11 @@ class ApplicationInputSetMixin:
107
134
 
108
135
  Parameters
109
136
  ----------
110
- id: str
111
- ID of the input set
112
- name: str
113
- Name of the input set.
137
+ id: str | None = None
138
+ ID of the input set, will be generated if not provided.
139
+ name: str | None = None
140
+ Name of the input set. If not provided, the ID will be used as
141
+ the name.
114
142
  description: Optional[str]
115
143
  Optional description of the input set.
116
144
  end_time: Optional[datetime]
@@ -145,6 +173,14 @@ class ApplicationInputSetMixin:
145
173
  If the response status code is not 2xx.
146
174
  """
147
175
 
176
+ # Generate ID if not provided
177
+ if id is None or id == "":
178
+ id = safe_id("input-set")
179
+
180
+ # Use ID as name if name not provided
181
+ if name is None or name == "":
182
+ name = id
183
+
148
184
  payload = {
149
185
  "id": id,
150
186
  "name": name,
@@ -21,14 +21,7 @@ from nextmv.cloud.url import DownloadURL
21
21
  from nextmv.input import Input, InputFormat
22
22
  from nextmv.logger import log
23
23
  from nextmv.options import Options
24
- from nextmv.output import (
25
- ASSETS_KEY,
26
- STATISTICS_KEY,
27
- Asset,
28
- Output,
29
- OutputFormat,
30
- Statistics,
31
- )
24
+ from nextmv.output import ASSETS_KEY, STATISTICS_KEY, Asset, Output, OutputFormat, Statistics
32
25
  from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
33
26
  from nextmv.run import (
34
27
  ExternalRunResult,
@@ -4,7 +4,7 @@ Application mixin for managing shadow tests.
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from nextmv.cloud.shadow import ShadowTest, ShadowTestMetadata, StartEvents, TerminationEvents
7
+ from nextmv.cloud.shadow import ShadowTest, ShadowTestMetadata, StartEvents, StopIntent, TerminationEvents
8
8
  from nextmv.run import Run
9
9
  from nextmv.safe import safe_id
10
10
 
@@ -248,7 +248,7 @@ class ApplicationShadowMixin:
248
248
  endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}/start",
249
249
  )
250
250
 
251
- def stop_shadow_test(self: "Application", shadow_test_id: str) -> None:
251
+ def stop_shadow_test(self: "Application", shadow_test_id: str, intent: StopIntent) -> None:
252
252
  """
253
253
  Stop a shadow test. The test should already have started before using
254
254
  this method.
@@ -257,16 +257,22 @@ class ApplicationShadowMixin:
257
257
  ----------
258
258
  shadow_test_id : str
259
259
  ID of the shadow test to stop.
260
-
260
+ intent : StopIntent
261
+ Intent for stopping the shadow test.
261
262
  Raises
262
263
  ------
263
264
  requests.HTTPError
264
265
  If the response status code is not 2xx.
265
266
  """
266
267
 
268
+ payload = {
269
+ "intent": intent.value,
270
+ }
271
+
267
272
  _ = self.client.request(
268
273
  method="PUT",
269
274
  endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}/stop",
275
+ payload=payload,
270
276
  )
271
277
 
272
278
  def update_shadow_test(