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