nextmv 0.39.0.dev1__py3-none-any.whl → 1.0.0__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 (161) hide show
  1. nextmv/__about__.py +1 -1
  2. nextmv/__entrypoint__.py +1 -2
  3. nextmv/__init__.py +2 -4
  4. nextmv/cli/CONTRIBUTING.md +583 -0
  5. nextmv/cli/cloud/__init__.py +49 -0
  6. nextmv/cli/cloud/acceptance/__init__.py +27 -0
  7. nextmv/cli/cloud/acceptance/create.py +391 -0
  8. nextmv/cli/cloud/acceptance/delete.py +64 -0
  9. nextmv/cli/cloud/acceptance/get.py +103 -0
  10. nextmv/cli/cloud/acceptance/list.py +62 -0
  11. nextmv/cli/cloud/acceptance/update.py +95 -0
  12. nextmv/cli/cloud/account/__init__.py +28 -0
  13. nextmv/cli/cloud/account/create.py +83 -0
  14. nextmv/cli/cloud/account/delete.py +59 -0
  15. nextmv/cli/cloud/account/get.py +66 -0
  16. nextmv/cli/cloud/account/update.py +70 -0
  17. nextmv/cli/cloud/app/__init__.py +35 -0
  18. nextmv/cli/cloud/app/create.py +140 -0
  19. nextmv/cli/cloud/app/delete.py +57 -0
  20. nextmv/cli/cloud/app/exists.py +44 -0
  21. nextmv/cli/cloud/app/get.py +66 -0
  22. nextmv/cli/cloud/app/list.py +61 -0
  23. nextmv/cli/cloud/app/push.py +432 -0
  24. nextmv/cli/cloud/app/update.py +124 -0
  25. nextmv/cli/cloud/batch/__init__.py +29 -0
  26. nextmv/cli/cloud/batch/create.py +452 -0
  27. nextmv/cli/cloud/batch/delete.py +64 -0
  28. nextmv/cli/cloud/batch/get.py +104 -0
  29. nextmv/cli/cloud/batch/list.py +63 -0
  30. nextmv/cli/cloud/batch/metadata.py +66 -0
  31. nextmv/cli/cloud/batch/update.py +95 -0
  32. nextmv/cli/cloud/data/__init__.py +26 -0
  33. nextmv/cli/cloud/data/upload.py +162 -0
  34. nextmv/cli/cloud/ensemble/__init__.py +33 -0
  35. nextmv/cli/cloud/ensemble/create.py +413 -0
  36. nextmv/cli/cloud/ensemble/delete.py +63 -0
  37. nextmv/cli/cloud/ensemble/get.py +65 -0
  38. nextmv/cli/cloud/ensemble/list.py +63 -0
  39. nextmv/cli/cloud/ensemble/update.py +103 -0
  40. nextmv/cli/cloud/input_set/__init__.py +32 -0
  41. nextmv/cli/cloud/input_set/create.py +168 -0
  42. nextmv/cli/cloud/input_set/delete.py +64 -0
  43. nextmv/cli/cloud/input_set/get.py +63 -0
  44. nextmv/cli/cloud/input_set/list.py +63 -0
  45. nextmv/cli/cloud/input_set/update.py +123 -0
  46. nextmv/cli/cloud/instance/__init__.py +35 -0
  47. nextmv/cli/cloud/instance/create.py +289 -0
  48. nextmv/cli/cloud/instance/delete.py +61 -0
  49. nextmv/cli/cloud/instance/exists.py +39 -0
  50. nextmv/cli/cloud/instance/get.py +62 -0
  51. nextmv/cli/cloud/instance/list.py +60 -0
  52. nextmv/cli/cloud/instance/update.py +216 -0
  53. nextmv/cli/cloud/managed_input/__init__.py +31 -0
  54. nextmv/cli/cloud/managed_input/create.py +144 -0
  55. nextmv/cli/cloud/managed_input/delete.py +64 -0
  56. nextmv/cli/cloud/managed_input/get.py +63 -0
  57. nextmv/cli/cloud/managed_input/list.py +60 -0
  58. nextmv/cli/cloud/managed_input/update.py +97 -0
  59. nextmv/cli/cloud/run/__init__.py +37 -0
  60. nextmv/cli/cloud/run/cancel.py +37 -0
  61. nextmv/cli/cloud/run/create.py +524 -0
  62. nextmv/cli/cloud/run/get.py +199 -0
  63. nextmv/cli/cloud/run/input.py +86 -0
  64. nextmv/cli/cloud/run/list.py +80 -0
  65. nextmv/cli/cloud/run/logs.py +166 -0
  66. nextmv/cli/cloud/run/metadata.py +67 -0
  67. nextmv/cli/cloud/run/track.py +500 -0
  68. nextmv/cli/cloud/scenario/__init__.py +29 -0
  69. nextmv/cli/cloud/scenario/create.py +451 -0
  70. nextmv/cli/cloud/scenario/delete.py +61 -0
  71. nextmv/cli/cloud/scenario/get.py +102 -0
  72. nextmv/cli/cloud/scenario/list.py +63 -0
  73. nextmv/cli/cloud/scenario/metadata.py +67 -0
  74. nextmv/cli/cloud/scenario/update.py +93 -0
  75. nextmv/cli/cloud/secrets/__init__.py +33 -0
  76. nextmv/cli/cloud/secrets/create.py +206 -0
  77. nextmv/cli/cloud/secrets/delete.py +63 -0
  78. nextmv/cli/cloud/secrets/get.py +66 -0
  79. nextmv/cli/cloud/secrets/list.py +60 -0
  80. nextmv/cli/cloud/secrets/update.py +144 -0
  81. nextmv/cli/cloud/shadow/__init__.py +33 -0
  82. nextmv/cli/cloud/shadow/create.py +184 -0
  83. nextmv/cli/cloud/shadow/delete.py +64 -0
  84. nextmv/cli/cloud/shadow/get.py +61 -0
  85. nextmv/cli/cloud/shadow/list.py +63 -0
  86. nextmv/cli/cloud/shadow/metadata.py +66 -0
  87. nextmv/cli/cloud/shadow/start.py +43 -0
  88. nextmv/cli/cloud/shadow/stop.py +53 -0
  89. nextmv/cli/cloud/shadow/update.py +96 -0
  90. nextmv/cli/cloud/switchback/__init__.py +33 -0
  91. nextmv/cli/cloud/switchback/create.py +151 -0
  92. nextmv/cli/cloud/switchback/delete.py +64 -0
  93. nextmv/cli/cloud/switchback/get.py +62 -0
  94. nextmv/cli/cloud/switchback/list.py +63 -0
  95. nextmv/cli/cloud/switchback/metadata.py +68 -0
  96. nextmv/cli/cloud/switchback/start.py +43 -0
  97. nextmv/cli/cloud/switchback/stop.py +53 -0
  98. nextmv/cli/cloud/switchback/update.py +96 -0
  99. nextmv/cli/cloud/upload/__init__.py +22 -0
  100. nextmv/cli/cloud/upload/create.py +39 -0
  101. nextmv/cli/cloud/version/__init__.py +33 -0
  102. nextmv/cli/cloud/version/create.py +96 -0
  103. nextmv/cli/cloud/version/delete.py +61 -0
  104. nextmv/cli/cloud/version/exists.py +39 -0
  105. nextmv/cli/cloud/version/get.py +62 -0
  106. nextmv/cli/cloud/version/list.py +60 -0
  107. nextmv/cli/cloud/version/update.py +92 -0
  108. nextmv/cli/community/__init__.py +24 -0
  109. nextmv/cli/community/clone.py +86 -0
  110. nextmv/cli/community/list.py +200 -0
  111. nextmv/cli/configuration/__init__.py +23 -0
  112. nextmv/cli/configuration/config.py +228 -0
  113. nextmv/cli/configuration/create.py +94 -0
  114. nextmv/cli/configuration/delete.py +67 -0
  115. nextmv/cli/configuration/list.py +77 -0
  116. nextmv/cli/confirm.py +34 -0
  117. nextmv/cli/main.py +161 -3
  118. nextmv/cli/message.py +170 -0
  119. nextmv/cli/options.py +220 -0
  120. nextmv/cli/version.py +22 -2
  121. nextmv/cloud/__init__.py +17 -38
  122. nextmv/cloud/acceptance_test.py +20 -83
  123. nextmv/cloud/account.py +269 -30
  124. nextmv/cloud/application/__init__.py +898 -0
  125. nextmv/cloud/application/_acceptance.py +424 -0
  126. nextmv/cloud/application/_batch_scenario.py +845 -0
  127. nextmv/cloud/application/_ensemble.py +251 -0
  128. nextmv/cloud/application/_input_set.py +263 -0
  129. nextmv/cloud/application/_instance.py +289 -0
  130. nextmv/cloud/application/_managed_input.py +227 -0
  131. nextmv/cloud/application/_run.py +1393 -0
  132. nextmv/cloud/application/_secrets.py +294 -0
  133. nextmv/cloud/application/_shadow.py +320 -0
  134. nextmv/cloud/application/_switchback.py +332 -0
  135. nextmv/cloud/application/_utils.py +54 -0
  136. nextmv/cloud/application/_version.py +304 -0
  137. nextmv/cloud/batch_experiment.py +6 -2
  138. nextmv/cloud/community.py +446 -0
  139. nextmv/cloud/instance.py +11 -1
  140. nextmv/cloud/integration.py +8 -5
  141. nextmv/cloud/package.py +50 -9
  142. nextmv/cloud/shadow.py +254 -0
  143. nextmv/cloud/switchback.py +228 -0
  144. nextmv/deprecated.py +5 -3
  145. nextmv/input.py +20 -88
  146. nextmv/local/application.py +3 -15
  147. nextmv/local/runner.py +1 -1
  148. nextmv/model.py +50 -11
  149. nextmv/options.py +11 -256
  150. nextmv/output.py +0 -62
  151. nextmv/polling.py +54 -16
  152. nextmv/run.py +84 -37
  153. nextmv/status.py +1 -51
  154. {nextmv-0.39.0.dev1.dist-info → nextmv-1.0.0.dist-info}/METADATA +37 -11
  155. nextmv-1.0.0.dist-info/RECORD +185 -0
  156. nextmv-1.0.0.dist-info/entry_points.txt +2 -0
  157. nextmv/cloud/application.py +0 -4204
  158. nextmv-0.39.0.dev1.dist-info/RECORD +0 -55
  159. nextmv-0.39.0.dev1.dist-info/entry_points.txt +0 -2
  160. {nextmv-0.39.0.dev1.dist-info → nextmv-1.0.0.dist-info}/WHEEL +0 -0
  161. {nextmv-0.39.0.dev1.dist-info → nextmv-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,294 @@
1
+ """
2
+ Application mixin for managing app secrets.
3
+ """
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from nextmv.cloud.secrets import Secret, SecretsCollection, SecretsCollectionSummary
8
+ from nextmv.safe import safe_id
9
+
10
+ if TYPE_CHECKING:
11
+ from . import Application
12
+
13
+
14
+ class ApplicationSecretsMixin:
15
+ """
16
+ Mixin class for managing app secrets within an application.
17
+ """
18
+
19
+ def delete_secrets_collection(self: "Application", secrets_collection_id: str) -> None:
20
+ """
21
+ Delete a secrets collection.
22
+
23
+ Parameters
24
+ ----------
25
+ secrets_collection_id : str
26
+ ID of the secrets collection to delete.
27
+
28
+ Raises
29
+ ------
30
+ requests.HTTPError
31
+ If the response status code is not 2xx.
32
+
33
+ Examples
34
+ --------
35
+ >>> app.delete_secrets_collection("secrets-123")
36
+ """
37
+
38
+ _ = self.client.request(
39
+ method="DELETE",
40
+ endpoint=f"{self.endpoint}/secrets/{secrets_collection_id}",
41
+ )
42
+
43
+ def list_secrets_collections(self: "Application") -> list[SecretsCollectionSummary]:
44
+ """
45
+ List all secrets collections.
46
+
47
+ Returns
48
+ -------
49
+ list[SecretsCollectionSummary]
50
+ List of all secrets collections associated with this application.
51
+
52
+ Raises
53
+ ------
54
+ requests.HTTPError
55
+ If the response status code is not 2xx.
56
+
57
+ Examples
58
+ --------
59
+ >>> collections = app.list_secrets_collections()
60
+ >>> for collection in collections:
61
+ ... print(collection.name)
62
+ 'API Keys'
63
+ 'Database Credentials'
64
+ """
65
+
66
+ response = self.client.request(
67
+ method="GET",
68
+ endpoint=f"{self.endpoint}/secrets",
69
+ )
70
+
71
+ return [SecretsCollectionSummary.from_dict(secrets) for secrets in response.json()["items"]]
72
+
73
+ def new_secrets_collection(
74
+ self: "Application",
75
+ secrets: list[Secret],
76
+ id: str | None = None,
77
+ name: str | None = None,
78
+ description: str | None = None,
79
+ ) -> SecretsCollectionSummary:
80
+ """
81
+ Create a new secrets collection.
82
+
83
+ This method creates a new secrets collection with the provided secrets.
84
+ A secrets collection is a group of key-value pairs that can be used by
85
+ your application instances during execution. If no secrets are
86
+ provided, a ValueError is raised. If the `id` or `name` parameters are
87
+ not provided, they will be generated based on a unique ID.
88
+
89
+ Parameters
90
+ ----------
91
+ secrets : list[Secret]
92
+ List of secrets to use for the secrets collection. Each secret
93
+ should be an instance of the Secret class containing a key and
94
+ value.
95
+ id : str | None, default=None
96
+ ID of the secrets collection. If not provided, a unique ID will be
97
+ generated.
98
+ name : str | None, default=None
99
+ Name of the secrets collection. If not provided, the ID will be
100
+ used.
101
+ description : Optional[str], default=None
102
+ Description of the secrets collection.
103
+
104
+ Returns
105
+ -------
106
+ SecretsCollectionSummary
107
+ Summary of the secrets collection including its metadata.
108
+
109
+ Raises
110
+ ------
111
+ ValueError
112
+ If no secrets are provided.
113
+ requests.HTTPError
114
+ If the response status code is not 2xx.
115
+
116
+ Examples
117
+ --------
118
+ >>> # Create a new secrets collection with API keys
119
+ >>> from nextmv.cloud import Secret
120
+ >>> secrets = [
121
+ ... Secret(
122
+ ... location="API_KEY",
123
+ ... value="your-api-key",
124
+ ... secret_type=SecretType.ENV,
125
+ ... ),
126
+ ... Secret(
127
+ ... location="DATABASE_URL",
128
+ ... value="your-database-url",
129
+ ... secret_type=SecretType.ENV,
130
+ ... ),
131
+ ... ]
132
+ >>> collection = app.new_secrets_collection(
133
+ ... secrets=secrets,
134
+ ... id="api-secrets",
135
+ ... name="API Secrets",
136
+ ... description="Collection of API secrets for external services"
137
+ ... )
138
+ >>> print(collection.id)
139
+ 'api-secrets'
140
+ """
141
+
142
+ if len(secrets) == 0:
143
+ raise ValueError("secrets must be provided")
144
+
145
+ if id is None or id == "":
146
+ id = safe_id(prefix="secrets")
147
+ if name is None or name == "":
148
+ name = id
149
+
150
+ payload = {
151
+ "id": id,
152
+ "name": name,
153
+ "secrets": [secret.to_dict() for secret in secrets],
154
+ }
155
+
156
+ if description is not None:
157
+ payload["description"] = description
158
+
159
+ response = self.client.request(
160
+ method="POST",
161
+ endpoint=f"{self.endpoint}/secrets",
162
+ payload=payload,
163
+ )
164
+
165
+ return SecretsCollectionSummary.from_dict(response.json())
166
+
167
+ def secrets_collection(self: "Application", secrets_collection_id: str) -> SecretsCollection:
168
+ """
169
+ Get a secrets collection.
170
+
171
+ This method retrieves a secrets collection by its ID. A secrets collection
172
+ is a group of key-value pairs that can be used by your application
173
+ instances during execution.
174
+
175
+ Parameters
176
+ ----------
177
+ secrets_collection_id : str
178
+ ID of the secrets collection to retrieve.
179
+
180
+ Returns
181
+ -------
182
+ SecretsCollection
183
+ The requested secrets collection, including all secret values
184
+ and metadata.
185
+
186
+ Raises
187
+ ------
188
+ requests.HTTPError
189
+ If the response status code is not 2xx.
190
+
191
+ Examples
192
+ --------
193
+ >>> # Retrieve a secrets collection
194
+ >>> collection = app.secrets_collection("api-secrets")
195
+ >>> print(collection.name)
196
+ 'API Secrets'
197
+ >>> print(len(collection.secrets))
198
+ 2
199
+ >>> for secret in collection.secrets:
200
+ ... print(secret.location)
201
+ 'API_KEY'
202
+ 'DATABASE_URL'
203
+ """
204
+
205
+ response = self.client.request(
206
+ method="GET",
207
+ endpoint=f"{self.endpoint}/secrets/{secrets_collection_id}",
208
+ )
209
+
210
+ return SecretsCollection.from_dict(response.json())
211
+
212
+ def update_secrets_collection(
213
+ self: "Application",
214
+ secrets_collection_id: str,
215
+ name: str | None = None,
216
+ description: str | None = None,
217
+ secrets: list[Secret | dict[str, Any]] | None = None,
218
+ ) -> SecretsCollectionSummary:
219
+ """
220
+ Update a secrets collection.
221
+
222
+ This method updates an existing secrets collection with new values for name,
223
+ description, and secrets. A secrets collection is a group of key-value pairs
224
+ that can be used by your application instances during execution.
225
+
226
+ Parameters
227
+ ----------
228
+ secrets_collection_id : str
229
+ ID of the secrets collection to update.
230
+ name : Optional[str], default=None
231
+ Optional new name for the secrets collection.
232
+ description : Optional[str], default=None
233
+ Optional new description for the secrets collection.
234
+ secrets : Optional[list[Secret | dict[str, Any]]], default=None
235
+ Optional list of secrets to update. Each secret should be an
236
+ instance of the Secret class containing a key and value.
237
+
238
+ Returns
239
+ -------
240
+ SecretsCollectionSummary
241
+ Summary of the updated secrets collection including its metadata.
242
+
243
+ Raises
244
+ ------
245
+ ValueError
246
+ If no secrets are provided.
247
+ requests.HTTPError
248
+ If the response status code is not 2xx.
249
+
250
+ Examples
251
+ --------
252
+ >>> # Update an existing secrets collection
253
+ >>> from nextmv.cloud import Secret
254
+ >>> updated_secrets = [
255
+ ... Secret(key="API_KEY", value="new-api-key"),
256
+ ... Secret(key="DATABASE_URL", value="new-database-url")
257
+ ... ]
258
+ >>> updated_collection = app.update_secrets_collection(
259
+ ... secrets_collection_id="api-secrets",
260
+ ... name="Updated API Secrets",
261
+ ... description="Updated collection of API secrets",
262
+ ... secrets=updated_secrets
263
+ ... )
264
+ >>> print(updated_collection.id)
265
+ 'api-secrets'
266
+ """
267
+
268
+ collection = self.secrets_collection(secrets_collection_id)
269
+ collection_dict = collection.to_dict()
270
+ payload = collection_dict.copy()
271
+
272
+ if name is not None:
273
+ payload["name"] = name
274
+ if description is not None:
275
+ payload["description"] = description
276
+ if secrets is not None and len(secrets) > 0:
277
+ secrets_dicts = []
278
+ for ix, secret in enumerate(secrets):
279
+ if isinstance(secret, dict):
280
+ secrets_dicts.append(secret)
281
+ elif isinstance(secret, Secret):
282
+ secrets_dicts.append(secret.to_dict())
283
+ else:
284
+ raise ValueError(f"secret at index {ix} must be either a Secret or dict object")
285
+
286
+ payload["secrets"] = secrets_dicts
287
+
288
+ response = self.client.request(
289
+ method="PUT",
290
+ endpoint=f"{self.endpoint}/secrets/{secrets_collection_id}",
291
+ payload=payload,
292
+ )
293
+
294
+ return SecretsCollectionSummary.from_dict(response.json())
@@ -0,0 +1,320 @@
1
+ """
2
+ Application mixin for managing shadow tests.
3
+ """
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from nextmv.cloud.shadow import ShadowTest, ShadowTestMetadata, StartEvents, StopIntent, TerminationEvents
8
+ from nextmv.run import Run
9
+ from nextmv.safe import safe_id
10
+
11
+ if TYPE_CHECKING:
12
+ from . import Application
13
+
14
+
15
+ class ApplicationShadowMixin:
16
+ """
17
+ Mixin class for managing shadow tests within an application.
18
+ """
19
+
20
+ def shadow_test(self: "Application", shadow_test_id: str) -> ShadowTest:
21
+ """
22
+ Get a shadow test. This method also returns the runs of the shadow
23
+ test under the `.runs` attribute.
24
+
25
+ Parameters
26
+ ----------
27
+ shadow_test_id : str
28
+ ID of the shadow test.
29
+
30
+ Returns
31
+ -------
32
+ ShadowTest
33
+ The requested shadow test details.
34
+
35
+ Raises
36
+ ------
37
+ requests.HTTPError
38
+ If the response status code is not 2xx.
39
+
40
+ Examples
41
+ --------
42
+ >>> shadow_test = app.shadow_test("shadow-123")
43
+ >>> print(shadow_test.name)
44
+ 'My Shadow Test'
45
+ """
46
+
47
+ response = self.client.request(
48
+ method="GET",
49
+ endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}",
50
+ )
51
+
52
+ exp = ShadowTest.from_dict(response.json())
53
+
54
+ runs_response = self.client.request(
55
+ method="GET",
56
+ endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}/runs",
57
+ )
58
+
59
+ runs = [Run.from_dict(run) for run in runs_response.json().get("runs", [])]
60
+ exp.runs = runs
61
+
62
+ return exp
63
+
64
+ def shadow_test_metadata(self: "Application", shadow_test_id: str) -> ShadowTestMetadata:
65
+ """
66
+ Get metadata for a shadow test.
67
+
68
+ Parameters
69
+ ----------
70
+ shadow_test_id : str
71
+ ID of the shadow test.
72
+
73
+ Returns
74
+ -------
75
+ ShadowTestMetadata
76
+ The requested shadow test metadata.
77
+
78
+ Raises
79
+ ------
80
+ requests.HTTPError
81
+ If the response status code is not 2xx.
82
+
83
+ Examples
84
+ --------
85
+ >>> metadata = app.shadow_test_metadata("shadow-123")
86
+ >>> print(metadata.name)
87
+ 'My Shadow Test'
88
+ """
89
+
90
+ response = self.client.request(
91
+ method="GET",
92
+ endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}/metadata",
93
+ )
94
+
95
+ return ShadowTestMetadata.from_dict(response.json())
96
+
97
+ def delete_shadow_test(self: "Application", shadow_test_id: str) -> None:
98
+ """
99
+ Delete a shadow test.
100
+
101
+ Deletes a shadow test along with all the associated information,
102
+ such as its runs.
103
+
104
+ Parameters
105
+ ----------
106
+ shadow_test_id : str
107
+ ID of the shadow test to delete.
108
+
109
+ Raises
110
+ ------
111
+ requests.HTTPError
112
+ If the response status code is not 2xx.
113
+
114
+ Examples
115
+ --------
116
+ >>> app.delete_shadow_test("shadow-123")
117
+ """
118
+
119
+ _ = self.client.request(
120
+ method="DELETE",
121
+ endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}",
122
+ )
123
+
124
+ def list_shadow_tests(self: "Application") -> list[ShadowTest]:
125
+ """
126
+ List all shadow tests.
127
+
128
+ Returns
129
+ -------
130
+ list[ShadowTest]
131
+ List of shadow tests.
132
+
133
+ Raises
134
+ ------
135
+ requests.HTTPError
136
+ If the response status code is not 2xx.
137
+ """
138
+
139
+ response = self.client.request(
140
+ method="GET",
141
+ endpoint=f"{self.experiments_endpoint}/shadow",
142
+ )
143
+
144
+ return [ShadowTest.from_dict(shadow_test) for shadow_test in response.json()]
145
+
146
+ def new_shadow_test(
147
+ self: "Application",
148
+ comparisons: dict[str, list[str]],
149
+ termination_events: TerminationEvents,
150
+ shadow_test_id: str | None = None,
151
+ name: str | None = None,
152
+ description: str | None = None,
153
+ start_events: StartEvents | None = None,
154
+ ) -> ShadowTest:
155
+ """
156
+ Create a new shadow test in draft mode. Shadow tests are experiments
157
+ that run instances in parallel to compare their results.
158
+
159
+ Use the `comparisons` parameter to define how to set up instance
160
+ comparisons. The keys of the `comparisons` dictionary are the baseline
161
+ instance IDs, and the values are the candidate lists of instance IDs to
162
+ compare against the respective baseline.
163
+
164
+ You may specify `start_events` to make the shadow test start at a
165
+ specific time. Alternatively, you may use the `start_shadow_test`
166
+ method to start the test.
167
+
168
+ The `termination_events` parameter is required and provides control
169
+ over when the shadow test should terminate. Alternatively, you may use
170
+ the `stop_shadow_test` method to stop the test.
171
+
172
+ Parameters
173
+ ----------
174
+ comparisons : dict[str, list[str]]
175
+ Dictionary defining the baseline and candidate instance IDs for
176
+ comparison. The keys are baseline instance IDs, and the values are
177
+ lists of candidate instance IDs to compare against the respective
178
+ baseline.
179
+ termination_events : TerminationEvents
180
+ Termination events for the shadow test.
181
+ shadow_test_id : Optional[str]
182
+ ID of the shadow test. Will be generated if not provided.
183
+ name : Optional[str]
184
+ Name of the shadow test. If not provided, the ID will be used as
185
+ the name.
186
+ description : Optional[str]
187
+ Optional description of the shadow test.
188
+ start_events : Optional[StartEvents]
189
+ Start events for the shadow test.
190
+
191
+ Returns
192
+ -------
193
+ ShadowTest
194
+ The created shadow test.
195
+
196
+ Raises
197
+ ------
198
+ requests.HTTPError
199
+ If the response status code is not 2xx.
200
+ """
201
+
202
+ # Generate ID if not provided
203
+ if shadow_test_id is None:
204
+ shadow_test_id = safe_id("shadow")
205
+
206
+ # Use ID as name if name not provided
207
+ if name is None or name == "":
208
+ name = shadow_test_id
209
+
210
+ payload = {
211
+ "id": shadow_test_id,
212
+ "name": name,
213
+ "comparisons": comparisons,
214
+ "termination_events": termination_events.to_dict(),
215
+ }
216
+
217
+ if description is not None:
218
+ payload["description"] = description
219
+ if start_events is not None:
220
+ payload["start_events"] = start_events.to_dict()
221
+
222
+ response = self.client.request(
223
+ method="POST",
224
+ endpoint=f"{self.experiments_endpoint}/shadow",
225
+ payload=payload,
226
+ )
227
+
228
+ return ShadowTest.from_dict(response.json())
229
+
230
+ def start_shadow_test(self: "Application", shadow_test_id: str) -> None:
231
+ """
232
+ Start a shadow test. Create a shadow test in draft mode using the
233
+ `new_shadow_test` method, then use this method to start the test.
234
+
235
+ Parameters
236
+ ----------
237
+ shadow_test_id : str
238
+ ID of the shadow test to start.
239
+
240
+ Raises
241
+ ------
242
+ requests.HTTPError
243
+ If the response status code is not 2xx.
244
+ """
245
+
246
+ _ = self.client.request(
247
+ method="PUT",
248
+ endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}/start",
249
+ )
250
+
251
+ def stop_shadow_test(self: "Application", shadow_test_id: str, intent: StopIntent) -> None:
252
+ """
253
+ Stop a shadow test. The test should already have started before using
254
+ this method.
255
+
256
+ Parameters
257
+ ----------
258
+ shadow_test_id : str
259
+ ID of the shadow test to stop.
260
+ intent : StopIntent
261
+ Intent for stopping the shadow test.
262
+ Raises
263
+ ------
264
+ requests.HTTPError
265
+ If the response status code is not 2xx.
266
+ """
267
+
268
+ payload = {
269
+ "intent": intent.value,
270
+ }
271
+
272
+ _ = self.client.request(
273
+ method="PUT",
274
+ endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}/stop",
275
+ payload=payload,
276
+ )
277
+
278
+ def update_shadow_test(
279
+ self: "Application",
280
+ shadow_test_id: str,
281
+ name: str | None = None,
282
+ description: str | None = None,
283
+ ) -> ShadowTest:
284
+ """
285
+ Update a shadow test.
286
+
287
+ Parameters
288
+ ----------
289
+ shadow_test_id : str
290
+ ID of the shadow test to update.
291
+ name : Optional[str], default=None
292
+ Optional name of the shadow test.
293
+ description : Optional[str], default=None
294
+ Optional description of the shadow test.
295
+
296
+ Returns
297
+ -------
298
+ ShadowTest
299
+ The information with the updated shadow test.
300
+
301
+ Raises
302
+ ------
303
+ requests.HTTPError
304
+ If the response status code is not 2xx.
305
+ """
306
+
307
+ payload = {}
308
+
309
+ if name is not None:
310
+ payload["name"] = name
311
+ if description is not None:
312
+ payload["description"] = description
313
+
314
+ response = self.client.request(
315
+ method="PATCH",
316
+ endpoint=f"{self.experiments_endpoint}/shadow/{shadow_test_id}",
317
+ payload=payload,
318
+ )
319
+
320
+ return ShadowTest.from_dict(response.json())