nextmv 0.40.0__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.
- nextmv/__about__.py +1 -1
- nextmv/__entrypoint__.py +1 -2
- nextmv/__init__.py +2 -4
- nextmv/cli/CONTRIBUTING.md +583 -0
- nextmv/cli/cloud/__init__.py +49 -0
- nextmv/cli/cloud/acceptance/__init__.py +27 -0
- nextmv/cli/cloud/acceptance/create.py +391 -0
- nextmv/cli/cloud/acceptance/delete.py +64 -0
- nextmv/cli/cloud/acceptance/get.py +103 -0
- nextmv/cli/cloud/acceptance/list.py +62 -0
- nextmv/cli/cloud/acceptance/update.py +95 -0
- nextmv/cli/cloud/account/__init__.py +28 -0
- nextmv/cli/cloud/account/create.py +83 -0
- nextmv/cli/cloud/account/delete.py +59 -0
- nextmv/cli/cloud/account/get.py +66 -0
- nextmv/cli/cloud/account/update.py +70 -0
- nextmv/cli/cloud/app/__init__.py +35 -0
- nextmv/cli/cloud/app/create.py +140 -0
- nextmv/cli/cloud/app/delete.py +57 -0
- nextmv/cli/cloud/app/exists.py +44 -0
- nextmv/cli/cloud/app/get.py +66 -0
- nextmv/cli/cloud/app/list.py +61 -0
- nextmv/cli/cloud/app/push.py +432 -0
- nextmv/cli/cloud/app/update.py +124 -0
- nextmv/cli/cloud/batch/__init__.py +29 -0
- nextmv/cli/cloud/batch/create.py +452 -0
- nextmv/cli/cloud/batch/delete.py +64 -0
- nextmv/cli/cloud/batch/get.py +104 -0
- nextmv/cli/cloud/batch/list.py +63 -0
- nextmv/cli/cloud/batch/metadata.py +66 -0
- nextmv/cli/cloud/batch/update.py +95 -0
- nextmv/cli/cloud/data/__init__.py +26 -0
- nextmv/cli/cloud/data/upload.py +162 -0
- nextmv/cli/cloud/ensemble/__init__.py +33 -0
- nextmv/cli/cloud/ensemble/create.py +413 -0
- nextmv/cli/cloud/ensemble/delete.py +63 -0
- nextmv/cli/cloud/ensemble/get.py +65 -0
- nextmv/cli/cloud/ensemble/list.py +63 -0
- nextmv/cli/cloud/ensemble/update.py +103 -0
- nextmv/cli/cloud/input_set/__init__.py +32 -0
- nextmv/cli/cloud/input_set/create.py +168 -0
- nextmv/cli/cloud/input_set/delete.py +64 -0
- nextmv/cli/cloud/input_set/get.py +63 -0
- nextmv/cli/cloud/input_set/list.py +63 -0
- nextmv/cli/cloud/input_set/update.py +123 -0
- nextmv/cli/cloud/instance/__init__.py +35 -0
- nextmv/cli/cloud/instance/create.py +289 -0
- nextmv/cli/cloud/instance/delete.py +61 -0
- nextmv/cli/cloud/instance/exists.py +39 -0
- nextmv/cli/cloud/instance/get.py +62 -0
- nextmv/cli/cloud/instance/list.py +60 -0
- nextmv/cli/cloud/instance/update.py +216 -0
- nextmv/cli/cloud/managed_input/__init__.py +31 -0
- nextmv/cli/cloud/managed_input/create.py +144 -0
- nextmv/cli/cloud/managed_input/delete.py +64 -0
- nextmv/cli/cloud/managed_input/get.py +63 -0
- nextmv/cli/cloud/managed_input/list.py +60 -0
- nextmv/cli/cloud/managed_input/update.py +97 -0
- nextmv/cli/cloud/run/__init__.py +37 -0
- nextmv/cli/cloud/run/cancel.py +37 -0
- nextmv/cli/cloud/run/create.py +524 -0
- nextmv/cli/cloud/run/get.py +199 -0
- nextmv/cli/cloud/run/input.py +86 -0
- nextmv/cli/cloud/run/list.py +80 -0
- nextmv/cli/cloud/run/logs.py +166 -0
- nextmv/cli/cloud/run/metadata.py +67 -0
- nextmv/cli/cloud/run/track.py +500 -0
- nextmv/cli/cloud/scenario/__init__.py +29 -0
- nextmv/cli/cloud/scenario/create.py +451 -0
- nextmv/cli/cloud/scenario/delete.py +61 -0
- nextmv/cli/cloud/scenario/get.py +102 -0
- nextmv/cli/cloud/scenario/list.py +63 -0
- nextmv/cli/cloud/scenario/metadata.py +67 -0
- nextmv/cli/cloud/scenario/update.py +93 -0
- nextmv/cli/cloud/secrets/__init__.py +33 -0
- nextmv/cli/cloud/secrets/create.py +206 -0
- nextmv/cli/cloud/secrets/delete.py +63 -0
- nextmv/cli/cloud/secrets/get.py +66 -0
- nextmv/cli/cloud/secrets/list.py +60 -0
- nextmv/cli/cloud/secrets/update.py +144 -0
- nextmv/cli/cloud/shadow/__init__.py +33 -0
- nextmv/cli/cloud/shadow/create.py +184 -0
- nextmv/cli/cloud/shadow/delete.py +64 -0
- nextmv/cli/cloud/shadow/get.py +61 -0
- nextmv/cli/cloud/shadow/list.py +63 -0
- nextmv/cli/cloud/shadow/metadata.py +66 -0
- nextmv/cli/cloud/shadow/start.py +43 -0
- nextmv/cli/cloud/shadow/stop.py +53 -0
- nextmv/cli/cloud/shadow/update.py +96 -0
- nextmv/cli/cloud/switchback/__init__.py +33 -0
- nextmv/cli/cloud/switchback/create.py +151 -0
- nextmv/cli/cloud/switchback/delete.py +64 -0
- nextmv/cli/cloud/switchback/get.py +62 -0
- nextmv/cli/cloud/switchback/list.py +63 -0
- nextmv/cli/cloud/switchback/metadata.py +68 -0
- nextmv/cli/cloud/switchback/start.py +43 -0
- nextmv/cli/cloud/switchback/stop.py +53 -0
- nextmv/cli/cloud/switchback/update.py +96 -0
- nextmv/cli/cloud/upload/__init__.py +22 -0
- nextmv/cli/cloud/upload/create.py +39 -0
- nextmv/cli/cloud/version/__init__.py +33 -0
- nextmv/cli/cloud/version/create.py +96 -0
- nextmv/cli/cloud/version/delete.py +61 -0
- nextmv/cli/cloud/version/exists.py +39 -0
- nextmv/cli/cloud/version/get.py +62 -0
- nextmv/cli/cloud/version/list.py +60 -0
- nextmv/cli/cloud/version/update.py +92 -0
- nextmv/cli/community/__init__.py +24 -0
- nextmv/cli/community/clone.py +20 -204
- nextmv/cli/community/list.py +61 -126
- nextmv/cli/configuration/__init__.py +23 -0
- nextmv/cli/configuration/config.py +103 -6
- nextmv/cli/configuration/create.py +17 -18
- nextmv/cli/configuration/delete.py +25 -13
- nextmv/cli/configuration/list.py +4 -4
- nextmv/cli/confirm.py +34 -0
- nextmv/cli/main.py +68 -36
- nextmv/cli/message.py +170 -0
- nextmv/cli/options.py +196 -0
- nextmv/cli/version.py +20 -1
- nextmv/cloud/__init__.py +17 -38
- nextmv/cloud/acceptance_test.py +20 -83
- nextmv/cloud/account.py +269 -30
- nextmv/cloud/application/__init__.py +898 -0
- nextmv/cloud/application/_acceptance.py +424 -0
- nextmv/cloud/application/_batch_scenario.py +845 -0
- nextmv/cloud/application/_ensemble.py +251 -0
- nextmv/cloud/application/_input_set.py +263 -0
- nextmv/cloud/application/_instance.py +289 -0
- nextmv/cloud/application/_managed_input.py +227 -0
- nextmv/cloud/application/_run.py +1393 -0
- nextmv/cloud/application/_secrets.py +294 -0
- nextmv/cloud/application/_shadow.py +320 -0
- nextmv/cloud/application/_switchback.py +332 -0
- nextmv/cloud/application/_utils.py +54 -0
- nextmv/cloud/application/_version.py +304 -0
- nextmv/cloud/batch_experiment.py +6 -2
- nextmv/cloud/community.py +446 -0
- nextmv/cloud/instance.py +11 -1
- nextmv/cloud/integration.py +8 -5
- nextmv/cloud/package.py +50 -9
- nextmv/cloud/shadow.py +254 -0
- nextmv/cloud/switchback.py +228 -0
- nextmv/deprecated.py +5 -3
- nextmv/input.py +20 -88
- nextmv/local/application.py +3 -15
- nextmv/local/runner.py +1 -1
- nextmv/model.py +50 -11
- nextmv/options.py +11 -256
- nextmv/output.py +0 -62
- nextmv/polling.py +54 -16
- nextmv/run.py +84 -37
- nextmv/status.py +1 -51
- {nextmv-0.40.0.dist-info → nextmv-1.0.0.dist-info}/METADATA +37 -11
- nextmv-1.0.0.dist-info/RECORD +185 -0
- nextmv-1.0.0.dist-info/entry_points.txt +2 -0
- nextmv/cli/community/community.py +0 -24
- nextmv/cli/configuration/configuration.py +0 -23
- nextmv/cli/error.py +0 -22
- nextmv/cloud/application.py +0 -4204
- nextmv-0.40.0.dist-info/RECORD +0 -66
- {nextmv-0.40.0.dist-info → nextmv-1.0.0.dist-info}/WHEEL +0 -0
- {nextmv-0.40.0.dist-info → nextmv-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application mixin for managing switchback tests.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from nextmv.cloud.shadow import StopIntent
|
|
9
|
+
from nextmv.cloud.switchback import SwitchbackTest, SwitchbackTestMetadata, TestComparisonSingle
|
|
10
|
+
from nextmv.run import Run
|
|
11
|
+
from nextmv.safe import safe_id
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from . import Application
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApplicationSwitchbackMixin:
|
|
18
|
+
"""
|
|
19
|
+
Mixin class for managing switchback tests within an application.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def switchback_test(self: "Application", switchback_test_id: str) -> SwitchbackTest:
|
|
23
|
+
"""
|
|
24
|
+
Get a switchback test. This method also returns the runs of the switchback
|
|
25
|
+
test under the `.runs` attribute.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
switchback_test_id : str
|
|
30
|
+
ID of the switchback test.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
SwitchbackTest
|
|
35
|
+
The requested switchback test details.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
requests.HTTPError
|
|
40
|
+
If the response status code is not 2xx.
|
|
41
|
+
|
|
42
|
+
Examples
|
|
43
|
+
--------
|
|
44
|
+
>>> switchback_test = app.switchback_test("switchback-123")
|
|
45
|
+
>>> print(switchback_test.name)
|
|
46
|
+
'My Switchback Test'
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
response = self.client.request(
|
|
50
|
+
method="GET",
|
|
51
|
+
endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
exp = SwitchbackTest.from_dict(response.json())
|
|
55
|
+
|
|
56
|
+
runs_response = self.client.request(
|
|
57
|
+
method="GET",
|
|
58
|
+
endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}/runs",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
runs = [Run.from_dict(run) for run in runs_response.json().get("runs", [])]
|
|
62
|
+
exp.runs = runs
|
|
63
|
+
|
|
64
|
+
return exp
|
|
65
|
+
|
|
66
|
+
def switchback_test_metadata(self: "Application", switchback_test_id: str) -> SwitchbackTestMetadata:
|
|
67
|
+
"""
|
|
68
|
+
Get metadata for a switchback test.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
switchback_test_id : str
|
|
73
|
+
ID of the switchback test.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
SwitchbackTestMetadata
|
|
78
|
+
The requested switchback test metadata.
|
|
79
|
+
|
|
80
|
+
Raises
|
|
81
|
+
------
|
|
82
|
+
requests.HTTPError
|
|
83
|
+
If the response status code is not 2xx.
|
|
84
|
+
|
|
85
|
+
Examples
|
|
86
|
+
--------
|
|
87
|
+
>>> metadata = app.switchback_test_metadata("switchback-123")
|
|
88
|
+
>>> print(metadata.name)
|
|
89
|
+
'My Switchback Test'
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
response = self.client.request(
|
|
93
|
+
method="GET",
|
|
94
|
+
endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}/metadata",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return SwitchbackTestMetadata.from_dict(response.json())
|
|
98
|
+
|
|
99
|
+
def delete_switchback_test(self: "Application", switchback_test_id: str) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Delete a switchback test.
|
|
102
|
+
|
|
103
|
+
Deletes a switchback test along with all the associated information,
|
|
104
|
+
such as its runs.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
switchback_test_id : str
|
|
109
|
+
ID of the switchback test to delete.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
requests.HTTPError
|
|
114
|
+
If the response status code is not 2xx.
|
|
115
|
+
|
|
116
|
+
Examples
|
|
117
|
+
--------
|
|
118
|
+
>>> app.delete_switchback_test("switchback-123")
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_ = self.client.request(
|
|
122
|
+
method="DELETE",
|
|
123
|
+
endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def list_switchback_tests(self: "Application") -> list[SwitchbackTest]:
|
|
127
|
+
"""
|
|
128
|
+
List all switchback tests.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
list[SwitchbackTest]
|
|
133
|
+
List of switchback tests.
|
|
134
|
+
|
|
135
|
+
Raises
|
|
136
|
+
------
|
|
137
|
+
requests.HTTPError
|
|
138
|
+
If the response status code is not 2xx.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
response = self.client.request(
|
|
142
|
+
method="GET",
|
|
143
|
+
endpoint=f"{self.experiments_endpoint}/switchback",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return [SwitchbackTest.from_dict(switchback_test) for switchback_test in response.json().get("items", [])]
|
|
147
|
+
|
|
148
|
+
def new_switchback_test(
|
|
149
|
+
self: "Application",
|
|
150
|
+
comparison: TestComparisonSingle,
|
|
151
|
+
unit_duration_minutes: float,
|
|
152
|
+
units: int,
|
|
153
|
+
switchback_test_id: str | None = None,
|
|
154
|
+
name: str | None = None,
|
|
155
|
+
description: str | None = None,
|
|
156
|
+
start: datetime | None = None,
|
|
157
|
+
) -> SwitchbackTest:
|
|
158
|
+
"""
|
|
159
|
+
Create a new switchback test in draft mode. Switchback tests are
|
|
160
|
+
experiments that alternate between different instances over specified
|
|
161
|
+
time intervals.
|
|
162
|
+
|
|
163
|
+
Use the `comparison` parameter to define how to set up the instance
|
|
164
|
+
comparison. The test will alternate between the baseline and candidate
|
|
165
|
+
instances defined in the comparison.
|
|
166
|
+
|
|
167
|
+
You may specify `start` to make the switchback test start at a
|
|
168
|
+
specific time. Alternatively, you may use the `start_switchback_test`
|
|
169
|
+
method to start the test.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
comparison : TestComparisonSingle
|
|
174
|
+
Comparison defining the baseline and candidate instances.
|
|
175
|
+
unit_duration_minutes : float
|
|
176
|
+
Duration of each interval in minutes. The value must be between 1
|
|
177
|
+
and 10080.
|
|
178
|
+
units : int
|
|
179
|
+
Total number of intervals in the switchback test. The value must be
|
|
180
|
+
between 1 and 1000.
|
|
181
|
+
switchback_test_id : Optional[str], default=None
|
|
182
|
+
Optional ID for the switchback test. Will be generated if not
|
|
183
|
+
provided.
|
|
184
|
+
name : Optional[str], default=None
|
|
185
|
+
Optional name of the switchback test. If not provided, the ID will
|
|
186
|
+
be used as the name.
|
|
187
|
+
description : Optional[str], default=None
|
|
188
|
+
Optional description of the switchback test.
|
|
189
|
+
start : Optional[datetime], default=None
|
|
190
|
+
Optional scheduled start time for the switchback test.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
SwitchbackTest
|
|
195
|
+
The created switchback test.
|
|
196
|
+
|
|
197
|
+
Raises
|
|
198
|
+
------
|
|
199
|
+
requests.HTTPError
|
|
200
|
+
If the response status code is not 2xx.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
if unit_duration_minutes < 1 or unit_duration_minutes > 10080:
|
|
204
|
+
raise ValueError("unit_duration_minutes must be between 1 and 10080")
|
|
205
|
+
|
|
206
|
+
if units < 1 or units > 1000:
|
|
207
|
+
raise ValueError("units must be between 1 and 1000")
|
|
208
|
+
|
|
209
|
+
# Generate ID if not provided
|
|
210
|
+
if switchback_test_id is None:
|
|
211
|
+
switchback_test_id = safe_id("switchback")
|
|
212
|
+
|
|
213
|
+
# Use ID as name if name not provided
|
|
214
|
+
if name is None or name == "":
|
|
215
|
+
name = switchback_test_id
|
|
216
|
+
|
|
217
|
+
payload = {
|
|
218
|
+
"id": switchback_test_id,
|
|
219
|
+
"name": name,
|
|
220
|
+
"comparison": comparison.to_dict(),
|
|
221
|
+
"generate_random_plan": {
|
|
222
|
+
"unit_duration_minutes": unit_duration_minutes,
|
|
223
|
+
"units": units,
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if description is not None:
|
|
228
|
+
payload["description"] = description
|
|
229
|
+
if start is not None:
|
|
230
|
+
payload["generate_random_plan"]["start"] = start.isoformat()
|
|
231
|
+
|
|
232
|
+
response = self.client.request(
|
|
233
|
+
method="POST",
|
|
234
|
+
endpoint=f"{self.experiments_endpoint}/switchback",
|
|
235
|
+
payload=payload,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return SwitchbackTest.from_dict(response.json())
|
|
239
|
+
|
|
240
|
+
def start_switchback_test(self: "Application", switchback_test_id: str) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Start a switchback test. Create a switchback test in draft mode using the
|
|
243
|
+
`new_switchback_test` method, then use this method to start the test.
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
switchback_test_id : str
|
|
248
|
+
ID of the switchback test to start.
|
|
249
|
+
|
|
250
|
+
Raises
|
|
251
|
+
------
|
|
252
|
+
requests.HTTPError
|
|
253
|
+
If the response status code is not 2xx.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
_ = self.client.request(
|
|
257
|
+
method="PUT",
|
|
258
|
+
endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}/start",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def stop_switchback_test(self: "Application", switchback_test_id: str, intent: StopIntent) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Stop a switchback test. The test should already have started before using
|
|
264
|
+
this method.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
switchback_test_id : str
|
|
269
|
+
ID of the switchback test to stop.
|
|
270
|
+
|
|
271
|
+
intent : StopIntent
|
|
272
|
+
Intent for stopping the switchback test.
|
|
273
|
+
|
|
274
|
+
Raises
|
|
275
|
+
------
|
|
276
|
+
requests.HTTPError
|
|
277
|
+
If the response status code is not 2xx.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
payload = {
|
|
281
|
+
"intent": intent.value,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_ = self.client.request(
|
|
285
|
+
method="PUT",
|
|
286
|
+
endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}/stop",
|
|
287
|
+
payload=payload,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def update_switchback_test(
|
|
291
|
+
self: "Application",
|
|
292
|
+
switchback_test_id: str,
|
|
293
|
+
name: str | None = None,
|
|
294
|
+
description: str | None = None,
|
|
295
|
+
) -> SwitchbackTest:
|
|
296
|
+
"""
|
|
297
|
+
Update a switchback test.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
switchback_test_id : str
|
|
302
|
+
ID of the switchback test to update.
|
|
303
|
+
name : Optional[str], default=None
|
|
304
|
+
Optional name of the switchback test.
|
|
305
|
+
description : Optional[str], default=None
|
|
306
|
+
Optional description of the switchback test.
|
|
307
|
+
|
|
308
|
+
Returns
|
|
309
|
+
-------
|
|
310
|
+
SwitchbackTest
|
|
311
|
+
The information with the updated switchback test.
|
|
312
|
+
|
|
313
|
+
Raises
|
|
314
|
+
------
|
|
315
|
+
requests.HTTPError
|
|
316
|
+
If the response status code is not 2xx.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
payload = {}
|
|
320
|
+
|
|
321
|
+
if name is not None:
|
|
322
|
+
payload["name"] = name
|
|
323
|
+
if description is not None:
|
|
324
|
+
payload["description"] = description
|
|
325
|
+
|
|
326
|
+
response = self.client.request(
|
|
327
|
+
method="PATCH",
|
|
328
|
+
endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}",
|
|
329
|
+
payload=payload,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return SwitchbackTest.from_dict(response.json())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module for general utility methods for Application.
|
|
3
|
+
|
|
4
|
+
Avoid abusing this module, as you should try to keep domain-specific
|
|
5
|
+
functionality together and not rely on generic utilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_not_exist_error(e: requests.HTTPError) -> bool:
|
|
12
|
+
"""
|
|
13
|
+
Check if the error is a known 404 Not Found error.
|
|
14
|
+
|
|
15
|
+
This is an internal helper function that examines HTTPError objects to determine
|
|
16
|
+
if they represent a "Not Found" (404) condition, either directly or through a
|
|
17
|
+
nested exception.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
e : requests.HTTPError
|
|
22
|
+
The HTTP error to check.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
bool
|
|
27
|
+
True if the error is a 404 Not Found error, False otherwise.
|
|
28
|
+
|
|
29
|
+
Examples
|
|
30
|
+
--------
|
|
31
|
+
>>> try:
|
|
32
|
+
... response = requests.get('https://api.example.com/nonexistent')
|
|
33
|
+
... response.raise_for_status()
|
|
34
|
+
... except requests.HTTPError as err:
|
|
35
|
+
... if _is_not_exist_error(err):
|
|
36
|
+
... print("Resource does not exist")
|
|
37
|
+
... else:
|
|
38
|
+
... print("Another error occurred")
|
|
39
|
+
Resource does not exist
|
|
40
|
+
"""
|
|
41
|
+
if (
|
|
42
|
+
# Check whether the error is caused by a 404 status code - meaning the app does not exist.
|
|
43
|
+
(hasattr(e, "response") and hasattr(e.response, "status_code") and e.response.status_code == 404)
|
|
44
|
+
or
|
|
45
|
+
# Check a possibly nested exception as well.
|
|
46
|
+
(
|
|
47
|
+
hasattr(e, "__cause__")
|
|
48
|
+
and hasattr(e.__cause__, "response")
|
|
49
|
+
and hasattr(e.__cause__.response, "status_code")
|
|
50
|
+
and e.__cause__.response.status_code == 404
|
|
51
|
+
)
|
|
52
|
+
):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application mixin for managing app versions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from nextmv.cloud.application._utils import _is_not_exist_error
|
|
10
|
+
from nextmv.cloud.version import Version
|
|
11
|
+
from nextmv.safe import safe_id
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from . import Application
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApplicationVersionMixin:
|
|
18
|
+
"""
|
|
19
|
+
Mixin class for managing app versions within an application.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def delete_version(self: "Application", version_id: str) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Delete a version.
|
|
25
|
+
|
|
26
|
+
Permanently removes the specified version from the application.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
version_id : str
|
|
31
|
+
ID of the version to delete.
|
|
32
|
+
|
|
33
|
+
Raises
|
|
34
|
+
------
|
|
35
|
+
requests.HTTPError
|
|
36
|
+
If the response status code is not 2xx.
|
|
37
|
+
|
|
38
|
+
Examples
|
|
39
|
+
--------
|
|
40
|
+
>>> app.delete_version("v1.0.0") # Permanently deletes the version
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_ = self.client.request(
|
|
44
|
+
method="DELETE",
|
|
45
|
+
endpoint=f"{self.endpoint}/versions/{version_id}",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def list_versions(self: "Application") -> list[Version]:
|
|
49
|
+
"""
|
|
50
|
+
List all versions.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
list[Version]
|
|
55
|
+
List of all versions associated with this application.
|
|
56
|
+
|
|
57
|
+
Raises
|
|
58
|
+
------
|
|
59
|
+
requests.HTTPError
|
|
60
|
+
If the response status code is not 2xx.
|
|
61
|
+
|
|
62
|
+
Examples
|
|
63
|
+
--------
|
|
64
|
+
>>> versions = app.list_versions()
|
|
65
|
+
>>> for version in versions:
|
|
66
|
+
... print(version.name)
|
|
67
|
+
'v1.0.0'
|
|
68
|
+
'v1.1.0'
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
response = self.client.request(
|
|
72
|
+
method="GET",
|
|
73
|
+
endpoint=f"{self.endpoint}/versions",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return [Version.from_dict(version) for version in response.json()]
|
|
77
|
+
|
|
78
|
+
def new_version(
|
|
79
|
+
self: "Application",
|
|
80
|
+
id: str | None = None,
|
|
81
|
+
name: str | None = None,
|
|
82
|
+
description: str | None = None,
|
|
83
|
+
exist_ok: bool = False,
|
|
84
|
+
) -> Version:
|
|
85
|
+
"""
|
|
86
|
+
Create a new version using the latest pushed executable.
|
|
87
|
+
|
|
88
|
+
This method creates a new version of the application using the current development
|
|
89
|
+
binary. Application versions represent different iterations of your application's
|
|
90
|
+
code and configuration that can be deployed.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
id : Optional[str], default=None
|
|
95
|
+
ID of the version. If not provided, a unique ID will be generated.
|
|
96
|
+
name : Optional[str], default=None
|
|
97
|
+
Name of the version. If not provided, a name will be generated.
|
|
98
|
+
description : Optional[str], default=None
|
|
99
|
+
Description of the version. If not provided, a description will be generated.
|
|
100
|
+
exist_ok : bool, default=False
|
|
101
|
+
If True and a version with the same ID already exists,
|
|
102
|
+
return the existing version instead of creating a new one.
|
|
103
|
+
If True, the 'id' parameter must be provided.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
Version
|
|
108
|
+
The newly created (or existing) version.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
ValueError
|
|
113
|
+
If exist_ok is True and id is None.
|
|
114
|
+
requests.HTTPError
|
|
115
|
+
If the response status code is not 2xx.
|
|
116
|
+
|
|
117
|
+
Examples
|
|
118
|
+
--------
|
|
119
|
+
>>> # Create a new version
|
|
120
|
+
>>> version = app.new_version(
|
|
121
|
+
... id="v1.0.0",
|
|
122
|
+
... name="Initial Release",
|
|
123
|
+
... description="First stable version"
|
|
124
|
+
... )
|
|
125
|
+
>>> print(version.id)
|
|
126
|
+
'v1.0.0'
|
|
127
|
+
|
|
128
|
+
>>> # Get or create a version with exist_ok
|
|
129
|
+
>>> version = app.new_version(
|
|
130
|
+
... id="v1.0.0",
|
|
131
|
+
... exist_ok=True
|
|
132
|
+
... )
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
if exist_ok and (id is None or id == ""):
|
|
136
|
+
raise ValueError("If exist_ok is True, id must be provided")
|
|
137
|
+
|
|
138
|
+
if exist_ok and self.version_exists(version_id=id):
|
|
139
|
+
return self.version(version_id=id)
|
|
140
|
+
|
|
141
|
+
if id is None or id == "":
|
|
142
|
+
id = safe_id(prefix="version")
|
|
143
|
+
|
|
144
|
+
if name is None or name == "":
|
|
145
|
+
name = id
|
|
146
|
+
|
|
147
|
+
payload = {
|
|
148
|
+
"id": id,
|
|
149
|
+
"name": name,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if description is not None:
|
|
153
|
+
payload["description"] = description
|
|
154
|
+
|
|
155
|
+
response = self.client.request(
|
|
156
|
+
method="POST",
|
|
157
|
+
endpoint=f"{self.endpoint}/versions",
|
|
158
|
+
payload=payload,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return Version.from_dict(response.json())
|
|
162
|
+
|
|
163
|
+
def update_version(
|
|
164
|
+
self: "Application",
|
|
165
|
+
version_id: str,
|
|
166
|
+
name: str | None = None,
|
|
167
|
+
description: str | None = None,
|
|
168
|
+
) -> Version:
|
|
169
|
+
"""
|
|
170
|
+
Update a version.
|
|
171
|
+
|
|
172
|
+
This method updates a specific version of the application. It mimics a
|
|
173
|
+
PATCH operation by allowing you to update only the name and/or description
|
|
174
|
+
fields while preserving all other fields.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
version_id : str
|
|
179
|
+
ID of the version to update.
|
|
180
|
+
name : Optional[str], default=None
|
|
181
|
+
Optional new name for the version.
|
|
182
|
+
description : Optional[str], default=None
|
|
183
|
+
Optional new description for the version.
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
Version
|
|
188
|
+
The updated version object.
|
|
189
|
+
|
|
190
|
+
Raises
|
|
191
|
+
------
|
|
192
|
+
requests.HTTPError
|
|
193
|
+
If the response status code is not 2xx.
|
|
194
|
+
|
|
195
|
+
Examples
|
|
196
|
+
--------
|
|
197
|
+
>>> # Update a version's name
|
|
198
|
+
>>> updated = app.update_version("v1.0.0", name="Version 1.0")
|
|
199
|
+
>>> print(updated.name)
|
|
200
|
+
'Version 1.0'
|
|
201
|
+
|
|
202
|
+
>>> # Update a version's description
|
|
203
|
+
>>> updated = app.update_version("v1.0.0", description="Initial release")
|
|
204
|
+
>>> print(updated.description)
|
|
205
|
+
'Initial release'
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
version = self.version(version_id=version_id)
|
|
209
|
+
version_dict = version.to_dict()
|
|
210
|
+
payload = version_dict.copy()
|
|
211
|
+
|
|
212
|
+
if name is not None:
|
|
213
|
+
payload["name"] = name
|
|
214
|
+
if description is not None:
|
|
215
|
+
payload["description"] = description
|
|
216
|
+
|
|
217
|
+
response = self.client.request(
|
|
218
|
+
method="PUT",
|
|
219
|
+
endpoint=f"{self.endpoint}/versions/{version_id}",
|
|
220
|
+
payload=payload,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return Version.from_dict(response.json())
|
|
224
|
+
|
|
225
|
+
def version(self: "Application", version_id: str) -> Version:
|
|
226
|
+
"""
|
|
227
|
+
Get a version.
|
|
228
|
+
|
|
229
|
+
Retrieves a specific version of the application by its ID. Application versions
|
|
230
|
+
represent different iterations of your application's code and configuration.
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
version_id : str
|
|
235
|
+
ID of the version to retrieve.
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
Version
|
|
240
|
+
The version object containing details about the requested application version.
|
|
241
|
+
|
|
242
|
+
Raises
|
|
243
|
+
------
|
|
244
|
+
requests.HTTPError
|
|
245
|
+
If the response status code is not 2xx.
|
|
246
|
+
|
|
247
|
+
Examples
|
|
248
|
+
--------
|
|
249
|
+
>>> # Retrieve a specific version
|
|
250
|
+
>>> version = app.version("v1.0.0")
|
|
251
|
+
>>> print(version.id)
|
|
252
|
+
'v1.0.0'
|
|
253
|
+
>>> print(version.name)
|
|
254
|
+
'Initial Release'
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
response = self.client.request(
|
|
258
|
+
method="GET",
|
|
259
|
+
endpoint=f"{self.endpoint}/versions/{version_id}",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return Version.from_dict(response.json())
|
|
263
|
+
|
|
264
|
+
def version_exists(self: "Application", version_id: str) -> bool:
|
|
265
|
+
"""
|
|
266
|
+
Check if a version exists.
|
|
267
|
+
|
|
268
|
+
This method checks if a specific version of the application exists by
|
|
269
|
+
attempting to retrieve it. It handles HTTP errors for non-existent versions
|
|
270
|
+
and returns a boolean indicating existence.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
version_id : str
|
|
275
|
+
ID of the version to check for existence.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
bool
|
|
280
|
+
True if the version exists, False otherwise.
|
|
281
|
+
|
|
282
|
+
Raises
|
|
283
|
+
------
|
|
284
|
+
requests.HTTPError
|
|
285
|
+
If an HTTP error occurs that is not related to the non-existence
|
|
286
|
+
of the version.
|
|
287
|
+
|
|
288
|
+
Examples
|
|
289
|
+
--------
|
|
290
|
+
>>> # Check if a version exists
|
|
291
|
+
>>> exists = app.version_exists("v1.0.0")
|
|
292
|
+
>>> if exists:
|
|
293
|
+
... print("Version exists!")
|
|
294
|
+
... else:
|
|
295
|
+
... print("Version does not exist.")
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
self.version(version_id=version_id)
|
|
300
|
+
return True
|
|
301
|
+
except requests.HTTPError as e:
|
|
302
|
+
if _is_not_exist_error(e):
|
|
303
|
+
return False
|
|
304
|
+
raise e
|
nextmv/cloud/batch_experiment.py
CHANGED
|
@@ -30,7 +30,9 @@ class ExperimentStatus(str, Enum):
|
|
|
30
30
|
|
|
31
31
|
You can import the `ExperimentStatus` class directly from `cloud`:
|
|
32
32
|
|
|
33
|
-
```python
|
|
33
|
+
```python
|
|
34
|
+
from nextmv.cloud import ExperimentStatus
|
|
35
|
+
```
|
|
34
36
|
|
|
35
37
|
This enum represents the comprehensive set of possible states for an
|
|
36
38
|
experiment in Nextmv Cloud.
|
|
@@ -223,7 +225,9 @@ class BatchExperimentRun(BaseModel):
|
|
|
223
225
|
Parameters
|
|
224
226
|
----------
|
|
225
227
|
input_id : str
|
|
226
|
-
ID of the input used for the experiment.
|
|
228
|
+
ID of the input used for the experiment. If a managed input is used,
|
|
229
|
+
this should be the ID of the managed input. If `input_set_id` is provided
|
|
230
|
+
for the run, this should be the ID of an input within that input set.
|
|
227
231
|
option_set : str
|
|
228
232
|
Option set used for the experiment. Defaults to None.
|
|
229
233
|
instance_id : str, optional
|