nextmv 0.38.0__tar.gz → 0.39.0__tar.gz

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 (110) hide show
  1. {nextmv-0.38.0 → nextmv-0.39.0}/PKG-INFO +2 -1
  2. nextmv-0.39.0/nextmv/__about__.py +1 -0
  3. nextmv-0.39.0/nextmv/cli/community/clone.py +270 -0
  4. nextmv-0.39.0/nextmv/cli/community/community.py +24 -0
  5. nextmv-0.39.0/nextmv/cli/community/list.py +265 -0
  6. nextmv-0.39.0/nextmv/cli/configuration/config.py +131 -0
  7. nextmv-0.39.0/nextmv/cli/configuration/configuration.py +23 -0
  8. nextmv-0.39.0/nextmv/cli/configuration/create.py +95 -0
  9. nextmv-0.39.0/nextmv/cli/configuration/delete.py +55 -0
  10. nextmv-0.39.0/nextmv/cli/configuration/list.py +77 -0
  11. nextmv-0.39.0/nextmv/cli/error.py +22 -0
  12. nextmv-0.39.0/nextmv/cli/main.py +146 -0
  13. nextmv-0.39.0/nextmv/cli/options.py +24 -0
  14. nextmv-0.39.0/nextmv/cli/version.py +19 -0
  15. {nextmv-0.38.0 → nextmv-0.39.0}/pyproject.toml +4 -0
  16. nextmv-0.39.0/tests/cli/test_community.py +665 -0
  17. nextmv-0.39.0/tests/cli/test_configuration.py +349 -0
  18. nextmv-0.39.0/tests/cli/test_main.py +99 -0
  19. nextmv-0.39.0/tests/cli/test_version.py +16 -0
  20. nextmv-0.39.0/tests/cloud/__init__.py +0 -0
  21. nextmv-0.39.0/tests/local/__init__.py +0 -0
  22. nextmv-0.39.0/tests/scripts/__init__.py +0 -0
  23. nextmv-0.39.0/tests/test_entrypoint/__init__.py +0 -0
  24. nextmv-0.38.0/nextmv/__about__.py +0 -1
  25. {nextmv-0.38.0 → nextmv-0.39.0}/.gitignore +0 -0
  26. {nextmv-0.38.0 → nextmv-0.39.0}/LICENSE +0 -0
  27. {nextmv-0.38.0 → nextmv-0.39.0}/README.md +0 -0
  28. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/__entrypoint__.py +0 -0
  29. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/__init__.py +0 -0
  30. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/_serialization.py +0 -0
  31. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/base_model.py +0 -0
  32. {nextmv-0.38.0/nextmv/default_app/src → nextmv-0.39.0/nextmv/cli}/__init__.py +0 -0
  33. {nextmv-0.38.0/tests → nextmv-0.39.0/nextmv/cli/community}/__init__.py +0 -0
  34. {nextmv-0.38.0/tests/cloud → nextmv-0.39.0/nextmv/cli/configuration}/__init__.py +0 -0
  35. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/__init__.py +0 -0
  36. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/acceptance_test.py +0 -0
  37. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/account.py +0 -0
  38. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/application.py +0 -0
  39. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/assets.py +0 -0
  40. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/batch_experiment.py +0 -0
  41. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/client.py +0 -0
  42. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/ensemble.py +0 -0
  43. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/input_set.py +0 -0
  44. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/instance.py +0 -0
  45. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/integration.py +0 -0
  46. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/package.py +0 -0
  47. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/scenario.py +0 -0
  48. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/secrets.py +0 -0
  49. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/url.py +0 -0
  50. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/cloud/version.py +0 -0
  51. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/default_app/.gitignore +0 -0
  52. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/default_app/README.md +0 -0
  53. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/default_app/app.yaml +0 -0
  54. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/default_app/input.json +0 -0
  55. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/default_app/main.py +0 -0
  56. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/default_app/requirements.txt +0 -0
  57. {nextmv-0.38.0/tests/local → nextmv-0.39.0/nextmv/default_app/src}/__init__.py +0 -0
  58. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/default_app/src/visuals.py +0 -0
  59. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/deprecated.py +0 -0
  60. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/input.py +0 -0
  61. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/local/__init__.py +0 -0
  62. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/local/application.py +0 -0
  63. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/local/executor.py +0 -0
  64. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/local/geojson_handler.py +0 -0
  65. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/local/local.py +0 -0
  66. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/local/plotly_handler.py +0 -0
  67. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/local/runner.py +0 -0
  68. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/logger.py +0 -0
  69. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/manifest.py +0 -0
  70. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/model.py +0 -0
  71. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/options.py +0 -0
  72. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/output.py +0 -0
  73. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/polling.py +0 -0
  74. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/run.py +0 -0
  75. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/safe.py +0 -0
  76. {nextmv-0.38.0 → nextmv-0.39.0}/nextmv/status.py +0 -0
  77. {nextmv-0.38.0/tests/scripts → nextmv-0.39.0/tests}/__init__.py +0 -0
  78. {nextmv-0.38.0/tests/test_entrypoint → nextmv-0.39.0/tests/cli}/__init__.py +0 -0
  79. {nextmv-0.38.0 → nextmv-0.39.0}/tests/cloud/app.yaml +0 -0
  80. {nextmv-0.38.0 → nextmv-0.39.0}/tests/cloud/test_client.py +0 -0
  81. {nextmv-0.38.0 → nextmv-0.39.0}/tests/cloud/test_instance.py +0 -0
  82. {nextmv-0.38.0 → nextmv-0.39.0}/tests/cloud/test_package.py +0 -0
  83. {nextmv-0.38.0 → nextmv-0.39.0}/tests/cloud/test_scenario.py +0 -0
  84. {nextmv-0.38.0 → nextmv-0.39.0}/tests/local/test_application.py +0 -0
  85. {nextmv-0.38.0 → nextmv-0.39.0}/tests/local/test_executor.py +0 -0
  86. {nextmv-0.38.0 → nextmv-0.39.0}/tests/local/test_runner.py +0 -0
  87. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options1.py +0 -0
  88. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options2.py +0 -0
  89. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options3.py +0 -0
  90. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options4.py +0 -0
  91. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options5.py +0 -0
  92. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options6.py +0 -0
  93. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options7.py +0 -0
  94. {nextmv-0.38.0 → nextmv-0.39.0}/tests/scripts/options_deprecated.py +0 -0
  95. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_base_model.py +0 -0
  96. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_entrypoint/test_entrypoint.py +0 -0
  97. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_input.py +0 -0
  98. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_inputs/test_data.csv +0 -0
  99. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_inputs/test_data.json +0 -0
  100. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_inputs/test_data.txt +0 -0
  101. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_logger.py +0 -0
  102. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_manifest.py +0 -0
  103. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_model.py +0 -0
  104. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_options.py +0 -0
  105. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_output.py +0 -0
  106. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_polling.py +0 -0
  107. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_run.py +0 -0
  108. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_safe.py +0 -0
  109. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_serialization.py +0 -0
  110. {nextmv-0.38.0 → nextmv-0.39.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextmv
3
- Version: 0.38.0
3
+ Version: 0.39.0
4
4
  Summary: The all-purpose Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://nextmv-py.docs.nextmv.io/en/latest/nextmv/
@@ -221,6 +221,7 @@ Requires-Python: >=3.10
221
221
  Requires-Dist: pydantic>=2.5.2
222
222
  Requires-Dist: pyyaml>=6.0.1
223
223
  Requires-Dist: requests>=2.31.0
224
+ Requires-Dist: typer>=0.20.1
224
225
  Requires-Dist: urllib3>=2.1.0
225
226
  Provides-Extra: all
226
227
  Requires-Dist: folium>=0.20.0; extra == 'all'
@@ -0,0 +1 @@
1
+ __version__ = "v0.39.0"
@@ -0,0 +1,270 @@
1
+ """
2
+ This module defines the community clone command for the Nextmv CLI.
3
+ """
4
+
5
+ import os
6
+ import shutil
7
+ import tarfile
8
+ import tempfile
9
+ from collections.abc import Callable
10
+ from typing import Annotated
11
+
12
+ import rich
13
+ import typer
14
+
15
+ from nextmv.cli.community.list import download_file, download_manifest, find_app, versions_table
16
+ from nextmv.cli.error import error
17
+ from nextmv.cli.options import ProfileOption
18
+
19
+ # Set up subcommand application.
20
+ app = typer.Typer()
21
+
22
+ # Helpful constants.
23
+ LATEST_VERSION = "latest"
24
+
25
+
26
+ @app.command()
27
+ def clone(
28
+ app: Annotated[
29
+ str,
30
+ typer.Option("--app", "-a", help="The name of the community app to clone.", metavar="COMMUNITY_APP"),
31
+ ],
32
+ directory: Annotated[
33
+ str | None,
34
+ typer.Option(
35
+ "--directory",
36
+ "-d",
37
+ help="The directory in which to clone the app. Default is the name of the app at current directory.",
38
+ metavar="DIRECTORY",
39
+ ),
40
+ ] = None,
41
+ version: Annotated[
42
+ str | None,
43
+ typer.Option(
44
+ "--version",
45
+ "-v",
46
+ help="The version of the community app to clone.",
47
+ metavar="VERSION",
48
+ ),
49
+ ] = LATEST_VERSION,
50
+ profile: ProfileOption = None,
51
+ ) -> None:
52
+ """
53
+ Clone a community app locally.
54
+
55
+ By default, the [magenta]latest[/magenta] version will be used. You can
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.
59
+
60
+ [bold][underline]Examples[/underline][/bold]
61
+
62
+ - Clone the [magenta]go-nextroute[/magenta] community app (under the
63
+ [magenta]"go-nextroute"[/magenta] directory), using the [magenta]latest[/magenta] version.
64
+ $ [green]nextmv community clone --app go-nextroute[/green]
65
+
66
+ - Clone the [magenta]go-nextroute[/magenta] community app under the
67
+ [magenta]"~/sample/my_app"[/magenta] directory, using the [magenta]latest[/magenta] version.
68
+ $ [green]nextmv community clone --app go-nextroute --directory ~/sample/my_app[/green]
69
+
70
+ - Clone the [magenta]go-nextroute[/magenta] community app (under the
71
+ [magenta]"go-nextroute"[/magenta] directory), using version [magenta]v1.2.0[/magenta].
72
+ $ [green]nextmv community clone --app go-nextroute --version v1.2.0[/green]
73
+
74
+ - Clone the [magenta]go-nextroute[/magenta] community app (under the
75
+ [magenta]"go-nextroute"[/magenta] directory), using the [magenta]latest[/magenta] version
76
+ and a profile named [magenta]hare[/magenta].
77
+ $ [green]nextmv community clone --app go-nextroute --profile hare[/green]
78
+ """
79
+
80
+ manifest = download_manifest(profile=profile)
81
+ app_obj = find_app(manifest, app)
82
+
83
+ if version is not None and version == "":
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
+ rich.print(
138
+ f":white_check_mark: Successfully cloned the [magenta]{app}[/magenta] community app, "
139
+ f"using version [magenta]{original_version}[/magenta] in path: [magenta]{full_destination}[/magenta]."
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
@@ -0,0 +1,24 @@
1
+ """
2
+ This module defines the community command tree for the Nextmv CLI.
3
+ """
4
+
5
+ import typer
6
+
7
+ from nextmv.cli.community.clone import app as clone_app
8
+ from nextmv.cli.community.list import app as list_app
9
+
10
+ # Set up subcommand application.
11
+ app = typer.Typer()
12
+ app.add_typer(list_app)
13
+ app.add_typer(clone_app)
14
+
15
+
16
+ @app.callback()
17
+ def callback() -> None:
18
+ """
19
+ Interact with community apps, which are pre-built decision models.
20
+
21
+ Community apps are maintained in the following GitHub repository:
22
+ [link=https://github.com/nextmv-io/community-apps][bold]nextmv-io/community-apps[/bold][/link].
23
+ """
24
+ pass
@@ -0,0 +1,265 @@
1
+ """
2
+ This module defines the community list command for the Nextmv CLI.
3
+ """
4
+
5
+ from typing import Annotated, Any
6
+
7
+ import requests
8
+ import rich
9
+ import typer
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from nextmv.cli.configuration.config import build_client
15
+ from nextmv.cli.error import error
16
+ from nextmv.cli.options import ProfileOption
17
+
18
+ # Set up subcommand application.
19
+ app = typer.Typer()
20
+ console = Console()
21
+
22
+
23
+ @app.command()
24
+ def list(
25
+ app: Annotated[
26
+ str | None,
27
+ typer.Option(
28
+ "--app",
29
+ "-a",
30
+ help="The community app to list versions for.",
31
+ metavar="COMMUNITY_APP",
32
+ ),
33
+ ] = None,
34
+ flat: Annotated[bool, typer.Option("--flat", "-f", help="Flatten the list output.")] = False,
35
+ profile: ProfileOption = None,
36
+ ) -> None:
37
+ """
38
+ List the available community apps
39
+
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
+
44
+ [bold][underline]Examples[/underline][/bold]
45
+
46
+ - List the available community apps.
47
+ $ [green]nextmv community list[/green]
48
+
49
+ - List the available versions of the [magenta]go-nextroute[/magenta] community app.
50
+ $ [green]nextmv community list --app go-nextroute[/green]
51
+
52
+ - List the names of the available community apps as a flat list.
53
+ $ [green]nextmv community list --flat[/green]
54
+
55
+ - List the available versions of the [magenta]go-nextroute[/magenta] community app as a flat list.
56
+ $ [green]nextmv community list --app go-nextroute --flat[/green]
57
+
58
+ - List the available community apps using a profile named [magenta]hare[/magenta].
59
+ $ [green]nextmv community list --profile hare[/green]
60
+ """
61
+
62
+ if app is not None and app == "":
63
+ error("The [code]--app[/code] flag cannot be an empty string.")
64
+
65
+ manifest = download_manifest(profile=profile)
66
+ if flat and app is None:
67
+ apps_list(manifest)
68
+ raise typer.Exit()
69
+ elif not flat and app is None:
70
+ apps_table(manifest)
71
+ raise typer.Exit()
72
+ elif flat and app is not None and app != "":
73
+ versions_list(manifest, app)
74
+ raise typer.Exit()
75
+ elif not flat and app is not None and app != "":
76
+ versions_table(manifest, app)
77
+ raise typer.Exit()
78
+
79
+
80
+ def download_manifest(profile: str | None = None) -> dict:
81
+ """
82
+ Downloads and returns the community apps manifest.
83
+
84
+ Parameters
85
+ ----------
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.
113
+ """
114
+
115
+ table = Table("Name", "Type", "Latest", "Description", border_style="cyan", header_style="cyan")
116
+ for app in manifest.get("apps", []):
117
+ table.add_row(
118
+ app.get("name", ""),
119
+ app.get("type", ""),
120
+ app.get("latest_app_version", ""),
121
+ app.get("description", ""),
122
+ )
123
+
124
+ console.print(table)
125
+
126
+
127
+ def apps_list(manifest: dict[str, Any]) -> None:
128
+ """
129
+ This function prints a flat list of community app names from the manifest.
130
+
131
+ Parameters
132
+ ----------
133
+ manifest : dict[str, Any]
134
+ The community apps manifest.
135
+ """
136
+
137
+ names = [app.get("name", "") for app in manifest.get("apps", [])]
138
+ print("\n".join(names))
139
+
140
+
141
+ def versions_table(manifest: dict[str, Any], app: str) -> None:
142
+ """
143
+ This function prints a table of versions for a specific community app.
144
+
145
+ Parameters
146
+ ----------
147
+ manifest : dict[str, Any]
148
+ The community apps manifest.
149
+ app : str
150
+ The name of the community app.
151
+ """
152
+
153
+ app_obj = find_app(manifest, app)
154
+ latest_version = app_obj.get("latest_app_version", "")
155
+
156
+ # Add the latest version with indicator
157
+ table = Table("Version", "Latest?", border_style="cyan", header_style="cyan")
158
+ table.add_row(f"[cyan underline]{latest_version}[/cyan underline]", "[cyan]<--[/cyan]")
159
+ table.add_row("", "") # Empty row to separate latest from others.
160
+
161
+ # Add all other versions (excluding the latest)
162
+ versions = app_obj.get("app_versions", [])
163
+ for version in versions:
164
+ if version != latest_version:
165
+ table.add_row(version, "")
166
+
167
+ console.print(table)
168
+
169
+
170
+ def versions_list(manifest: dict[str, Any], app: str) -> None:
171
+ """
172
+ This function prints a flat list of versions for a specific community app.
173
+
174
+ Parameters
175
+ ----------
176
+ manifest : dict[str, Any]
177
+ The community apps manifest.
178
+ app : str
179
+ The name of the community app.
180
+ """
181
+
182
+ app_obj = find_app(manifest, app)
183
+ versions = app_obj.get("app_versions", [])
184
+
185
+ versions_output = ""
186
+ for version in versions:
187
+ versions_output += f"{version}\n"
188
+
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.
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.
208
+
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:
241
+ """
242
+ Finds and returns a community app from the manifest by its name.
243
+
244
+ Parameters
245
+ ----------
246
+ manifest : dict[str, Any]
247
+ The community apps manifest.
248
+ app : str
249
+ The name of the community app to find.
250
+
251
+ Returns
252
+ -------
253
+ dict[str, Any] | None
254
+ The community app dictionary if found, otherwise None.
255
+ """
256
+
257
+ for manifest_app in manifest.get("apps", []):
258
+ if manifest_app.get("name", "") == app:
259
+ return manifest_app
260
+
261
+ # We don't use error() here to allow printing something before exiting.
262
+ rich.print(f"[red]Error:[/red] Community app [magenta]{app}[/magenta] was not found. Here are the available apps:")
263
+ apps_table(manifest)
264
+
265
+ raise typer.Exit(code=1)
@@ -0,0 +1,131 @@
1
+ """
2
+ This module contains configuration utilities for the Nextmv CLI.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from nextmv.cli.error import error
11
+ from nextmv.cloud.client import Client
12
+
13
+ # Some useful constants.
14
+ CONFIG_DIR = Path.home() / ".nextmv"
15
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
16
+ API_KEY_KEY = "apikey"
17
+ ENDPOINT_KEY = "endpoint"
18
+ DEFAULT_ENDPOINT = "api.cloud.nextmv.io"
19
+ GO_CLI_PATH = CONFIG_DIR / "nextmv"
20
+
21
+
22
+ def load_config() -> dict[str, Any]:
23
+ """
24
+ Load the current configuration from the config file. Returns an empty
25
+ dictionary if no configuration file exists.
26
+
27
+ Returns
28
+ -------
29
+ dict[str, Any]
30
+ The current configuration as a dictionary.
31
+ """
32
+
33
+ if not CONFIG_FILE.exists():
34
+ return {}
35
+
36
+ with CONFIG_FILE.open() as file:
37
+ config = yaml.safe_load(file)
38
+
39
+ if config is None:
40
+ return {}
41
+ return config
42
+
43
+
44
+ def save_config(config: dict[str, Any]) -> None:
45
+ """
46
+ Save the given configuration to the config file.
47
+
48
+ Parameters
49
+ ----------
50
+ config : dict[str, Any]
51
+ The configuration to save.
52
+ """
53
+
54
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
55
+
56
+ with CONFIG_FILE.open("w") as file:
57
+ yaml.safe_dump(config, file)
58
+
59
+
60
+ def build_client(profile: str | None = None) -> Client:
61
+ """
62
+ Builds a `cloud.Client` using the API key and endpoint for the given
63
+ profile. If no profile is given, the default profile is used. If either the
64
+ API key or endpoint is missing, an exception is raised. If the config is
65
+ not available, an exception is raised.
66
+
67
+ Parameters
68
+ ----------
69
+ profile : str | None
70
+ The profile name to use. If None, the default profile is used.
71
+
72
+ Returns
73
+ -------
74
+ Client
75
+ A client configured with the API key and endpoint for the selected
76
+ profile or the default configuration.
77
+
78
+ Raises
79
+ ------
80
+ typer.Exit
81
+ If no configuration is found, if the requested profile does not exist,
82
+ or if the API key or endpoint (for either the selected profile or the
83
+ default configuration) is not set or is empty.
84
+ """
85
+
86
+ config = load_config()
87
+ if config == {}:
88
+ error("No configuration found. Please run [code]nextmv configuration create[/code].")
89
+
90
+ if profile is not None:
91
+ if profile not in config:
92
+ error(f"Profile [bold magenta]{profile}[/bold magenta] does not exist.")
93
+
94
+ api_key = config[profile].get(API_KEY_KEY)
95
+ if api_key is None or api_key == "":
96
+ error(f"API key for profile [bold magenta]{profile}[/bold magenta] is not set or is empty.")
97
+
98
+ endpoint = config[profile].get(ENDPOINT_KEY)
99
+ if endpoint is None or endpoint == "":
100
+ error(f"Endpoint for profile [bold magenta]{profile}[/bold magenta] is not set or is empty.")
101
+ else:
102
+ api_key = config.get(API_KEY_KEY)
103
+ if api_key is None or api_key == "":
104
+ error("Default API key is not set or is empty.")
105
+
106
+ endpoint = config.get(ENDPOINT_KEY)
107
+ if endpoint is None or endpoint == "":
108
+ error("Default endpoint is not set or is empty.")
109
+
110
+ return Client(api_key=api_key, url=f"https://{endpoint}")
111
+
112
+
113
+ def obscure_api_key(api_key: str) -> str:
114
+ """
115
+ Obscure an API key for display purposes.
116
+
117
+ Parameters
118
+ ----------
119
+ api_key : str
120
+ The API key to obscure.
121
+
122
+ Returns
123
+ -------
124
+ str
125
+ The obscured API key.
126
+ """
127
+
128
+ if len(api_key) <= 4:
129
+ return "*" * len(api_key)
130
+
131
+ return api_key[:2] + "*" * 4 + api_key[-2:]