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.
Files changed (163) 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 +20 -204
  110. nextmv/cli/community/list.py +61 -126
  111. nextmv/cli/configuration/__init__.py +23 -0
  112. nextmv/cli/configuration/config.py +103 -6
  113. nextmv/cli/configuration/create.py +17 -18
  114. nextmv/cli/configuration/delete.py +25 -13
  115. nextmv/cli/configuration/list.py +4 -4
  116. nextmv/cli/confirm.py +34 -0
  117. nextmv/cli/main.py +68 -36
  118. nextmv/cli/message.py +170 -0
  119. nextmv/cli/options.py +196 -0
  120. nextmv/cli/version.py +20 -1
  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.40.0.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/cli/community/community.py +0 -24
  158. nextmv/cli/configuration/configuration.py +0 -23
  159. nextmv/cli/error.py +0 -22
  160. nextmv/cloud/application.py +0 -4204
  161. nextmv-0.40.0.dist-info/RECORD +0 -66
  162. {nextmv-0.40.0.dist-info → nextmv-1.0.0.dist-info}/WHEEL +0 -0
  163. {nextmv-0.40.0.dist-info → nextmv-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,898 @@
1
+ """
2
+ Application module for interacting with Nextmv Cloud applications.
3
+
4
+ This module provides functionality to interact with applications in Nextmv Cloud,
5
+ including application management, running applications, and managing experiments
6
+ and inputs.
7
+
8
+ Classes
9
+ -------
10
+ ApplicationType
11
+ Enumeration of application types in Nextmv Cloud.
12
+ Application
13
+ Class for interacting with applications in Nextmv Cloud.
14
+
15
+ Functions
16
+ ---------
17
+ list_application
18
+ Function to list applications in Nextmv Cloud.
19
+ """
20
+
21
+ import json
22
+ import shutil
23
+ import sys
24
+ from datetime import datetime
25
+ from enum import Enum
26
+ from typing import Any
27
+
28
+ import requests
29
+ import rich
30
+ from pydantic import AliasChoices, Field
31
+
32
+ from nextmv import deprecated
33
+ from nextmv._serialization import deflated_serialize_json
34
+ from nextmv.base_model import BaseModel
35
+ from nextmv.cloud import package
36
+ from nextmv.cloud.application._acceptance import ApplicationAcceptanceMixin
37
+ from nextmv.cloud.application._batch_scenario import ApplicationBatchMixin
38
+ from nextmv.cloud.application._ensemble import ApplicationEnsembleMixin
39
+ from nextmv.cloud.application._input_set import ApplicationInputSetMixin
40
+ from nextmv.cloud.application._instance import ApplicationInstanceMixin
41
+ from nextmv.cloud.application._managed_input import ApplicationManagedInputMixin
42
+ from nextmv.cloud.application._run import ApplicationRunMixin
43
+ from nextmv.cloud.application._secrets import ApplicationSecretsMixin
44
+ from nextmv.cloud.application._shadow import ApplicationShadowMixin
45
+ from nextmv.cloud.application._switchback import ApplicationSwitchbackMixin
46
+ from nextmv.cloud.application._utils import _is_not_exist_error
47
+ from nextmv.cloud.application._version import ApplicationVersionMixin
48
+ from nextmv.cloud.client import Client
49
+ from nextmv.cloud.url import UploadURL
50
+ from nextmv.logger import log
51
+ from nextmv.manifest import Manifest
52
+ from nextmv.model import Model, ModelConfiguration
53
+ from nextmv.safe import safe_id
54
+
55
+
56
+ class ApplicationType(str, Enum):
57
+ """
58
+ Enumeration of application types in Nextmv Cloud.
59
+
60
+ You can import the `ApplicationType` class directly from `cloud`:
61
+
62
+ ```python
63
+ from nextmv.cloud import ApplicationType
64
+ ```
65
+
66
+ Attributes
67
+ ----------
68
+ CUSTOM : str
69
+ Custom application type, which is the most common. Represents a standard
70
+ application that you can push code to.
71
+ SUBSCRIPTION : str
72
+ Subscription application type. You cannot push code to subscription
73
+ applications, but only subscribe to them through the marketplace.
74
+ PIPELINE : str
75
+ Pipeline application type that refers to workflows.
76
+ """
77
+
78
+ CUSTOM = "custom"
79
+ """
80
+ Custom application type, which is the most common. Represents a standard
81
+ application that you can push code to.
82
+ """
83
+ SUBSCRIPTION = "subscription"
84
+ """
85
+ Subscription application type. You cannot push code to subscription
86
+ applications, but only subscribe to them through the marketplace.
87
+ """
88
+ PIPELINE = "pipeline"
89
+ """
90
+ Pipeline application type that refers to workflows.
91
+ """
92
+
93
+
94
+ class Application(
95
+ BaseModel,
96
+ ApplicationAcceptanceMixin,
97
+ ApplicationBatchMixin,
98
+ ApplicationRunMixin,
99
+ ApplicationEnsembleMixin,
100
+ ApplicationInstanceMixin,
101
+ ApplicationSecretsMixin,
102
+ ApplicationVersionMixin,
103
+ ApplicationInputSetMixin,
104
+ ApplicationManagedInputMixin,
105
+ ApplicationShadowMixin,
106
+ ApplicationSwitchbackMixin,
107
+ ):
108
+ """
109
+ A published decision model that can be executed.
110
+
111
+ You can import the `Application` class directly from `cloud`:
112
+
113
+ ```python
114
+ from nextmv.cloud import Application
115
+ ```
116
+
117
+ This class represents an application in Nextmv Cloud, providing methods to
118
+ interact with the application, run it with different inputs, manage versions,
119
+ instances, experiments, and more.
120
+
121
+ Note: It is recommended to use `Application.get()` or `Application.new()`
122
+ instead of direct initialization to ensure proper setup.
123
+
124
+ Parameters
125
+ ----------
126
+ client : Client
127
+ Client to use for interacting with the Nextmv Cloud API.
128
+ id : str
129
+ ID of the application.
130
+ name : str, optional
131
+ Name of the application.
132
+ description : str, optional
133
+ Description of the application.
134
+ type : ApplicationType, optional
135
+ Type of the application (CUSTOM, SUBSCRIPTION, or PIPELINE).
136
+ default_instance_id : str, optional
137
+ Default instance ID to use for submitting runs.
138
+ default_experiment_instance : str, optional
139
+ Default experiment instance ID to use for experiments.
140
+ subscription_id : str, optional
141
+ Subscription ID if the application is a subscription type.
142
+ locked : bool, default=False
143
+ Whether the application is locked.
144
+ created_at : datetime, optional
145
+ Creation timestamp of the application.
146
+ updated_at : datetime, optional
147
+ Last update timestamp of the application.
148
+ endpoint : str, default="v1/applications/{id}"
149
+ Base endpoint for the application (SDK-specific).
150
+ experiments_endpoint : str, default="{base}/experiments"
151
+ Base endpoint for experiments (SDK-specific).
152
+ ensembles_endpoint : str, default="{base}/ensembles"
153
+ Base endpoint for ensembles (SDK-specific).
154
+
155
+ Examples
156
+ --------
157
+ >>> from nextmv.cloud import Client, Application
158
+ >>> client = Client(api_key="your-api-key")
159
+ >>> # Retrieve an existing application
160
+ >>> app = Application.get(client=client, id="your-app-id")
161
+ >>> print(f"Application name: {app.name}")
162
+ Application name: My Application
163
+ >>> # Create a new application
164
+ >>> new_app = Application.new(client=client, name="My New App", id="my-new-app")
165
+ >>> # List application instances
166
+ >>> instances = app.list_instances()
167
+ """
168
+
169
+ # Actual API attributes of an application.
170
+ id: str
171
+ """ID of the application."""
172
+ name: str | None = None
173
+ """Name of the application."""
174
+ description: str | None = None
175
+ """Description of the application."""
176
+ type: ApplicationType | None = None
177
+ """Type of the application."""
178
+ default_instance_id: str | None = Field(
179
+ serialization_alias="default_instance",
180
+ validation_alias=AliasChoices("default_instance", "default_instance_id"),
181
+ default=None,
182
+ )
183
+ """Default instance ID to use for submitting runs."""
184
+ default_experiment_instance: str | None = None
185
+ """Default experiment instance ID to use for experiments."""
186
+ subscription_id: str | None = None
187
+ """Subscription ID if the application is a subscription type."""
188
+ locked: bool = False
189
+ """Whether the application is locked."""
190
+ created_at: datetime | None = None
191
+ """Creation timestamp of the application."""
192
+ updated_at: datetime | None = None
193
+ """Last update timestamp of the application."""
194
+
195
+ # SDK-specific attributes for convenience when using methods.
196
+ client: Client = Field(exclude=True)
197
+ """Client to use for interacting with the Nextmv Cloud API."""
198
+ endpoint: str = Field(exclude=True, default="v1/applications/{id}")
199
+ """Base endpoint for the application."""
200
+ experiments_endpoint: str = Field(exclude=True, default="{base}/experiments")
201
+ """Base endpoint for the experiments in the application."""
202
+ ensembles_endpoint: str = Field(exclude=True, default="{base}/ensembles")
203
+ """Base endpoint for managing the ensemble definitions in the
204
+ application"""
205
+
206
+ def model_post_init(self, __context) -> None:
207
+ """Initialize the endpoint and experiments_endpoint attributes.
208
+
209
+ This method is automatically called after class initialization to
210
+ format the endpoint and experiments_endpoint URLs with the application ID.
211
+ """
212
+ self.endpoint = self.endpoint.format(id=self.id)
213
+ self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
214
+ self.ensembles_endpoint = self.ensembles_endpoint.format(base=self.endpoint)
215
+
216
+ @classmethod
217
+ def get(cls, client: Client, id: str) -> "Application":
218
+ """
219
+ Retrieve an application directly from Nextmv Cloud.
220
+
221
+ This function is useful if you want to populate an `Application` class
222
+ by fetching the attributes directly from Nextmv Cloud.
223
+
224
+ Parameters
225
+ ----------
226
+ client : Client
227
+ Client to use for interacting with the Nextmv Cloud API.
228
+ id : str
229
+ ID of the application to retrieve.
230
+
231
+ Returns
232
+ -------
233
+ Application
234
+ The requested application.
235
+
236
+ Raises
237
+ ------
238
+ requests.HTTPError
239
+ If the response status code is not 2xx.
240
+ """
241
+
242
+ response = client.request(
243
+ method="GET",
244
+ endpoint=f"v1/applications/{id}",
245
+ )
246
+
247
+ return cls.from_dict({"client": client} | response.json())
248
+
249
+ @classmethod
250
+ def new(
251
+ cls,
252
+ client: Client,
253
+ name: str | None = None,
254
+ id: str | None = None,
255
+ description: str | None = None,
256
+ is_workflow: bool | None = None,
257
+ exist_ok: bool = False,
258
+ default_instance_id: str | None = None,
259
+ default_experiment_instance: str | None = None,
260
+ ) -> "Application":
261
+ """
262
+ Create a new application directly in Nextmv Cloud.
263
+
264
+ The application is created as an empty shell, and executable code must
265
+ be pushed to the app before running it remotely.
266
+
267
+ Parameters
268
+ ----------
269
+ client : Client
270
+ Client to use for interacting with the Nextmv Cloud API.
271
+ name : str | None = None
272
+ Name of the application. Uses the ID as the name if not provided.
273
+ id : str | None = None
274
+ ID of the application. Will be generated if not provided.
275
+ description : str | None = None
276
+ Description of the application.
277
+ is_workflow : bool | None = None
278
+ Whether the application is a Decision Workflow.
279
+ exist_ok : bool, default=False
280
+ If True and an application with the same ID already exists,
281
+ return the existing application instead of creating a new one.
282
+ default_instance_id : str, optional
283
+ Default instance ID to use for submitting runs.
284
+ default_experiment_instance : str, optional
285
+ Default experiment instance ID to use for experiments.
286
+
287
+ Returns
288
+ -------
289
+ Application
290
+ The newly created (or existing) application.
291
+
292
+ Examples
293
+ --------
294
+ >>> from nextmv.cloud import Client
295
+ >>> client = Client(api_key="your-api-key")
296
+ >>> app = Application.new(client=client, name="My New App", id="my-app")
297
+ """
298
+
299
+ if exist_ok and (id is None or id == ""):
300
+ raise ValueError("If exist_ok is True, id must be provided")
301
+
302
+ if id is None or id == "":
303
+ id = safe_id("app")
304
+
305
+ if exist_ok and cls.exists(client=client, id=id):
306
+ response = client.request(
307
+ method="GET",
308
+ endpoint=f"v1/applications/{id}",
309
+ )
310
+
311
+ return cls.from_dict({"client": client} | response.json())
312
+
313
+ if name is None or name == "":
314
+ name = id
315
+
316
+ payload = {
317
+ "name": name,
318
+ "id": id,
319
+ }
320
+
321
+ if description is not None:
322
+ payload["description"] = description
323
+
324
+ if is_workflow is not None:
325
+ payload["is_pipeline"] = is_workflow
326
+
327
+ if default_instance_id is not None:
328
+ payload["default_instance"] = default_instance_id
329
+
330
+ if default_experiment_instance is not None:
331
+ payload["default_experiment_instance"] = default_experiment_instance
332
+
333
+ response = client.request(
334
+ method="POST",
335
+ endpoint="v1/applications",
336
+ payload=payload,
337
+ )
338
+
339
+ return cls.from_dict({"client": client} | response.json())
340
+
341
+ def delete(self) -> None:
342
+ """
343
+ Delete the application.
344
+
345
+ Permanently removes the application from Nextmv Cloud.
346
+
347
+ Raises
348
+ ------
349
+ requests.HTTPError
350
+ If the response status code is not 2xx.
351
+
352
+ Examples
353
+ --------
354
+ >>> app.delete() # Permanently deletes the application
355
+ """
356
+
357
+ _ = self.client.request(
358
+ method="DELETE",
359
+ endpoint=self.endpoint,
360
+ )
361
+
362
+ @staticmethod
363
+ def exists(client: Client, id: str) -> bool:
364
+ """
365
+ Check if an application exists.
366
+
367
+ Parameters
368
+ ----------
369
+ client : Client
370
+ Client to use for interacting with the Nextmv Cloud API.
371
+ id : str
372
+ ID of the application to check.
373
+
374
+ Returns
375
+ -------
376
+ bool
377
+ True if the application exists, False otherwise.
378
+
379
+ Examples
380
+ --------
381
+ >>> from nextmv.cloud import Client
382
+ >>> client = Client(api_key="your-api-key")
383
+ >>> Application.exists(client, "app-123")
384
+ True
385
+ """
386
+
387
+ try:
388
+ _ = client.request(
389
+ method="GET",
390
+ endpoint=f"v1/applications/{id}",
391
+ )
392
+ # If the request was successful, the application exists.
393
+ return True
394
+ except requests.HTTPError as e:
395
+ if _is_not_exist_error(e):
396
+ return False
397
+ # Re-throw the exception if it is not the expected 404 error.
398
+ raise e from None
399
+
400
+ def push( # noqa: C901
401
+ self,
402
+ manifest: Manifest | None = None,
403
+ app_dir: str | None = None,
404
+ verbose: bool = False,
405
+ model: Model | None = None,
406
+ model_configuration: ModelConfiguration | None = None,
407
+ rich_print: bool = False,
408
+ ) -> None:
409
+ """
410
+ Push an app to Nextmv Cloud.
411
+
412
+ If the manifest is not provided, an `app.yaml` file will be searched
413
+ for in the provided path. If there is no manifest file found, an
414
+ exception will be raised.
415
+
416
+ There are two ways to push an app to Nextmv Cloud:
417
+ 1. Specifying `app_dir`, which is the path to an app's root directory.
418
+ This acts as an external strategy, where the app is composed of files
419
+ in a directory and those apps are packaged and pushed to Nextmv Cloud.
420
+ 2. Specifying a `model` and `model_configuration`. This acts as an
421
+ internal (or Python-native) strategy, where the app is actually a
422
+ `nextmv.Model`. The model is encoded, some dependencies and
423
+ accompanying files are packaged, and the app is pushed to Nextmv Cloud.
424
+
425
+ Parameters
426
+ ----------
427
+ manifest : Optional[Manifest], default=None
428
+ The manifest for the app. If None, an `app.yaml` file in the provided
429
+ app directory will be used.
430
+ app_dir : Optional[str], default=None
431
+ The path to the app's root directory. If None, the current directory
432
+ will be used. This is for the external strategy approach.
433
+ verbose : bool, default=False
434
+ Whether to print verbose output during the push process.
435
+ model : Optional[Model], default=None
436
+ The Python-native model to push. Must be specified together with
437
+ `model_configuration`. This is for the internal strategy approach.
438
+ model_configuration : Optional[ModelConfiguration], default=None
439
+ Configuration for the Python-native model. Must be specified together
440
+ with `model`.
441
+ rich_print : bool, default=False
442
+ Whether to use rich printing when verbose output is enabled.
443
+
444
+ Raises
445
+ ------
446
+ ValueError
447
+ If neither app_dir nor model/model_configuration is provided correctly,
448
+ or if only one of model and model_configuration is provided.
449
+ TypeError
450
+ If model is not an instance of nextmv.Model or if model_configuration
451
+ is not an instance of nextmv.ModelConfiguration.
452
+ Exception
453
+ If there's an error in the build, packaging, or cleanup process.
454
+
455
+ Examples
456
+ --------
457
+ 1. Push an app using an external strategy (directory-based):
458
+
459
+ >>> import os
460
+ >>> from nextmv import cloud
461
+ >>> client = cloud.Client(api_key=os.getenv("NEXTMV_API_KEY"))
462
+ >>> app = cloud.Application(client=client, id="<YOUR-APP-ID>")
463
+ >>> app.push() # Use verbose=True for step-by-step output.
464
+
465
+ 2. Push an app using an internal strategy (Python-native model):
466
+
467
+ >>> import os
468
+ >>> import nextroute
469
+ >>> import nextmv
470
+ >>> import nextmv.cloud
471
+ >>>
472
+ >>> # Define the model that makes decisions
473
+ >>> class DecisionModel(nextmv.Model):
474
+ ... def solve(self, input: nextmv.Input) -> nextmv.Output:
475
+ ... nextroute_input = nextroute.schema.Input.from_dict(input.data)
476
+ ... nextroute_options = nextroute.Options.extract_from_dict(input.options.to_dict())
477
+ ... nextroute_output = nextroute.solve(nextroute_input, nextroute_options)
478
+ ...
479
+ ... return nextmv.Output(
480
+ ... options=input.options,
481
+ ... solution=nextroute_output.solutions[0].to_dict(),
482
+ ... statistics=nextroute_output.statistics.to_dict(),
483
+ ... )
484
+ >>>
485
+ >>> # Define the options that the model needs
486
+ >>> opt = []
487
+ >>> default_options = nextroute.Options()
488
+ >>> for name, default_value in default_options.to_dict().items():
489
+ ... opt.append(nextmv.Option(name.lower(), type(default_value), default_value, name, False))
490
+ >>> options = nextmv.Options(*opt)
491
+ >>>
492
+ >>> # Instantiate the model and model configuration
493
+ >>> model = DecisionModel()
494
+ >>> model_configuration = nextmv.ModelConfiguration(
495
+ ... name="python_nextroute_model",
496
+ ... requirements=[
497
+ ... "nextroute==1.8.1",
498
+ ... "nextmv==0.14.0.dev1",
499
+ ... ],
500
+ ... options=options,
501
+ ... )
502
+ >>>
503
+ >>> # Push the model to Nextmv Cloud
504
+ >>> client = cloud.Client(api_key=os.getenv("NEXTMV_API_KEY"))
505
+ >>> app = cloud.Application(client=client, id="<YOUR-APP-ID>")
506
+ >>> manifest = nextmv.cloud.default_python_manifest()
507
+ >>> app.push(
508
+ ... manifest=manifest,
509
+ ... verbose=True,
510
+ ... model=model,
511
+ ... model_configuration=model_configuration,
512
+ ... )
513
+ """
514
+
515
+ if verbose:
516
+ if rich_print:
517
+ rich.print(f":cd: Starting build for Nextmv application [magenta]{self.id}[/magenta].", file=sys.stderr)
518
+ else:
519
+ log("💽 Starting build for Nextmv application.")
520
+
521
+ if app_dir is None or app_dir == "":
522
+ app_dir = "."
523
+
524
+ if manifest is None:
525
+ manifest = Manifest.from_yaml(app_dir)
526
+
527
+ if model is not None and not isinstance(model, Model):
528
+ raise TypeError("model must be an instance of nextmv.Model")
529
+
530
+ if model_configuration is not None and not isinstance(model_configuration, ModelConfiguration):
531
+ raise TypeError("model_configuration must be an instance of nextmv.ModelConfiguration")
532
+
533
+ if (model is None and model_configuration is not None) or (model is not None and model_configuration is None):
534
+ raise ValueError("model and model_configuration must be provided together")
535
+
536
+ package._run_build_command(app_dir, manifest.build, verbose, rich_print)
537
+ package._run_pre_push_command(app_dir, manifest.pre_push, verbose, rich_print)
538
+ tar_file, output_dir = package._package(app_dir, manifest, model, model_configuration, verbose, rich_print)
539
+ self.__update_app_binary(tar_file, manifest, verbose, rich_print)
540
+
541
+ try:
542
+ shutil.rmtree(output_dir)
543
+ except OSError as e:
544
+ raise Exception(f"error deleting output directory: {e}") from e
545
+
546
+ def update(
547
+ self,
548
+ name: str | None = None,
549
+ description: str | None = None,
550
+ default_instance_id: str | None = None,
551
+ default_experiment_instance: str | None = None,
552
+ ) -> "Application":
553
+ """
554
+ Update the application.
555
+
556
+ Parameters
557
+ ----------
558
+ name : Optional[str], default=None
559
+ Optional name of the application.
560
+ description : Optional[str], default=None
561
+ Optional description of the application.
562
+ default_instance_id : Optional[str], default=None
563
+ Optional default instance ID for the application.
564
+ default_experiment_instance : Optional[str], default=None
565
+ Optional default experiment instance ID for the application.
566
+
567
+ Returns
568
+ -------
569
+ Application
570
+ The updated application.
571
+
572
+ Raises
573
+ ------
574
+ requests.HTTPError
575
+ If the response status code is not 2xx.
576
+ """
577
+
578
+ app = self.get(client=self.client, id=self.id)
579
+ app_dict = app.to_dict()
580
+ payload = app_dict.copy()
581
+
582
+ if name is not None:
583
+ payload["name"] = name
584
+ if description is not None:
585
+ payload["description"] = description
586
+ if default_instance_id is not None:
587
+ payload["default_instance"] = default_instance_id
588
+ if default_experiment_instance is not None:
589
+ payload["default_experiment_instance"] = default_experiment_instance
590
+
591
+ response = self.client.request(
592
+ method="PUT",
593
+ endpoint=self.endpoint,
594
+ payload=payload,
595
+ )
596
+
597
+ return Application.from_dict({"client": self.client} | response.json())
598
+
599
+ def upload_data(
600
+ self,
601
+ upload_url: UploadURL | str,
602
+ data: dict[str, Any] | str | None = None,
603
+ json_configurations: dict[str, Any] | None = None,
604
+ tar_file: str | None = None,
605
+ ) -> None:
606
+ """
607
+ Upload data to the provided upload URL.
608
+
609
+ This method allows uploading data (either a dictionary or string)
610
+ to a pre-signed URL. If the data is a dictionary, it will be converted to
611
+ a JSON string before upload.
612
+
613
+ Parameters
614
+ ----------
615
+ upload_url : UploadURL | str
616
+ Upload URL object containing the pre-signed URL to use for
617
+ uploading. If it is a string, it will be used directly as the
618
+ pre-signed URL.
619
+ data : Optional[Union[dict[str, Any], str]]
620
+ Data to upload. Can be either a dictionary that will be
621
+ converted to JSON, or a pre-formatted JSON string.
622
+ json_configurations : Optional[dict[str, Any]], default=None
623
+ Optional configurations for JSON serialization. If provided, these
624
+ configurations will be used when serializing the data via
625
+ `json.dumps`.
626
+ tar_file : Optional[str], default=None
627
+ If provided, this will be used to upload a tar file instead of
628
+ a JSON string or dictionary. This is useful for uploading large
629
+ files that are already packaged as a tarball.
630
+
631
+ Returns
632
+ -------
633
+ None
634
+ This method doesn't return anything.
635
+
636
+ Raises
637
+ ------
638
+ requests.HTTPError
639
+ If the response status code is not 2xx.
640
+
641
+ Examples
642
+ --------
643
+ >>> # Upload a dictionary as JSON
644
+ >>> data = {"locations": [...], "vehicles": [...]}
645
+ >>> url = app.upload_url()
646
+ >>> app.upload_data(data=data, upload_url=url)
647
+ >>>
648
+ >>> # Upload a pre-formatted JSON string
649
+ >>> json_str = '{"locations": [...], "vehicles": [...]}'
650
+ >>> app.upload_data(data=json_str, upload_url=url)
651
+ """
652
+
653
+ if data is not None and isinstance(data, dict):
654
+ data = deflated_serialize_json(data, json_configurations=json_configurations)
655
+
656
+ self.client.upload_to_presigned_url(
657
+ url=upload_url.upload_url if isinstance(upload_url, UploadURL) else upload_url,
658
+ data=data,
659
+ tar_file=tar_file,
660
+ )
661
+
662
+ def upload_large_input(
663
+ self,
664
+ input: dict[str, Any] | str | None,
665
+ upload_url: UploadURL,
666
+ json_configurations: dict[str, Any] | None = None,
667
+ tar_file: str | None = None,
668
+ ) -> None:
669
+ """
670
+ !!! warning
671
+ `upload_large_input` is deprecated, use `upload_data` instead.
672
+
673
+ Upload large input data to the provided upload URL.
674
+
675
+ This method allows uploading large input data (either a dictionary or string)
676
+ to a pre-signed URL. If the input is a dictionary, it will be converted to
677
+ a JSON string before upload.
678
+
679
+ Parameters
680
+ ----------
681
+ input : Optional[Union[dict[str, Any], str]]
682
+ Input data to upload. Can be either a dictionary that will be
683
+ converted to JSON, or a pre-formatted JSON string.
684
+ upload_url : UploadURL
685
+ Upload URL object containing the pre-signed URL to use for uploading.
686
+ json_configurations : Optional[dict[str, Any]], default=None
687
+ Optional configurations for JSON serialization. If provided, these
688
+ configurations will be used when serializing the data via
689
+ `json.dumps`.
690
+ tar_file : Optional[str], default=None
691
+ If provided, this will be used to upload a tar file instead of
692
+ a JSON string or dictionary. This is useful for uploading large
693
+ files that are already packaged as a tarball.
694
+
695
+ Returns
696
+ -------
697
+ None
698
+ This method doesn't return anything.
699
+
700
+ Raises
701
+ ------
702
+ requests.HTTPError
703
+ If the response status code is not 2xx.
704
+
705
+ Examples
706
+ --------
707
+ >>> # Upload a dictionary as JSON
708
+ >>> data = {"locations": [...], "vehicles": [...]}
709
+ >>> url = app.upload_url()
710
+ >>> app.upload_large_input(input=data, upload_url=url)
711
+ >>>
712
+ >>> # Upload a pre-formatted JSON string
713
+ >>> json_str = '{"locations": [...], "vehicles": [...]}'
714
+ >>> app.upload_large_input(input=json_str, upload_url=url)
715
+ """
716
+
717
+ deprecated(
718
+ name="Application.upload_large_input",
719
+ reason="`upload_large_input` is deprecated, use `upload_data` instead",
720
+ )
721
+
722
+ self.upload_data(
723
+ data=input,
724
+ upload_url=upload_url,
725
+ json_configurations=json_configurations,
726
+ tar_file=tar_file,
727
+ )
728
+
729
+ def upload_url(self) -> UploadURL:
730
+ """
731
+ Get an upload URL to use for uploading a file.
732
+
733
+ This method generates a pre-signed URL that can be used to upload large files
734
+ to Nextmv Cloud. It's primarily used for uploading large input data, output
735
+ results, or log files that exceed the size limits for direct API calls.
736
+
737
+ Returns
738
+ -------
739
+ UploadURL
740
+ An object containing both the upload URL and an upload ID for reference.
741
+ The upload URL is a pre-signed URL that allows temporary write access.
742
+
743
+ Raises
744
+ ------
745
+ requests.HTTPError
746
+ If the response status code is not 2xx.
747
+
748
+ Examples
749
+ --------
750
+ >>> # Get an upload URL and upload large input data
751
+ >>> upload_url = app.upload_url()
752
+ >>> large_input = {"locations": [...], "vehicles": [...]}
753
+ >>> app.upload_data(data=large_input, upload_url=upload_url)
754
+ """
755
+
756
+ response = self.client.request(
757
+ method="POST",
758
+ endpoint=f"{self.endpoint}/runs/uploadurl",
759
+ )
760
+
761
+ return UploadURL.from_dict(response.json())
762
+
763
+ @staticmethod
764
+ def __convert_manifest_to_payload(manifest: Manifest) -> dict[str, Any]: # noqa: C901
765
+ """Converts a manifest to a payload dictionary for the API."""
766
+
767
+ activation_request = {
768
+ "requirements": {
769
+ "executable_type": manifest.type,
770
+ "runtime": manifest.runtime,
771
+ },
772
+ }
773
+
774
+ if manifest.configuration is not None and manifest.configuration.content is not None:
775
+ content = manifest.configuration.content
776
+ io_config = {
777
+ "format": content.format,
778
+ }
779
+ if content.multi_file is not None:
780
+ multi_config = io_config["multi_file"] = {}
781
+ if content.multi_file.input is not None:
782
+ multi_config["input_path"] = content.multi_file.input.path
783
+ if content.multi_file.output is not None:
784
+ output_config = multi_config["output_configuration"] = {}
785
+ if content.multi_file.output.statistics:
786
+ output_config["statistics_path"] = content.multi_file.output.statistics
787
+ if content.multi_file.output.assets:
788
+ output_config["assets_path"] = content.multi_file.output.assets
789
+ if content.multi_file.output.solutions:
790
+ output_config["solutions_path"] = content.multi_file.output.solutions
791
+ activation_request["requirements"]["io_configuration"] = io_config
792
+
793
+ if manifest.configuration is not None and manifest.configuration.options is not None:
794
+ options = manifest.configuration.options.to_dict()
795
+ if "format" in options and isinstance(options["format"], list):
796
+ # the endpoint expects a dictionary with a template key having a list of strings
797
+ # the app.yaml however defines format as a list of strings, so we need to convert it here
798
+ options["format"] = {
799
+ "template": options["format"],
800
+ }
801
+ activation_request["requirements"]["options"] = options
802
+
803
+ if manifest.execution is not None:
804
+ if manifest.execution.entrypoint:
805
+ activation_request["requirements"]["entrypoint"] = manifest.execution.entrypoint
806
+ if manifest.execution.cwd:
807
+ activation_request["requirements"]["working_directory"] = manifest.execution.cwd
808
+
809
+ return activation_request
810
+
811
+ def __update_app_binary(
812
+ self,
813
+ tar_file: str,
814
+ manifest: Manifest,
815
+ verbose: bool = False,
816
+ rich_print: bool = False,
817
+ ) -> None:
818
+ """Updates the application binary in Cloud."""
819
+
820
+ if verbose:
821
+ if rich_print:
822
+ rich.print(f":star2: Pushing to application: [magenta]{self.id}[/magenta].", file=sys.stderr)
823
+ else:
824
+ log(f'🌟 Pushing to application: "{self.id}".')
825
+
826
+ endpoint = f"{self.endpoint}/binary"
827
+ response = self.client.request(
828
+ method="GET",
829
+ endpoint=endpoint,
830
+ )
831
+ upload_url = response.json()["upload_url"]
832
+
833
+ with open(tar_file, "rb") as f:
834
+ response = self.client.request(
835
+ method="PUT",
836
+ endpoint=upload_url,
837
+ data=f,
838
+ headers={"Content-Type": "application/octet-stream"},
839
+ )
840
+
841
+ response = self.client.request(
842
+ method="PUT",
843
+ endpoint=endpoint,
844
+ payload=Application.__convert_manifest_to_payload(manifest=manifest),
845
+ )
846
+
847
+ if verbose:
848
+ data = {
849
+ "app_id": self.id,
850
+ "endpoint": self.client.url,
851
+ "instance_url": f"{self.endpoint}/runs?instance_id=latest",
852
+ }
853
+
854
+ if rich_print:
855
+ rich.print(f":boom: Successfully pushed to application: [magenta]{self.id}[/magenta].", file=sys.stderr)
856
+ rich.print_json(data=data)
857
+ else:
858
+ log(f'💥️ Successfully pushed to application: "{self.id}".')
859
+ log(json.dumps(data, indent=2))
860
+
861
+
862
+ def list_applications(client: Client) -> list[Application]:
863
+ """
864
+ List all Nextmv Cloud applications.
865
+
866
+ You can import the `list_applications` function directly from `cloud`:
867
+
868
+ ```python
869
+ from nextmv.cloud import list_applications
870
+ ```
871
+
872
+ Parameters
873
+ ----------
874
+ client : Client
875
+ The Nextmv Cloud client used to make API requests.
876
+
877
+ Returns
878
+ -------
879
+ list[Application]
880
+ A list of Nextmv Cloud applications.
881
+
882
+ Raises
883
+ -------
884
+ requests.HTTPError
885
+ If the response status code is not 2xx.
886
+ """
887
+
888
+ response = client.request(
889
+ method="GET",
890
+ endpoint="v1/applications",
891
+ )
892
+
893
+ applications = []
894
+ for app_data in response.json() or []:
895
+ app = Application.from_dict({"client": client} | app_data)
896
+ applications.append(app)
897
+
898
+ return applications