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,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
@@ -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 from nextmv.cloud import ExperimentStatus ```
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