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,446 @@
1
+ """
2
+ This module contains functionality for working with Nextmv community apps.
3
+
4
+ Community apps are pre-built decision models. They are maintained in the
5
+ following GitHub repository: https://github.com/nextmv-io/community-apps
6
+
7
+ Classes
8
+ -------
9
+ CommunityApp
10
+ Representation of a Nextmv Cloud Community App.
11
+
12
+ Functions
13
+ ---------
14
+ list_community_apps
15
+ List the available Nextmv community apps.
16
+ clone_community_app
17
+ Clone a community app locally.
18
+ """
19
+
20
+ import os
21
+ import shutil
22
+ import sys
23
+ import tarfile
24
+ import tempfile
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ import requests
29
+ import rich
30
+ import yaml
31
+ from pydantic import AliasChoices, Field
32
+
33
+ from nextmv.base_model import BaseModel
34
+ from nextmv.cloud.client import Client
35
+ from nextmv.logger import log
36
+
37
+ # Helpful constants.
38
+ LATEST_VERSION = "latest"
39
+
40
+
41
+ class CommunityApp(BaseModel):
42
+ """
43
+ Information about a Nextmv community app.
44
+
45
+ You can import the `CommunityApp` class directly from `cloud`:
46
+
47
+ ```python
48
+ from nextmv.cloud import CommunityApp
49
+ ```
50
+
51
+ Parameters
52
+ ----------
53
+ app_versions : list[str]
54
+ Available versions of the community app.
55
+ description : str
56
+ Description of the community app.
57
+ latest_app_version : str
58
+ The latest version of the community app.
59
+ latest_marketplace_version : str
60
+ The latest version of the community app in the Nextmv Marketplace.
61
+ marketplace_versions : list[str]
62
+ Available versions of the community app in the Nextmv Marketplace.
63
+ name : str
64
+ Name of the community app.
65
+ app_type : str
66
+ Type of the community app.
67
+ """
68
+
69
+ description: str
70
+ """Description of the community app."""
71
+ name: str
72
+ """Name of the community app."""
73
+ app_type: str = Field(
74
+ serialization_alias="type",
75
+ validation_alias=AliasChoices("type", "app_type"),
76
+ )
77
+ """Type of the community app."""
78
+
79
+ app_versions: list[str] | None = None
80
+ """Available versions of the community app."""
81
+ latest_app_version: str | None = None
82
+ """The latest version of the community app."""
83
+ latest_marketplace_version: str | None = None
84
+ """The latest version of the community app in the Nextmv Marketplace."""
85
+ marketplace_versions: list[str] | None = None
86
+ """Available versions of the community app in the Nextmv Marketplace."""
87
+
88
+ def has_version(self, version: str) -> bool:
89
+ """
90
+ Check if the community app has the specified version.
91
+
92
+ Parameters
93
+ ----------
94
+ version : str
95
+ The version to check.
96
+
97
+ Returns
98
+ -------
99
+ bool
100
+ True if the app has the specified version, False otherwise.
101
+ """
102
+
103
+ if version == LATEST_VERSION:
104
+ version = self.latest_app_version
105
+
106
+ if self.app_versions is not None and version in self.app_versions:
107
+ return True
108
+
109
+ return False
110
+
111
+
112
+ def list_community_apps(client: Client) -> list[CommunityApp]:
113
+ """
114
+ List the available Nextmv community apps.
115
+
116
+ You can import the `list_community_apps` function directly from `cloud`:
117
+
118
+ ```python
119
+ from nextmv.cloud import list_community_apps
120
+ ```
121
+
122
+ Parameters
123
+ ----------
124
+ client : Client
125
+ The Nextmv Cloud client to use for the request.
126
+
127
+ Returns
128
+ -------
129
+ list[CommunityApp]
130
+ A list of available community apps.
131
+ """
132
+
133
+ manifest = _download_manifest(client)
134
+ dict_apps = manifest.get("apps", [])
135
+ apps = [CommunityApp.from_dict(app) for app in dict_apps]
136
+
137
+ return apps
138
+
139
+
140
+ def clone_community_app(
141
+ client: Client,
142
+ app: str,
143
+ directory: str | None = None,
144
+ version: str | None = LATEST_VERSION,
145
+ verbose: bool = False,
146
+ rich_print: bool = False,
147
+ ) -> None:
148
+ """
149
+ Clone a community app locally.
150
+
151
+ By default, the `latest` version will be used. You can
152
+ specify a version with the `version` parameter, and customize the output
153
+ directory with the `directory` parameter. If you want to list the available
154
+ apps, use the `list_community_apps` function.
155
+
156
+ You can import the `clone_community_app` function directly from `cloud`:
157
+
158
+ ```python
159
+ from nextmv.cloud import clone_community_app
160
+ ```
161
+
162
+ Parameters
163
+ ----------
164
+ client : Client
165
+ The Nextmv Cloud client to use for the request.
166
+ app : str
167
+ The name of the community app to clone.
168
+ directory : str | None, optional
169
+ The directory in which to clone the app. Default is the name of the app at current directory.
170
+ version : str | None, optional
171
+ The version of the community app to clone. Default is `latest`.
172
+ verbose : bool, optional
173
+ Whether to print verbose output.
174
+ rich_print : bool, optional
175
+ Whether to use rich printing for output messages.
176
+ """
177
+ comm_app = _find_app(client, app)
178
+
179
+ if version is not None and version == "":
180
+ raise ValueError("`version` cannot be an empty string.")
181
+
182
+ if not comm_app.has_version(version):
183
+ raise ValueError(f"Community app '{app}' does not have version '{version}'.")
184
+
185
+ original_version = version
186
+ if version == LATEST_VERSION:
187
+ version = comm_app.latest_app_version
188
+
189
+ # Clean and normalize directory path in an OS-independent way
190
+ if directory is not None and directory != "":
191
+ destination = os.path.normpath(directory)
192
+ else:
193
+ destination = app
194
+
195
+ full_destination = _get_valid_path(destination, os.stat)
196
+ os.makedirs(full_destination, exist_ok=True)
197
+
198
+ tarball = f"{app}_{version}.tar.gz"
199
+ s3_file_path = f"{app}/{version}/{tarball}"
200
+ downloaded_object = _download_object(
201
+ client=client,
202
+ file=s3_file_path,
203
+ path="community-apps",
204
+ output_dir=full_destination,
205
+ output_file=tarball,
206
+ )
207
+
208
+ # Extract the tarball to a temporary directory to handle nested structure
209
+ with tempfile.TemporaryDirectory() as temp_dir:
210
+ with tarfile.open(downloaded_object, "r:gz") as tar:
211
+ tar.extractall(path=temp_dir)
212
+
213
+ # Find the extracted directory (typically the app name)
214
+ extracted_items = os.listdir(temp_dir)
215
+ if len(extracted_items) == 1 and os.path.isdir(os.path.join(temp_dir, extracted_items[0])):
216
+ # Move contents from the extracted directory to full_destination
217
+ extracted_dir = os.path.join(temp_dir, extracted_items[0])
218
+ for item in os.listdir(extracted_dir):
219
+ shutil.move(os.path.join(extracted_dir, item), full_destination)
220
+ else:
221
+ # If structure is unexpected, move everything directly
222
+ for item in extracted_items:
223
+ shutil.move(os.path.join(temp_dir, item), full_destination)
224
+
225
+ # Remove the tarball after extraction
226
+ os.remove(downloaded_object)
227
+
228
+ if not verbose:
229
+ return
230
+
231
+ if rich_print:
232
+ rich.print(
233
+ f":white_check_mark: Successfully cloned the [magenta]{app}[/magenta] community app, "
234
+ f"using version [magenta]{original_version}[/magenta] in path: [magenta]{full_destination}[/magenta].",
235
+ file=sys.stderr,
236
+ )
237
+ return
238
+
239
+ log(
240
+ f"✅ Successfully cloned the {app} community app, using version {original_version} in path: {full_destination}."
241
+ )
242
+
243
+
244
+ def _download_manifest(client: Client) -> dict[str, Any]:
245
+ """
246
+ Downloads and returns the community apps manifest.
247
+
248
+ Parameters
249
+ ----------
250
+ client : Client
251
+ The Nextmv Cloud client to use for the request.
252
+
253
+ Returns
254
+ -------
255
+ dict[str, Any]
256
+ The community apps manifest as a dictionary.
257
+
258
+ Raises
259
+ requests.HTTPError
260
+ If the response status code is not 2xx.
261
+ """
262
+
263
+ response = _download_file(client=client, directory="community-apps", file="manifest.yml")
264
+ manifest = yaml.safe_load(response.text)
265
+
266
+ return manifest
267
+
268
+
269
+ def _download_file(
270
+ client: Client,
271
+ directory: str,
272
+ file: str,
273
+ ) -> requests.Response:
274
+ """
275
+ Gets a file from an internal bucket and return it.
276
+
277
+ Parameters
278
+ ----------
279
+ client : Client
280
+ The Nextmv Cloud client to use for the request.
281
+ directory : str
282
+ The directory in the bucket where the file is located.
283
+ file : str
284
+ The name of the file to download.
285
+
286
+ Returns
287
+ -------
288
+ requests.Response
289
+ The response object containing the file data.
290
+
291
+ Raises
292
+ requests.HTTPError
293
+ If the response status code is not 2xx.
294
+ """
295
+
296
+ # Request the download URL for the file.
297
+ response = client.request(
298
+ method="GET",
299
+ endpoint="v0/internal/tools",
300
+ headers=client.headers | {"request-source": "cli"}, # Pass `client.headers` to preserve auth.
301
+ query_params={"file": f"{directory}/{file}"},
302
+ )
303
+
304
+ # Use the URL obtained to download the file.
305
+ body = response.json()
306
+ download_response = client.request(
307
+ method="GET",
308
+ endpoint=body.get("url"),
309
+ headers={"Content-Type": "application/json"},
310
+ )
311
+
312
+ return download_response
313
+
314
+
315
+ def _download_object(client: Client, file: str, path: str, output_dir: str, output_file: str) -> str:
316
+ """
317
+ Downloads an object from the internal bucket and saves it to the specified
318
+ output directory.
319
+
320
+ Parameters
321
+ ----------
322
+ client : Client
323
+ The Nextmv Cloud client to use for the request.
324
+ file : str
325
+ The name of the file to download.
326
+ path : str
327
+ The directory in the bucket where the file is located.
328
+ output_dir : str
329
+ The local directory where the file will be saved.
330
+ output_file : str
331
+ The name of the output file.
332
+
333
+ Returns
334
+ -------
335
+ str
336
+ The path to the downloaded file.
337
+ """
338
+
339
+ response = _download_file(client=client, directory=path, file=file)
340
+ file_name = os.path.join(output_dir, output_file)
341
+
342
+ with open(file_name, "wb") as f:
343
+ f.write(response.content)
344
+
345
+ return file_name
346
+
347
+
348
+ def _get_valid_path(path: str, stat_fn: Callable[[str], os.stat_result], ending: str = "") -> str:
349
+ """
350
+ Validates and returns a non-existing path. If the path exists,
351
+ it will append a number to the path and return it. If the path does not
352
+ exist, it will return the path as is.
353
+
354
+ The ending parameter is used to check if the path ends with a specific
355
+ string. This is useful to specify if it is a file (like foo.json, in which
356
+ case the next iteration is foo-1.json) or a directory (like foo, in which
357
+ case the next iteration is foo-1).
358
+
359
+ Parameters
360
+ ----------
361
+ path : str
362
+ The initial path to validate.
363
+ stat_fn : Callable[[str], os.stat_result]
364
+ A function that takes a path and returns its stat result.
365
+ ending : str, optional
366
+ The expected ending of the path (e.g., file extension), by default "".
367
+
368
+ Returns
369
+ -------
370
+ str
371
+ A valid, non-existing path.
372
+
373
+ Raises
374
+ ------
375
+ RuntimeError
376
+ If an unexpected error occurs during path validation
377
+ """
378
+ base_name = os.path.basename(path)
379
+ name_without_ending = base_name.removesuffix(ending) if ending else base_name
380
+
381
+ while True:
382
+ try:
383
+ stat_fn(path)
384
+ # If we get here, the path exists
385
+ # Get folder/file name number, increase it and create new path
386
+ name = os.path.basename(path)
387
+
388
+ # Get folder/file name number
389
+ parts = name.split("-")
390
+ last = parts[-1].removesuffix(ending) if ending else parts[-1]
391
+
392
+ # Save last folder name index to be changed
393
+ i = path.rfind(name)
394
+
395
+ try:
396
+ num = int(last)
397
+ # Increase number and create new path
398
+ if ending:
399
+ temp_path = path[:i] + f"{name_without_ending}-{num + 1}{ending}"
400
+ else:
401
+ temp_path = path[:i] + f"{base_name}-{num + 1}"
402
+ path = temp_path
403
+ except ValueError:
404
+ # If there is no number, add it
405
+ if ending:
406
+ temp_path = path[:i] + f"{name_without_ending}-1{ending}"
407
+ else:
408
+ temp_path = path[:i] + f"{name}-1"
409
+ path = temp_path
410
+
411
+ except FileNotFoundError:
412
+ # Path doesn't exist, we can use it
413
+ return path
414
+ except Exception as e:
415
+ # Re-raise unexpected errors
416
+ raise RuntimeError(f"An unexpected error occurred while validating the path: {path} ") from e
417
+
418
+
419
+ def _find_app(client: Client, app: str) -> CommunityApp:
420
+ """
421
+ Finds and returns a community app from the manifest by its name.
422
+
423
+ Parameters
424
+ ----------
425
+ client : Client
426
+ The Nextmv Cloud client to use for the request.
427
+ app : str
428
+ The name of the community app to find.
429
+
430
+ Returns
431
+ -------
432
+ CommunityApp
433
+ The community app if found.
434
+
435
+ Raises
436
+ ------
437
+ ValueError
438
+ If the community app is not found.
439
+ """
440
+
441
+ comm_apps = list_community_apps(client)
442
+ for comm_app in comm_apps:
443
+ if comm_app.name == app:
444
+ return comm_app
445
+
446
+ raise ValueError(f"Community app '{app}' not found.")
nextmv/cloud/instance.py CHANGED
@@ -15,7 +15,7 @@ Instance
15
15
  from datetime import datetime
16
16
 
17
17
  from nextmv.base_model import BaseModel
18
- from nextmv.run import RunQueuing
18
+ from nextmv.run import Format, RunQueuing
19
19
 
20
20
 
21
21
  class InstanceConfiguration(BaseModel):
@@ -54,6 +54,9 @@ class InstanceConfiguration(BaseModel):
54
54
 
55
55
  execution_class: str | None = None
56
56
  """Execution class for the instance."""
57
+ format: Format | None = None
58
+ """Input format for the instance, if applicable. When configuring an
59
+ instance, only the `format.format_input` attribute is used."""
57
60
  options: dict | None = None
58
61
  """Options of the app that the instance uses."""
59
62
  secrets_collection_id: str | None = None
@@ -82,6 +85,13 @@ class InstanceConfiguration(BaseModel):
82
85
 
83
86
  self.execution_class = integration_val
84
87
 
88
+ # Processes the format to ensure only format_input is set.
89
+ if self.format is not None and self.format.format_input is not None:
90
+ final_format = Format(format_input=self.format.format_input)
91
+ else:
92
+ final_format = None
93
+ self.format = final_format
94
+
85
95
 
86
96
  class Instance(BaseModel):
87
97
  """An instance of an application tied to a version with configuration.
@@ -225,12 +225,12 @@ class Integration(BaseModel):
225
225
  def new( # noqa: C901
226
226
  cls,
227
227
  client: Client,
228
- name: str,
229
228
  integration_type: IntegrationType | str,
230
229
  exec_types: list[ManifestType | str],
231
230
  provider: IntegrationProvider | str,
232
231
  provider_config: dict[str, Any],
233
232
  integration_id: str | None = None,
233
+ name: str | None = None,
234
234
  description: str | None = None,
235
235
  is_global: bool = False,
236
236
  application_ids: list[str] | None = None,
@@ -243,8 +243,6 @@ class Integration(BaseModel):
243
243
  ----------
244
244
  client : Client
245
245
  Client to use for interacting with the Nextmv Cloud API.
246
- name : str
247
- The name of the integration.
248
246
  integration_type : IntegrationType | str
249
247
  The type of the integration. Please refer to the `IntegrationType`
250
248
  enum for possible values.
@@ -259,6 +257,9 @@ class Integration(BaseModel):
259
257
  integration_id : str, optional
260
258
  The unique identifier of the integration. If not provided,
261
259
  it will be generated automatically.
260
+ name : str | None, optional
261
+ The name of the integration. If not provided, the integration ID
262
+ will be used as the name.
262
263
  description : str, optional
263
264
  An optional description of the integration.
264
265
  is_global : bool, optional, default=False
@@ -302,8 +303,10 @@ class Integration(BaseModel):
302
303
  elif not is_global and application_ids is None:
303
304
  raise ValueError("A non-global integration must have specific application IDs.")
304
305
 
305
- if integration_id is None:
306
+ if integration_id is None or integration_id == "":
306
307
  integration_id = safe_id("integration")
308
+ if name is None or name == "":
309
+ name = integration_id
307
310
 
308
311
  if exist_ok:
309
312
  try:
@@ -429,7 +432,7 @@ class Integration(BaseModel):
429
432
 
430
433
  integration = self.get(client=self.client, integration_id=self.integration_id)
431
434
  integration_dict = integration.to_dict()
432
- payload = integration_dict
435
+ payload = integration_dict.copy()
433
436
 
434
437
  if name is not None:
435
438
  payload["name"] = name
nextmv/cloud/package.py CHANGED
@@ -6,9 +6,12 @@ import platform
6
6
  import re
7
7
  import shutil
8
8
  import subprocess
9
+ import sys
9
10
  import tarfile
10
11
  import tempfile
11
12
 
13
+ import rich
14
+
12
15
  from nextmv.logger import log
13
16
  from nextmv.manifest import MANIFEST_FILE_NAME, Manifest, ManifestBuild, ManifestType
14
17
  from nextmv.model import Model, ModelConfiguration, _cleanup_python_model
@@ -21,18 +24,19 @@ _MANDATORY_FILES_PER_TYPE = {
21
24
  }
22
25
 
23
26
 
24
- def _package(
27
+ def _package( # noqa: C901 # complexity attributed to printing.
25
28
  app_dir: str,
26
29
  manifest: Manifest,
27
30
  model: Model | None = None,
28
31
  model_configuration: ModelConfiguration | None = None,
29
32
  verbose: bool = False,
33
+ rich_print: bool = False,
30
34
  ) -> tuple[str, str]:
31
35
  """Package the app into a tarball."""
32
36
 
33
37
  with tempfile.TemporaryDirectory(prefix="nextmv-temp-") as temp_dir:
34
38
  if manifest.type == ManifestType.PYTHON:
35
- __handle_python(app_dir, temp_dir, manifest, model, model_configuration, verbose)
39
+ __handle_python(app_dir, temp_dir, manifest, model, model_configuration, verbose, rich_print)
36
40
 
37
41
  found, missing, files = __find_files(app_dir, manifest.files)
38
42
  __confirm_mandatory_files(manifest, found)
@@ -55,7 +59,13 @@ def _package(
55
59
  raise Exception(f"error copying asset files {file['absolute_path']}: {e}") from e
56
60
 
57
61
  if verbose:
58
- log(f'📋 Copied files listed in "{MANIFEST_FILE_NAME}" manifest.')
62
+ if rich_print:
63
+ rich.print(
64
+ f":clipboard: Copied files listed in [magenta]{MANIFEST_FILE_NAME}[/magenta] manifest.",
65
+ file=sys.stderr,
66
+ )
67
+ else:
68
+ log(f'📋 Copied files listed in "{MANIFEST_FILE_NAME}" manifest.')
59
69
 
60
70
  if manifest.type == ManifestType.PYTHON:
61
71
  _cleanup_python_model(app_dir, model_configuration, verbose)
@@ -66,9 +76,22 @@ def _package(
66
76
  if verbose:
67
77
  try:
68
78
  size = __human_friendly_file_size(tar_file)
69
- log(f"📦 Packaged application ({file_count_msg}, {size}).")
79
+ if rich_print:
80
+ rich.print(
81
+ ":package: Packaged application "
82
+ f"([magenta]{file_count_msg}[/magenta], [magenta]{size}[/magenta]).",
83
+ file=sys.stderr,
84
+ )
85
+ else:
86
+ log(f"📦 Packaged application ({file_count_msg}, {size}).")
70
87
  except Exception:
71
- log(f"📦 Packaged application ({file_count_msg}).")
88
+ if rich_print:
89
+ rich.print(
90
+ f":package: Packaged application ([magenta]{file_count_msg}[/magenta]).",
91
+ file=sys.stderr,
92
+ )
93
+ else:
94
+ log(f"📦 Packaged application ({file_count_msg}).")
72
95
 
73
96
  return tar_file, output_dir
74
97
 
@@ -77,6 +100,7 @@ def _run_build_command(
77
100
  app_dir: str,
78
101
  manifest_build: ManifestBuild | None = None,
79
102
  verbose: bool = False,
103
+ rich_print: bool = False,
80
104
  ) -> None:
81
105
  """Run the build command specified in the manifest."""
82
106
 
@@ -85,7 +109,12 @@ def _run_build_command(
85
109
 
86
110
  elements = manifest_build.command.split(" ")
87
111
  command_str = " ".join(elements)
88
- log(f'🚧 Running build command: "{command_str}"')
112
+
113
+ if verbose:
114
+ if rich_print:
115
+ rich.print(f":construction: Running build command: [magenta]{command_str}[/magenta]", file=sys.stderr)
116
+ else:
117
+ log(f'🚧 Running build command: "{command_str}"')
89
118
  try:
90
119
  result = subprocess.run(
91
120
  elements,
@@ -120,6 +149,7 @@ def _run_pre_push_command(
120
149
  app_dir: str,
121
150
  pre_push_command: str | None = None,
122
151
  verbose: bool = False,
152
+ rich_print: bool = False,
123
153
  ) -> None:
124
154
  """Run the pre-push command specified in the manifest."""
125
155
 
@@ -129,7 +159,11 @@ def _run_pre_push_command(
129
159
  elements = _get_shell_command_elements(pre_push_command)
130
160
 
131
161
  command_str = " ".join(elements)
132
- log(f'🔨 Running pre-push command: "{command_str}"')
162
+ if verbose:
163
+ if rich_print:
164
+ rich.print(f":hammer: Running pre-push command: [magenta]{command_str}[/magenta]", file=sys.stderr)
165
+ else:
166
+ log(f'🔨 Running pre-push command: "{command_str}"')
133
167
  try:
134
168
  result = subprocess.run(
135
169
  elements,
@@ -227,16 +261,23 @@ def __handle_python(
227
261
  model: Model | None = None,
228
262
  model_configuration: ModelConfiguration | None = None,
229
263
  verbose: bool = False,
264
+ rich_print: bool = False,
230
265
  ) -> None:
231
266
  """Handles the Python-specific packaging logic."""
232
267
 
233
268
  if model is not None and model_configuration is not None:
234
269
  if verbose:
235
- log("🔮 Encoding Python model.")
270
+ if rich_print:
271
+ rich.print(":crystal_ball: Encoding Python model.", file=sys.stderr)
272
+ else:
273
+ log("🔮 Encoding Python model.")
236
274
  model.save(app_dir, model_configuration)
237
275
 
238
276
  if verbose:
239
- log("🐍 Bundling Python dependencies.")
277
+ if rich_print:
278
+ rich.print(":snake: Bundling Python dependencies.", file=sys.stderr)
279
+ else:
280
+ log("🐍 Bundling Python dependencies.")
240
281
  __install_dependencies(manifest, app_dir, temp_dir)
241
282
 
242
283