nextmv 1.0.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 (140) hide show
  1. nextmv/__about__.py +1 -1
  2. nextmv/__entrypoint__.py +2 -1
  3. nextmv/__init__.py +4 -0
  4. nextmv/cli/CONTRIBUTING.md +40 -112
  5. nextmv/cli/cloud/__init__.py +0 -4
  6. nextmv/cli/cloud/acceptance/create.py +22 -20
  7. nextmv/cli/cloud/acceptance/delete.py +12 -8
  8. nextmv/cli/cloud/acceptance/get.py +10 -9
  9. nextmv/cli/cloud/acceptance/list.py +3 -3
  10. nextmv/cli/cloud/acceptance/update.py +6 -6
  11. nextmv/cli/cloud/account/__init__.py +3 -3
  12. nextmv/cli/cloud/account/create.py +11 -11
  13. nextmv/cli/cloud/account/delete.py +8 -7
  14. nextmv/cli/cloud/account/get.py +3 -3
  15. nextmv/cli/cloud/account/update.py +5 -5
  16. nextmv/cli/cloud/app/create.py +26 -25
  17. nextmv/cli/cloud/app/delete.py +7 -6
  18. nextmv/cli/cloud/app/exists.py +2 -2
  19. nextmv/cli/cloud/app/get.py +2 -2
  20. nextmv/cli/cloud/app/list.py +3 -3
  21. nextmv/cli/cloud/app/push.py +54 -349
  22. nextmv/cli/cloud/app/update.py +12 -12
  23. nextmv/cli/cloud/batch/create.py +28 -26
  24. nextmv/cli/cloud/batch/delete.py +10 -6
  25. nextmv/cli/cloud/batch/get.py +9 -9
  26. nextmv/cli/cloud/batch/list.py +3 -3
  27. nextmv/cli/cloud/batch/metadata.py +4 -4
  28. nextmv/cli/cloud/batch/update.py +6 -6
  29. nextmv/cli/cloud/data/__init__.py +1 -1
  30. nextmv/cli/cloud/data/upload.py +15 -15
  31. nextmv/cli/cloud/ensemble/__init__.py +0 -2
  32. nextmv/cli/cloud/ensemble/create.py +22 -21
  33. nextmv/cli/cloud/ensemble/delete.py +10 -6
  34. nextmv/cli/cloud/ensemble/get.py +4 -4
  35. nextmv/cli/cloud/ensemble/update.py +9 -9
  36. nextmv/cli/cloud/input_set/__init__.py +0 -2
  37. nextmv/cli/cloud/input_set/create.py +22 -22
  38. nextmv/cli/cloud/input_set/get.py +3 -3
  39. nextmv/cli/cloud/input_set/list.py +3 -3
  40. nextmv/cli/cloud/input_set/update.py +24 -24
  41. nextmv/cli/cloud/instance/create.py +15 -14
  42. nextmv/cli/cloud/instance/delete.py +7 -6
  43. nextmv/cli/cloud/instance/exists.py +2 -2
  44. nextmv/cli/cloud/instance/get.py +2 -2
  45. nextmv/cli/cloud/instance/list.py +3 -3
  46. nextmv/cli/cloud/instance/update.py +14 -14
  47. nextmv/cli/cloud/managed_input/create.py +16 -14
  48. nextmv/cli/cloud/managed_input/delete.py +8 -7
  49. nextmv/cli/cloud/managed_input/get.py +3 -3
  50. nextmv/cli/cloud/managed_input/list.py +3 -3
  51. nextmv/cli/cloud/managed_input/update.py +9 -9
  52. nextmv/cli/cloud/run/cancel.py +2 -2
  53. nextmv/cli/cloud/run/create.py +40 -34
  54. nextmv/cli/cloud/run/get.py +8 -8
  55. nextmv/cli/cloud/run/input.py +4 -4
  56. nextmv/cli/cloud/run/list.py +6 -6
  57. nextmv/cli/cloud/run/logs.py +10 -9
  58. nextmv/cli/cloud/run/metadata.py +4 -4
  59. nextmv/cli/cloud/run/track.py +33 -32
  60. nextmv/cli/cloud/scenario/create.py +21 -21
  61. nextmv/cli/cloud/scenario/delete.py +10 -6
  62. nextmv/cli/cloud/scenario/get.py +9 -9
  63. nextmv/cli/cloud/scenario/list.py +3 -3
  64. nextmv/cli/cloud/scenario/metadata.py +4 -4
  65. nextmv/cli/cloud/scenario/update.py +6 -6
  66. nextmv/cli/cloud/secrets/create.py +17 -17
  67. nextmv/cli/cloud/secrets/delete.py +10 -6
  68. nextmv/cli/cloud/secrets/get.py +4 -4
  69. nextmv/cli/cloud/secrets/list.py +3 -3
  70. nextmv/cli/cloud/secrets/update.py +20 -17
  71. nextmv/cli/cloud/upload/create.py +2 -2
  72. nextmv/cli/cloud/version/create.py +10 -9
  73. nextmv/cli/cloud/version/delete.py +7 -6
  74. nextmv/cli/cloud/version/exists.py +2 -2
  75. nextmv/cli/cloud/version/get.py +2 -2
  76. nextmv/cli/cloud/version/list.py +3 -3
  77. nextmv/cli/cloud/version/update.py +8 -8
  78. nextmv/cli/community/__init__.py +1 -1
  79. nextmv/cli/community/clone.py +204 -20
  80. nextmv/cli/community/list.py +125 -60
  81. nextmv/cli/configuration/config.py +10 -43
  82. nextmv/cli/configuration/create.py +7 -7
  83. nextmv/cli/configuration/delete.py +8 -8
  84. nextmv/cli/configuration/list.py +3 -3
  85. nextmv/cli/main.py +36 -26
  86. nextmv/cli/message.py +54 -71
  87. nextmv/cli/options.py +0 -28
  88. nextmv/cli/version.py +1 -1
  89. nextmv/cloud/__init__.py +38 -14
  90. nextmv/cloud/acceptance_test.py +65 -1
  91. nextmv/cloud/account.py +6 -1
  92. nextmv/cloud/application/__init__.py +75 -18
  93. nextmv/cloud/application/_acceptance.py +8 -13
  94. nextmv/cloud/application/_batch_scenario.py +19 -4
  95. nextmv/cloud/application/_input_set.py +6 -42
  96. nextmv/cloud/application/_instance.py +3 -3
  97. nextmv/cloud/application/_managed_input.py +2 -2
  98. nextmv/cloud/application/_version.py +3 -4
  99. nextmv/cloud/batch_experiment.py +1 -3
  100. nextmv/cloud/integration.py +4 -7
  101. nextmv/deprecated.py +3 -5
  102. nextmv/input.py +52 -0
  103. nextmv/local/runner.py +1 -1
  104. nextmv/model.py +11 -50
  105. nextmv/options.py +256 -11
  106. nextmv/output.py +62 -0
  107. nextmv/run.py +10 -1
  108. nextmv/status.py +51 -1
  109. {nextmv-1.0.0.dist-info → nextmv-1.0.0.dev0.dist-info}/METADATA +4 -5
  110. nextmv-1.0.0.dev0.dist-info/RECORD +158 -0
  111. nextmv/cli/cloud/ensemble/list.py +0 -63
  112. nextmv/cli/cloud/input_set/delete.py +0 -64
  113. nextmv/cli/cloud/shadow/__init__.py +0 -33
  114. nextmv/cli/cloud/shadow/create.py +0 -184
  115. nextmv/cli/cloud/shadow/delete.py +0 -64
  116. nextmv/cli/cloud/shadow/get.py +0 -61
  117. nextmv/cli/cloud/shadow/list.py +0 -63
  118. nextmv/cli/cloud/shadow/metadata.py +0 -66
  119. nextmv/cli/cloud/shadow/start.py +0 -43
  120. nextmv/cli/cloud/shadow/stop.py +0 -53
  121. nextmv/cli/cloud/shadow/update.py +0 -96
  122. nextmv/cli/cloud/switchback/__init__.py +0 -33
  123. nextmv/cli/cloud/switchback/create.py +0 -151
  124. nextmv/cli/cloud/switchback/delete.py +0 -64
  125. nextmv/cli/cloud/switchback/get.py +0 -62
  126. nextmv/cli/cloud/switchback/list.py +0 -63
  127. nextmv/cli/cloud/switchback/metadata.py +0 -68
  128. nextmv/cli/cloud/switchback/start.py +0 -43
  129. nextmv/cli/cloud/switchback/stop.py +0 -53
  130. nextmv/cli/cloud/switchback/update.py +0 -96
  131. nextmv/cli/confirm.py +0 -34
  132. nextmv/cloud/application/_shadow.py +0 -320
  133. nextmv/cloud/application/_switchback.py +0 -332
  134. nextmv/cloud/community.py +0 -446
  135. nextmv/cloud/shadow.py +0 -254
  136. nextmv/cloud/switchback.py +0 -228
  137. nextmv-1.0.0.dist-info/RECORD +0 -185
  138. nextmv-1.0.0.dist-info/entry_points.txt +0 -2
  139. {nextmv-1.0.0.dist-info → nextmv-1.0.0.dev0.dist-info}/WHEEL +0 -0
  140. {nextmv-1.0.0.dist-info → nextmv-1.0.0.dev0.dist-info}/licenses/LICENSE +0 -0
@@ -2,14 +2,19 @@
2
2
  This module defines the community clone command for the Nextmv CLI.
3
3
  """
4
4
 
5
+ import os
6
+ import shutil
7
+ import tarfile
8
+ import tempfile
9
+ from collections.abc import Callable
5
10
  from typing import Annotated
6
11
 
12
+ import rich
7
13
  import typer
8
14
 
9
- from nextmv.cli.configuration.config import build_client
10
- from nextmv.cli.message import error
15
+ from nextmv.cli.community.list import download_file, download_manifest, find_app, versions_table
16
+ from nextmv.cli.message import error, success
11
17
  from nextmv.cli.options import ProfileOption
12
- from nextmv.cloud.community import clone_community_app
13
18
 
14
19
  # Set up subcommand application.
15
20
  app = typer.Typer()
@@ -48,39 +53,218 @@ def clone(
48
53
  Clone a community app locally.
49
54
 
50
55
  By default, the [magenta]latest[/magenta] version will be used. You can
51
- specify a version with the --version flag, and customize the output
52
- directory with the --directory flag. If you want to list the available
53
- apps, use the [code]nextmv community list[/code] command.
56
+ specify a version with the [code]--version[/code] flag, and customize the
57
+ output directory with the [code]--directory[/code] flag. If you want to
58
+ list the available apps, use the [code]nextmv community list[/code] command.
54
59
 
55
60
  [bold][underline]Examples[/underline][/bold]
56
61
 
57
62
  - Clone the [magenta]go-nextroute[/magenta] community app (under the
58
63
  [magenta]"go-nextroute"[/magenta] directory), using the [magenta]latest[/magenta] version.
59
- $ [dim]nextmv community clone --app go-nextroute[/dim]
64
+ $ [green]nextmv community clone --app go-nextroute[/green]
60
65
 
61
66
  - Clone the [magenta]go-nextroute[/magenta] community app under the
62
67
  [magenta]"~/sample/my_app"[/magenta] directory, using the [magenta]latest[/magenta] version.
63
- $ [dim]nextmv community clone --app go-nextroute --directory ~/sample/my_app[/dim]
68
+ $ [green]nextmv community clone --app go-nextroute --directory ~/sample/my_app[/green]
64
69
 
65
70
  - Clone the [magenta]go-nextroute[/magenta] community app (under the
66
71
  [magenta]"go-nextroute"[/magenta] directory), using version [magenta]v1.2.0[/magenta].
67
- $ [dim]nextmv community clone --app go-nextroute --version v1.2.0[/dim]
72
+ $ [green]nextmv community clone --app go-nextroute --version v1.2.0[/green]
68
73
 
69
74
  - Clone the [magenta]go-nextroute[/magenta] community app (under the
70
75
  [magenta]"go-nextroute"[/magenta] directory), using the [magenta]latest[/magenta] version
71
76
  and a profile named [magenta]hare[/magenta].
72
- $ [dim]nextmv community clone --app go-nextroute --profile hare[/dim]
77
+ $ [green]nextmv community clone --app go-nextroute --profile hare[/green]
73
78
  """
74
79
 
80
+ manifest = download_manifest(profile=profile)
81
+ app_obj = find_app(manifest, app)
82
+
75
83
  if version is not None and version == "":
76
- error("The --version flag cannot be an empty string.")
77
-
78
- client = build_client(profile)
79
- clone_community_app(
80
- client=client,
81
- app=app,
82
- directory=directory,
83
- version=version,
84
- verbose=True,
85
- rich_print=True,
84
+ error("The [code]--version[/code] flag cannot be an empty string.")
85
+
86
+ if not app_has_version(app_obj, version):
87
+ # We don't use error() here to allow printing something before exiting.
88
+ rich.print(
89
+ f"[red]Error:[/red] Version [magenta]{version}[/magenta] not found "
90
+ f"for community app [magenta]{app}[/magenta]. Available versions are:"
91
+ )
92
+ versions_table(manifest, app)
93
+
94
+ raise typer.Exit(code=1)
95
+
96
+ original_version = version
97
+ if version == LATEST_VERSION:
98
+ version = app_obj.get("latest_app_version")
99
+
100
+ destination = directory
101
+ if directory is None or directory == "":
102
+ destination = app
103
+
104
+ full_destination = get_valid_path(destination, os.stat)
105
+ os.makedirs(full_destination, exist_ok=True)
106
+
107
+ tarball = f"{app}_{version}.tar.gz"
108
+ s3_file_path = f"{app}/{version}/{tarball}"
109
+ downloaded_object = download_object(
110
+ file=s3_file_path,
111
+ path="community-apps",
112
+ output_dir=full_destination,
113
+ output_file=tarball,
114
+ profile=profile,
115
+ )
116
+
117
+ # Extract the tarball to a temporary directory to handle nested structure
118
+ with tempfile.TemporaryDirectory() as temp_dir:
119
+ with tarfile.open(downloaded_object, "r:gz") as tar:
120
+ tar.extractall(path=temp_dir)
121
+
122
+ # Find the extracted directory (typically the app name)
123
+ extracted_items = os.listdir(temp_dir)
124
+ if len(extracted_items) == 1 and os.path.isdir(os.path.join(temp_dir, extracted_items[0])):
125
+ # Move contents from the extracted directory to full_destination
126
+ extracted_dir = os.path.join(temp_dir, extracted_items[0])
127
+ for item in os.listdir(extracted_dir):
128
+ shutil.move(os.path.join(extracted_dir, item), full_destination)
129
+ else:
130
+ # If structure is unexpected, move everything directly
131
+ for item in extracted_items:
132
+ shutil.move(os.path.join(temp_dir, item), full_destination)
133
+
134
+ # Remove the tarball after extraction
135
+ os.remove(downloaded_object)
136
+
137
+ success(
138
+ f"Successfully cloned the [magenta]{app}[/magenta] community app, "
139
+ f"using version [magenta]{original_version}[/magenta] in path: [magenta]{full_destination}[/magenta]."
86
140
  )
141
+
142
+
143
+ def app_has_version(app_obj: dict, version: str) -> bool:
144
+ """
145
+ Check if the given app object has the specified version.
146
+
147
+ Parameters
148
+ ----------
149
+ app_obj : dict
150
+ The community app object.
151
+ version : str
152
+ The version to check.
153
+
154
+ Returns
155
+ -------
156
+ bool
157
+ True if the app has the specified version, False otherwise.
158
+ """
159
+
160
+ if version == LATEST_VERSION:
161
+ version = app_obj.get("latest_app_version")
162
+
163
+ if version in app_obj.get("app_versions", []):
164
+ return True
165
+
166
+ return False
167
+
168
+
169
+ def get_valid_path(path: str, stat_fn: Callable[[str], os.stat_result], ending: str = "") -> str:
170
+ """
171
+ Validates and returns a non-existing path. If the path exists,
172
+ it will append a number to the path and return it. If the path does not
173
+ exist, it will return the path as is.
174
+
175
+ The ending parameter is used to check if the path ends with a specific
176
+ string. This is useful to specify if it is a file (like foo.json, in which
177
+ case the next iteration is foo-1.json) or a directory (like foo, in which
178
+ case the next iteration is foo-1).
179
+
180
+ Parameters
181
+ ----------
182
+ path : str
183
+ The initial path to validate.
184
+ stat_fn : Callable[[str], os.stat_result]
185
+ A function that takes a path and returns its stat result.
186
+ ending : str, optional
187
+ The expected ending of the path (e.g., file extension), by default "".
188
+
189
+ Returns
190
+ -------
191
+ str
192
+ A valid, non-existing path.
193
+
194
+ Raises
195
+ ------
196
+ Exception
197
+ If an unexpected error occurs during path validation
198
+ """
199
+ base_name = os.path.basename(path)
200
+ name_without_ending = base_name.removesuffix(ending) if ending else base_name
201
+
202
+ while True:
203
+ try:
204
+ stat_fn(path)
205
+ # If we get here, the path exists
206
+ # Get folder/file name number, increase it and create new path
207
+ name = os.path.basename(path)
208
+
209
+ # Get folder/file name number
210
+ parts = name.split("-")
211
+ last = parts[-1].removesuffix(ending) if ending else parts[-1]
212
+
213
+ # Save last folder name index to be changed
214
+ i = path.rfind(name)
215
+
216
+ try:
217
+ num = int(last)
218
+ # Increase number and create new path
219
+ if ending:
220
+ temp_path = path[:i] + f"{name_without_ending}-{num + 1}{ending}"
221
+ else:
222
+ temp_path = path[:i] + f"{base_name}-{num + 1}"
223
+ path = temp_path
224
+ except ValueError:
225
+ # If there is no number, add it
226
+ if ending:
227
+ temp_path = path[:i] + f"{name_without_ending}-1{ending}"
228
+ else:
229
+ temp_path = path[:i] + f"{name}-1"
230
+ path = temp_path
231
+
232
+ except FileNotFoundError:
233
+ # Path doesn't exist, we can use it
234
+ return path
235
+ except Exception:
236
+ # Re-raise unexpected errors
237
+ error(f"An unexpected error occurred while validating the path: {path}")
238
+
239
+
240
+ def download_object(file: str, path: str, output_dir: str, output_file: str, profile: str | None = None) -> str:
241
+ """
242
+ Downloads an object from the internal bucket and saves it to the specified
243
+ output directory.
244
+
245
+ Parameters
246
+ ----------
247
+ file : str
248
+ The name of the file to download.
249
+ path : str
250
+ The directory in the bucket where the file is located.
251
+ output_dir : str
252
+ The local directory where the file will be saved.
253
+ output_file : str
254
+ The name of the output file.
255
+ profile : str | None
256
+ The profile name to use. If None, the default profile is used.
257
+
258
+ Returns
259
+ -------
260
+ str
261
+ The path to the downloaded file.
262
+ """
263
+
264
+ response = download_file(directory=path, file=file, profile=profile)
265
+ file_name = os.path.join(output_dir, output_file)
266
+
267
+ with open(file_name, "wb") as f:
268
+ f.write(response.content)
269
+
270
+ return file_name
@@ -2,18 +2,18 @@
2
2
  This module defines the community list command for the Nextmv CLI.
3
3
  """
4
4
 
5
- from typing import Annotated
5
+ from typing import Annotated, Any
6
6
 
7
+ import requests
7
8
  import rich
8
9
  import typer
10
+ import yaml
9
11
  from rich.console import Console
10
12
  from rich.table import Table
11
13
 
12
14
  from nextmv.cli.configuration.config import build_client
13
15
  from nextmv.cli.message import error
14
16
  from nextmv.cli.options import ProfileOption
15
- from nextmv.cloud.client import Client
16
- from nextmv.cloud.community import CommunityApp, list_community_apps
17
17
 
18
18
  # Set up subcommand application.
19
19
  app = typer.Typer()
@@ -37,98 +37,121 @@ def list(
37
37
  """
38
38
  List the available community apps
39
39
 
40
- Use the --app flag to list that app's versions. Use the --flat flag to
41
- flatten the list of names/versions. If you want to clone a community app
42
- locally, use the [code]nextmv community clone[/code] command.
40
+ Use the [code]--app[/code] flag to list that app's versions. Use the
41
+ [code]--flat[/code] flag to flatten the list of names/versions. If you
42
+ want to clone a community app locally, use the [code]nextmv community clone[/code] command.
43
43
 
44
44
  [bold][underline]Examples[/underline][/bold]
45
45
 
46
46
  - List the available community apps.
47
- $ [dim]nextmv community list[/dim]
47
+ $ [green]nextmv community list[/green]
48
48
 
49
49
  - List the available versions of the [magenta]go-nextroute[/magenta] community app.
50
- $ [dim]nextmv community list --app go-nextroute[/dim]
50
+ $ [green]nextmv community list --app go-nextroute[/green]
51
51
 
52
52
  - List the names of the available community apps as a flat list.
53
- $ [dim]nextmv community list --flat[/dim]
53
+ $ [green]nextmv community list --flat[/green]
54
54
 
55
55
  - List the available versions of the [magenta]go-nextroute[/magenta] community app as a flat list.
56
- $ [dim]nextmv community list --app go-nextroute --flat[/dim]
56
+ $ [green]nextmv community list --app go-nextroute --flat[/green]
57
57
 
58
58
  - List the available community apps using a profile named [magenta]hare[/magenta].
59
- $ [dim]nextmv community list --profile hare[/dim]
59
+ $ [green]nextmv community list --profile hare[/green]
60
60
  """
61
61
 
62
62
  if app is not None and app == "":
63
- error("The --app flag cannot be an empty string.")
63
+ error("The [code]--app[/code] flag cannot be an empty string.")
64
64
 
65
- client = build_client(profile)
65
+ manifest = download_manifest(profile=profile)
66
66
  if flat and app is None:
67
- _apps_list(client)
67
+ apps_list(manifest)
68
68
  raise typer.Exit()
69
69
  elif not flat and app is None:
70
- _apps_table(client)
70
+ apps_table(manifest)
71
71
  raise typer.Exit()
72
72
  elif flat and app is not None and app != "":
73
- _versions_list(client, app)
73
+ versions_list(manifest, app)
74
74
  raise typer.Exit()
75
75
  elif not flat and app is not None and app != "":
76
- _versions_table(client, app)
76
+ versions_table(manifest, app)
77
77
  raise typer.Exit()
78
78
 
79
79
 
80
- def _apps_table(client: Client) -> None:
80
+ def download_manifest(profile: str | None = None) -> dict:
81
81
  """
82
- This function prints a table of community apps.
82
+ Downloads and returns the community apps manifest.
83
83
 
84
84
  Parameters
85
85
  ----------
86
- client : Client
87
- The Nextmv Cloud client to use for the request.
86
+ profile : str | None
87
+ The profile name to use. If None, the default profile is used.
88
+
89
+ Returns
90
+ -------
91
+ dict
92
+ The community apps manifest as a dictionary.
93
+
94
+ Raises
95
+ requests.HTTPError
96
+ If the response status code is not 2xx.
97
+ """
98
+
99
+ response = download_file(directory="community-apps", file="manifest.yml", profile=profile)
100
+ manifest = yaml.safe_load(response.text)
101
+
102
+ return manifest
103
+
104
+
105
+ def apps_table(manifest: dict[str, Any]) -> None:
106
+ """
107
+ This function prints a table of community apps from the manifest.
108
+
109
+ Parameters
110
+ ----------
111
+ manifest : dict[str, Any]
112
+ The community apps manifest.
88
113
  """
89
114
 
90
- apps = list_community_apps(client)
91
115
  table = Table("Name", "Type", "Latest", "Description", border_style="cyan", header_style="cyan")
92
- for app in apps:
116
+ for app in manifest.get("apps", []):
93
117
  table.add_row(
94
- app.name,
95
- app.app_type,
96
- app.latest_app_version if app.latest_app_version is not None else "",
97
- app.description,
118
+ app.get("name", ""),
119
+ app.get("type", ""),
120
+ app.get("latest_app_version", ""),
121
+ app.get("description", ""),
98
122
  )
99
123
 
100
124
  console.print(table)
101
125
 
102
126
 
103
- def _apps_list(client: Client) -> None:
127
+ def apps_list(manifest: dict[str, Any]) -> None:
104
128
  """
105
- This function prints a flat list of community app names.
129
+ This function prints a flat list of community app names from the manifest.
106
130
 
107
131
  Parameters
108
132
  ----------
109
- client : Client
110
- The Nextmv Cloud client to use for the request.
133
+ manifest : dict[str, Any]
134
+ The community apps manifest.
111
135
  """
112
136
 
113
- apps = list_community_apps(client)
114
- names = [app.name for app in apps]
137
+ names = [app.get("name", "") for app in manifest.get("apps", [])]
115
138
  print("\n".join(names))
116
139
 
117
140
 
118
- def _versions_table(client: Client, app: str) -> None:
141
+ def versions_table(manifest: dict[str, Any], app: str) -> None:
119
142
  """
120
143
  This function prints a table of versions for a specific community app.
121
144
 
122
145
  Parameters
123
146
  ----------
124
- client : Client
125
- The Nextmv Cloud client to use for the request.
147
+ manifest : dict[str, Any]
148
+ The community apps manifest.
126
149
  app : str
127
150
  The name of the community app.
128
151
  """
129
152
 
130
- comm_app = _find_app(client, app)
131
- latest_version = comm_app.latest_app_version if comm_app.latest_app_version is not None else ""
153
+ app_obj = find_app(manifest, app)
154
+ latest_version = app_obj.get("latest_app_version", "")
132
155
 
133
156
  # Add the latest version with indicator
134
157
  table = Table("Version", "Latest?", border_style="cyan", header_style="cyan")
@@ -136,7 +159,7 @@ def _versions_table(client: Client, app: str) -> None:
136
159
  table.add_row("", "") # Empty row to separate latest from others.
137
160
 
138
161
  # Add all other versions (excluding the latest)
139
- versions = comm_app.app_versions if comm_app.app_versions is not None else []
162
+ versions = app_obj.get("app_versions", [])
140
163
  for version in versions:
141
164
  if version != latest_version:
142
165
  table.add_row(version, "")
@@ -144,57 +167,99 @@ def _versions_table(client: Client, app: str) -> None:
144
167
  console.print(table)
145
168
 
146
169
 
147
- def _versions_list(client: Client, app: str) -> None:
170
+ def versions_list(manifest: dict[str, Any], app: str) -> None:
148
171
  """
149
172
  This function prints a flat list of versions for a specific community app.
150
173
 
151
174
  Parameters
152
175
  ----------
153
- client : Client
154
- The Nextmv Cloud client to use for the request.
176
+ manifest : dict[str, Any]
177
+ The community apps manifest.
155
178
  app : str
156
179
  The name of the community app.
157
180
  """
158
181
 
159
- comm_app = _find_app(client, app)
160
- versions = comm_app.app_versions if comm_app.app_versions is not None else []
182
+ app_obj = find_app(manifest, app)
183
+ versions = app_obj.get("app_versions", [])
161
184
 
162
185
  versions_output = ""
163
186
  for version in versions:
164
187
  versions_output += f"{version}\n"
165
188
 
166
- print(versions_output.rstrip("\n"))
189
+ print("\n".join(app_obj.get("app_versions", [])))
190
+
191
+
192
+ def download_file(
193
+ directory: str,
194
+ file: str,
195
+ profile: str | None = None,
196
+ ) -> requests.Response:
197
+ """
198
+ Gets a file from an internal bucket and return it.
167
199
 
200
+ Parameters
201
+ ----------
202
+ directory : str
203
+ The directory in the bucket where the file is located.
204
+ file : str
205
+ The name of the file to download.
206
+ profile : str | None
207
+ The profile name to use. If None, the default profile is used.
168
208
 
169
- def _find_app(client: Client, app: str) -> CommunityApp:
209
+ Returns
210
+ -------
211
+ requests.Response
212
+ The response object containing the file data.
213
+
214
+ Raises
215
+ requests.HTTPError
216
+ If the response status code is not 2xx.
217
+ """
218
+
219
+ client = build_client(profile)
220
+
221
+ # Request the download URL for the file.
222
+ response = client.request(
223
+ method="GET",
224
+ endpoint="v0/internal/tools",
225
+ headers=client.headers | {"request-source": "cli"}, # Pass `client.headers` to preserve auth.
226
+ query_params={"file": f"{directory}/{file}"},
227
+ )
228
+
229
+ # Use the URL obtained to download the file.
230
+ body = response.json()
231
+ download_response = client.request(
232
+ method="GET",
233
+ endpoint=body.get("url"),
234
+ headers={"Content-Type": "application/json"},
235
+ )
236
+
237
+ return download_response
238
+
239
+
240
+ def find_app(manifest: dict[str, Any], app: str) -> dict[str, Any] | None:
170
241
  """
171
242
  Finds and returns a community app from the manifest by its name.
172
243
 
173
244
  Parameters
174
245
  ----------
175
- client : Client
176
- The Nextmv Cloud client to use for the request.
246
+ manifest : dict[str, Any]
247
+ The community apps manifest.
177
248
  app : str
178
249
  The name of the community app to find.
179
250
 
180
251
  Returns
181
252
  -------
182
- CommunityApp
183
- The community app if found.
184
-
185
- Raises
186
- ------
187
- ValueError
188
- If the community app is not found.
253
+ dict[str, Any] | None
254
+ The community app dictionary if found, otherwise None.
189
255
  """
190
256
 
191
- comm_apps = list_community_apps(client)
192
- for comm_app in comm_apps:
193
- if comm_app.name == app:
194
- return comm_app
257
+ for manifest_app in manifest.get("apps", []):
258
+ if manifest_app.get("name", "") == app:
259
+ return manifest_app
195
260
 
196
261
  # We don't use error() here to allow printing something before exiting.
197
262
  rich.print(f"[red]Error:[/red] Community app [magenta]{app}[/magenta] was not found. Here are the available apps:")
198
- _apps_table(client)
263
+ apps_table(manifest)
199
264
 
200
265
  raise typer.Exit(code=1)
@@ -7,8 +7,7 @@ from typing import Any
7
7
 
8
8
  import yaml
9
9
 
10
- from nextmv.cli.confirm import get_confirmation
11
- from nextmv.cli.message import error, success, warning
10
+ from nextmv.cli.message import error
12
11
  from nextmv.cloud.account import Account
13
12
  from nextmv.cloud.application import Application
14
13
  from nextmv.cloud.client import Client
@@ -60,18 +59,6 @@ def save_config(config: dict[str, Any]) -> None:
60
59
  yaml.safe_dump(config, file)
61
60
 
62
61
 
63
- def non_profile_keys() -> set[str]:
64
- """
65
- Returns the set of keys that are not profile names in the configuration.
66
-
67
- Returns
68
- -------
69
- set[str]
70
- The set of non-profile keys.
71
- """
72
- return {API_KEY_KEY, ENDPOINT_KEY}
73
-
74
-
75
62
  def build_client(profile: str | None = None) -> Client:
76
63
  """
77
64
  Builds a `cloud.Client` using the API key and endpoint for the given
@@ -104,35 +91,23 @@ def build_client(profile: str | None = None) -> Client:
104
91
 
105
92
  if profile is not None:
106
93
  if profile not in config:
107
- error(
108
- f"Profile [magenta]{profile}[/magenta] does not exist. "
109
- "Create it using [code]nextmv configuration create[/code] with the --profile option."
110
- )
94
+ error(f"Profile [magenta]{profile}[/magenta] does not exist.")
111
95
 
112
96
  api_key = config[profile].get(API_KEY_KEY)
113
97
  if api_key is None or api_key == "":
114
- error(
115
- f"API key for profile [magenta]{profile}[/magenta] is not set or is empty. "
116
- "Set it using [code]nextmv configuration create[/code] with the --profile and --api-key options."
117
- )
98
+ error(f"API key for profile [magenta]{profile}[/magenta] is not set or is empty.")
118
99
 
119
100
  endpoint = config[profile].get(ENDPOINT_KEY)
120
101
  if endpoint is None or endpoint == "":
121
- error(
122
- f"Endpoint for profile [magenta]{profile}[/magenta] is not set or is empty. "
123
- "Please run [code]nextmv configuration create[/code]."
124
- )
102
+ error(f"Endpoint for profile [magenta]{profile}[/magenta] is not set or is empty.")
125
103
  else:
126
104
  api_key = config.get(API_KEY_KEY)
127
105
  if api_key is None or api_key == "":
128
- error(
129
- "Default API key is not set or is empty. "
130
- "Please run [code]nextmv configuration create[/code] with the --api-key option."
131
- )
106
+ error("Default API key is not set or is empty.")
132
107
 
133
108
  endpoint = config.get(ENDPOINT_KEY)
134
109
  if endpoint is None or endpoint == "":
135
- error("Default endpoint is not set or is empty. Please run [code]nextmv configuration create[/code].")
110
+ error("Default endpoint is not set or is empty.")
136
111
 
137
112
  return Client(api_key=api_key, url=f"https://{endpoint}")
138
113
 
@@ -162,21 +137,13 @@ def build_app(app_id: str, profile: str | None = None) -> Application:
162
137
  """
163
138
  client = build_client(profile)
164
139
  exists = Application.exists(client=client, id=app_id)
165
- if exists:
166
- return Application(client=client, id=app_id)
167
-
168
- warning(f"Application with ID [magenta]{app_id}[/magenta] does not exist.")
169
- should_create = get_confirmation(f"Do you want to create a new application with ID [magenta]{app_id}[/magenta]?")
170
- if not should_create:
140
+ if not exists:
171
141
  error(
172
- f"Application with ID [magenta]{app_id}[/magenta] was not created and does not exist. "
173
- "Use [code]nextmv cloud app create[/code] to create a new app."
142
+ f"Application with ID [magenta]{app_id}[/magenta] does not exist. "
143
+ "Use [code]nextmv cloud app create[/code] to create a new application."
174
144
  )
175
145
 
176
- app = Application.new(client=client, id=app_id, name=app_id)
177
- success(f"Application with ID and name [magenta]{app_id}[/magenta] created successfully.")
178
-
179
- return app
146
+ return Application(client=client, id=app_id)
180
147
 
181
148
 
182
149
  def build_account(account_id: str | None = None, profile: str | None = None) -> Account: