nextmv 0.39.0.dev0__py3-none-any.whl → 0.40.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.
- nextmv/__about__.py +1 -1
- nextmv/cli/community/__init__.py +0 -0
- nextmv/cli/community/clone.py +270 -0
- nextmv/cli/community/community.py +24 -0
- nextmv/cli/community/list.py +265 -0
- nextmv/cli/configuration/__init__.py +0 -0
- nextmv/cli/configuration/config.py +131 -0
- nextmv/cli/configuration/configuration.py +23 -0
- nextmv/cli/configuration/create.py +95 -0
- nextmv/cli/configuration/delete.py +55 -0
- nextmv/cli/configuration/list.py +77 -0
- nextmv/cli/error.py +22 -0
- nextmv/cli/main.py +136 -1
- nextmv/cli/options.py +24 -0
- nextmv/cli/version.py +7 -2
- {nextmv-0.39.0.dev0.dist-info → nextmv-0.40.0.dist-info}/METADATA +1 -1
- {nextmv-0.39.0.dev0.dist-info → nextmv-0.40.0.dist-info}/RECORD +19 -8
- nextmv-0.39.0.dev0.dist-info/entry_points.txt +0 -2
- {nextmv-0.39.0.dev0.dist-info → nextmv-0.40.0.dist-info}/WHEEL +0 -0
- {nextmv-0.39.0.dev0.dist-info → nextmv-0.40.0.dist-info}/licenses/LICENSE +0 -0
nextmv/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "v0.
|
|
1
|
+
__version__ = "v0.40.0"
|
|
File without changes
|
|
@@ -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)
|
|
File without changes
|
|
@@ -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:]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the configuration command tree for the Nextmv CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from nextmv.cli.configuration.create import app as create_app
|
|
8
|
+
from nextmv.cli.configuration.delete import app as delete_app
|
|
9
|
+
from nextmv.cli.configuration.list import app as list_app
|
|
10
|
+
|
|
11
|
+
# Set up subcommand application.
|
|
12
|
+
app = typer.Typer()
|
|
13
|
+
app.add_typer(create_app)
|
|
14
|
+
app.add_typer(delete_app)
|
|
15
|
+
app.add_typer(list_app)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback()
|
|
19
|
+
def callback() -> None:
|
|
20
|
+
"""
|
|
21
|
+
Configure the CLI and manage profiles.
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the configuration create command for the Nextmv CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import rich
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from nextmv.cli.configuration.config import (
|
|
11
|
+
API_KEY_KEY,
|
|
12
|
+
DEFAULT_ENDPOINT,
|
|
13
|
+
ENDPOINT_KEY,
|
|
14
|
+
load_config,
|
|
15
|
+
obscure_api_key,
|
|
16
|
+
save_config,
|
|
17
|
+
)
|
|
18
|
+
from nextmv.cli.error import error
|
|
19
|
+
|
|
20
|
+
# Set up subcommand application.
|
|
21
|
+
app = typer.Typer()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def create(
|
|
26
|
+
api_key: Annotated[
|
|
27
|
+
str,
|
|
28
|
+
typer.Option(
|
|
29
|
+
"--api-key",
|
|
30
|
+
"-a",
|
|
31
|
+
help="A valid Nextmv Cloud API key. "
|
|
32
|
+
+ "Get one from [link=https://cloud.nextmv.io][bold]https://cloud.nextmv.io[/bold][/link].",
|
|
33
|
+
envvar="NEXTMV_API_KEY",
|
|
34
|
+
metavar="NEXTMV_API_KEY",
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
profile: Annotated[ # Similar to nextmv.cli.options.ProfileOption but with different help text.
|
|
38
|
+
str | None,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--profile",
|
|
41
|
+
"-p",
|
|
42
|
+
help="Profile name to save the configuration under.",
|
|
43
|
+
envvar="NEXTMV_PROFILE",
|
|
44
|
+
metavar="PROFILE_NAME",
|
|
45
|
+
),
|
|
46
|
+
] = None,
|
|
47
|
+
endpoint: Annotated[ # Hidden because it is meant for internal use.
|
|
48
|
+
str | None,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--endpoint",
|
|
51
|
+
"-e",
|
|
52
|
+
hidden=True,
|
|
53
|
+
),
|
|
54
|
+
] = DEFAULT_ENDPOINT,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Create a new configuration or update an existing one.
|
|
58
|
+
|
|
59
|
+
[bold][underline]Examples[/underline][/bold]
|
|
60
|
+
|
|
61
|
+
- Default configuration.
|
|
62
|
+
$ [green]nextmv configuration create --api-key NEXTMV_API_KEY[/green]
|
|
63
|
+
|
|
64
|
+
- Configure a profile named [italic]hare[/italic].
|
|
65
|
+
$ [green]nextmv configuration create --api-key NEXTMV_API_KEY --profile hare[/green]
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
if profile is not None and profile.strip().lower() == "default":
|
|
69
|
+
error("[code]default[/code] is a reserved profile name.")
|
|
70
|
+
|
|
71
|
+
endpoint = str(endpoint)
|
|
72
|
+
if endpoint.startswith("https://"):
|
|
73
|
+
endpoint = endpoint[len("https://") :]
|
|
74
|
+
elif endpoint.startswith("http://"):
|
|
75
|
+
endpoint = endpoint[len("http://") :]
|
|
76
|
+
|
|
77
|
+
config = load_config()
|
|
78
|
+
|
|
79
|
+
if profile is None:
|
|
80
|
+
config[API_KEY_KEY] = api_key
|
|
81
|
+
config[ENDPOINT_KEY] = endpoint
|
|
82
|
+
else:
|
|
83
|
+
if profile not in config:
|
|
84
|
+
config[profile] = {}
|
|
85
|
+
|
|
86
|
+
config[profile][API_KEY_KEY] = api_key
|
|
87
|
+
config[profile][ENDPOINT_KEY] = endpoint
|
|
88
|
+
|
|
89
|
+
save_config(config)
|
|
90
|
+
|
|
91
|
+
rich.print(":white_check_mark: Configuration saved successfully.")
|
|
92
|
+
rich.print(f"\t[bold]Profile[/bold]: {profile or 'Default'}")
|
|
93
|
+
rich.print(f"\t[bold]API Key[/bold]: {obscure_api_key(api_key)}")
|
|
94
|
+
if endpoint != DEFAULT_ENDPOINT:
|
|
95
|
+
rich.print(f"\t[bold]Endpoint[/bold]: {endpoint}")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the configuration delete command for the Nextmv CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import rich
|
|
8
|
+
import typer
|
|
9
|
+
from rich.prompt import Confirm
|
|
10
|
+
|
|
11
|
+
from nextmv.cli.configuration.config import load_config, save_config
|
|
12
|
+
from nextmv.cli.error import error
|
|
13
|
+
|
|
14
|
+
# Set up subcommand application.
|
|
15
|
+
app = typer.Typer()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command()
|
|
19
|
+
def delete(
|
|
20
|
+
profile: Annotated[ # Similar to nextmv.cli.options.ProfileOption but with different help text.
|
|
21
|
+
str,
|
|
22
|
+
typer.Option(
|
|
23
|
+
"--profile",
|
|
24
|
+
"-p",
|
|
25
|
+
help="Profile name to delete.",
|
|
26
|
+
envvar="NEXTMV_PROFILE",
|
|
27
|
+
metavar="PROFILE_NAME",
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Delete a profile from the configuration.
|
|
33
|
+
|
|
34
|
+
[bold][underline]Examples[/underline][/bold]
|
|
35
|
+
|
|
36
|
+
- Delete a profile named [magenta]hare[/magenta].
|
|
37
|
+
$ [green]nextmv configuration delete --profile hare[/green]
|
|
38
|
+
"""
|
|
39
|
+
config = load_config()
|
|
40
|
+
if profile not in config:
|
|
41
|
+
error(f"Profile [bold magenta]{profile}[/bold magenta] does not exist.")
|
|
42
|
+
|
|
43
|
+
confirm = Confirm.ask(
|
|
44
|
+
f"Are you sure you want to delete profile [bold magenta]{profile}[/bold magenta]? This action cannot be undone",
|
|
45
|
+
default=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not confirm:
|
|
49
|
+
rich.print(f":bulb: Profile [bold magenta]{profile}[/bold magenta] will not be deleted.")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
del config[profile]
|
|
53
|
+
save_config(config)
|
|
54
|
+
|
|
55
|
+
rich.print(f":white_check_mark: Profile [bold magenta]{profile}[/bold magenta] deleted successfully.")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the configuration list command for the Nextmv CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from nextmv.cli.configuration.config import API_KEY_KEY, ENDPOINT_KEY, load_config, obscure_api_key
|
|
10
|
+
from nextmv.cli.error import error
|
|
11
|
+
|
|
12
|
+
# Set up subcommand application.
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def list() -> None:
|
|
19
|
+
"""
|
|
20
|
+
List the current configuration and all profiles.
|
|
21
|
+
|
|
22
|
+
[bold][underline]Examples[/underline][/bold]
|
|
23
|
+
|
|
24
|
+
- Show current configuration and all profiles.
|
|
25
|
+
$ [green]nextmv configuration list[/green]
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
config = load_config()
|
|
29
|
+
if config == {}:
|
|
30
|
+
error("No configuration found. Please run [code]nextmv configuration[/code].")
|
|
31
|
+
|
|
32
|
+
default = {
|
|
33
|
+
"api_key": config.get(API_KEY_KEY),
|
|
34
|
+
"endpoint": config.get(ENDPOINT_KEY),
|
|
35
|
+
"name": "Default",
|
|
36
|
+
}
|
|
37
|
+
profiles = [default]
|
|
38
|
+
|
|
39
|
+
for k, v in config.items():
|
|
40
|
+
# Skip default configuration.
|
|
41
|
+
if k in {API_KEY_KEY, ENDPOINT_KEY}:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
profile = {
|
|
45
|
+
"name": k,
|
|
46
|
+
"api_key": v.get(API_KEY_KEY),
|
|
47
|
+
"endpoint": v.get(ENDPOINT_KEY),
|
|
48
|
+
}
|
|
49
|
+
profiles.append(profile)
|
|
50
|
+
|
|
51
|
+
table = Table("Profile name", "API Key", "Endpoint")
|
|
52
|
+
not_set = "[italic]Not set[/italic]"
|
|
53
|
+
for profile in profiles:
|
|
54
|
+
if profile["name"] != "Default":
|
|
55
|
+
table.add_row(
|
|
56
|
+
profile["name"],
|
|
57
|
+
obscure_api_key(profile["api_key"]) if profile.get("api_key") is not None else not_set,
|
|
58
|
+
profile["endpoint"] if profile.get("endpoint") is not None else not_set,
|
|
59
|
+
)
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
api_key = not_set
|
|
63
|
+
if profile.get("api_key") is not None:
|
|
64
|
+
api_key = obscure_api_key(profile["api_key"])
|
|
65
|
+
|
|
66
|
+
endpoint = not_set
|
|
67
|
+
if profile.get("endpoint") is not None:
|
|
68
|
+
endpoint = profile["endpoint"]
|
|
69
|
+
|
|
70
|
+
table.add_row(
|
|
71
|
+
f"[bold yellow]{profile['name']}[/bold yellow]",
|
|
72
|
+
f"[bold yellow]{api_key}[/bold yellow]",
|
|
73
|
+
f"[bold yellow]{endpoint}[/bold yellow]",
|
|
74
|
+
)
|
|
75
|
+
table.add_section()
|
|
76
|
+
|
|
77
|
+
console.print(table)
|
nextmv/cli/error.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import rich
|
|
2
|
+
import typer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def error(msg: str) -> None:
|
|
6
|
+
"""
|
|
7
|
+
Pretty-print an error message and exit with code 1. Your message should end
|
|
8
|
+
with a period.
|
|
9
|
+
|
|
10
|
+
Parameters
|
|
11
|
+
----------
|
|
12
|
+
msg : str
|
|
13
|
+
The error message to display.
|
|
14
|
+
|
|
15
|
+
Raises
|
|
16
|
+
------
|
|
17
|
+
typer.Exit
|
|
18
|
+
Exits the program with code 1.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
rich.print(f"[red]Error:[/red] {msg}")
|
|
22
|
+
raise typer.Exit(code=1)
|
nextmv/cli/main.py
CHANGED
|
@@ -1,11 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Nextmv Command Line Interface (CLI).
|
|
3
|
+
|
|
4
|
+
This module is the main entry point for the Nextmv CLI application. The Nextmv
|
|
5
|
+
CLI is built with [Typer](https://typer.tiangolo.com/) and provides various
|
|
6
|
+
commands to interact with Nextmv services. You should visit the "Learn" section
|
|
7
|
+
of the Typer documentation to learn about the features that are used here.
|
|
8
|
+
|
|
9
|
+
The Nextmv CLI also uses [Rich](https://rich.readthedocs.io/en/stable/) for
|
|
10
|
+
rich text and formatting in the terminal. The command documentation is created
|
|
11
|
+
using Rich markup. You should also visit the Rich documentation to learn more
|
|
12
|
+
about the features used here. An example of Rich markup can be found in the
|
|
13
|
+
epilog of the Typer application defined below.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
import rich
|
|
1
19
|
import typer
|
|
20
|
+
from rich.prompt import Confirm
|
|
2
21
|
|
|
22
|
+
from nextmv.cli.community.community import app as community_app
|
|
23
|
+
from nextmv.cli.configuration.config import CONFIG_DIR, GO_CLI_PATH, load_config
|
|
24
|
+
from nextmv.cli.configuration.configuration import app as configuration_app
|
|
25
|
+
from nextmv.cli.error import error
|
|
3
26
|
from nextmv.cli.version import app as version_app
|
|
4
27
|
|
|
28
|
+
# Main CLI application.
|
|
5
29
|
app = typer.Typer(
|
|
6
30
|
help="The Nextmv Command Line Interface (CLI).",
|
|
7
|
-
epilog="[italic]:rabbit: Made with :heart:
|
|
31
|
+
epilog="[dim]\n---\n\n[italic]:rabbit: Made by Nextmv with :heart:[/italic][/dim]",
|
|
8
32
|
rich_markup_mode="rich",
|
|
9
33
|
context_settings={"help_option_names": ["--help", "-h"]},
|
|
34
|
+
no_args_is_help=True,
|
|
10
35
|
)
|
|
36
|
+
|
|
37
|
+
# Register subcommands. The `name` parameter is required when the subcommand
|
|
38
|
+
# module has a callback function defined.
|
|
39
|
+
app.add_typer(community_app, name="community")
|
|
40
|
+
app.add_typer(configuration_app, name="configuration")
|
|
11
41
|
app.add_typer(version_app)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback()
|
|
45
|
+
def callback(ctx: typer.Context) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Callback function that runs before any command. Useful for checks on the
|
|
48
|
+
environment.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
handle_go_cli()
|
|
52
|
+
handle_config_existence(ctx)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def handle_go_cli() -> None:
|
|
56
|
+
"""
|
|
57
|
+
Handle the presence of the deprecated Go CLI by notifying the user.
|
|
58
|
+
|
|
59
|
+
This function checks if the Go CLI is installed and prompts the user to
|
|
60
|
+
remove it to avoid conflicts with the Python CLI.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
exists = go_cli_exists()
|
|
64
|
+
if exists:
|
|
65
|
+
delete = Confirm.ask(
|
|
66
|
+
"Do you want to delete the [italic red]deprecated[/italic red] Nextmv CLI "
|
|
67
|
+
f"at [italic]{GO_CLI_PATH}[/italic] now?",
|
|
68
|
+
default=True,
|
|
69
|
+
)
|
|
70
|
+
if delete:
|
|
71
|
+
remove_go_cli()
|
|
72
|
+
else:
|
|
73
|
+
rich.print(
|
|
74
|
+
":bulb: You can delete the [italic red]deprecated[/italic red] Nextmv CLI "
|
|
75
|
+
f"later by removing [italic]{GO_CLI_PATH}[/italic]. Make sure you also clean up your [code]PATH[/code]."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def handle_config_existence(ctx: typer.Context) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Check if configuration exists and show an error if it does not.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
ctx : typer.Context
|
|
86
|
+
The Typer context object.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
ignored_commands = {"configuration", "version"}
|
|
90
|
+
if ctx.invoked_subcommand in ignored_commands:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
config = load_config()
|
|
94
|
+
if config == {}:
|
|
95
|
+
error("No configuration found. Please run [code]nextmv configuration create[/code].")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def go_cli_exists() -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Check if the Go CLI is installed by looking for the 'nextmv' executable
|
|
101
|
+
under the config dir.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
bool
|
|
106
|
+
True if the Go CLI is installed, False otherwise.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
# Check if the Go CLI executable exists
|
|
110
|
+
exists = GO_CLI_PATH.exists()
|
|
111
|
+
if exists:
|
|
112
|
+
rich.print(
|
|
113
|
+
":construction: A [italic red]deprecated[/italic red] Nextmv CLI is installed at "
|
|
114
|
+
f"[italic]{GO_CLI_PATH}[/italic]. You must delete it to avoid conflicts."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
check_config_in_path()
|
|
118
|
+
|
|
119
|
+
return exists
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def remove_go_cli() -> None:
|
|
123
|
+
"""
|
|
124
|
+
Remove the Go CLI executable if it exists and notify about PATH cleanup.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
if GO_CLI_PATH.exists():
|
|
128
|
+
GO_CLI_PATH.unlink()
|
|
129
|
+
rich.print(f":white_check_mark: Deleted deprecated {GO_CLI_PATH}.")
|
|
130
|
+
|
|
131
|
+
check_config_in_path()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_config_in_path() -> None:
|
|
135
|
+
"""
|
|
136
|
+
Check if the configuration directory is in the PATH and notify the user.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
|
140
|
+
config_dir_str = str(CONFIG_DIR)
|
|
141
|
+
|
|
142
|
+
if config_dir_str in path_dirs:
|
|
143
|
+
rich.print(
|
|
144
|
+
f":construction: [italic]{CONFIG_DIR}[/italic] was found in your [code]PATH[/code]. "
|
|
145
|
+
f"You should remove any entries related to [italic]{CONFIG_DIR}[/italic] from your [code]PATH[/code]."
|
|
146
|
+
)
|
nextmv/cli/options.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared CLI options for the Nextmv CLI.
|
|
3
|
+
|
|
4
|
+
This module defines reusable option types that can be imported
|
|
5
|
+
and used across all CLI commands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
# profile option - can be used in any command to specify which profile to use.
|
|
13
|
+
# Define it as follows in commands or callbacks, as necessary:
|
|
14
|
+
# profile: ProfileOption = None
|
|
15
|
+
ProfileOption = Annotated[
|
|
16
|
+
str | None,
|
|
17
|
+
typer.Option(
|
|
18
|
+
"--profile",
|
|
19
|
+
"-p",
|
|
20
|
+
help="Profile to use for this action. Use [code]nextmv configuration[/code] to manage profiles.",
|
|
21
|
+
envvar="NEXTMV_PROFILE",
|
|
22
|
+
metavar="PROFILE_NAME",
|
|
23
|
+
),
|
|
24
|
+
]
|
nextmv/cli/version.py
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the version command for the Nextmv CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
1
5
|
import typer
|
|
2
|
-
from rich import print
|
|
3
6
|
|
|
4
7
|
from nextmv.__about__ import __version__
|
|
5
8
|
|
|
9
|
+
# Set up subcommand application.
|
|
6
10
|
app = typer.Typer()
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
@app.command()
|
|
10
|
-
def version() ->
|
|
14
|
+
def version() -> None:
|
|
11
15
|
"""
|
|
12
16
|
Show the current version of the Nextmv CLI.
|
|
13
17
|
"""
|
|
18
|
+
|
|
14
19
|
print(__version__)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
nextmv/__about__.py,sha256=
|
|
1
|
+
nextmv/__about__.py,sha256=F4Lf3QJWmlBfnNtouk1QY7WHW34ubJX7Y9tFbAZdAU0,24
|
|
2
2
|
nextmv/__entrypoint__.py,sha256=dA0iwwHtrq6Z9w9FxmxKLoBGLyhe7jWtUAU-Y3PEgHg,1094
|
|
3
3
|
nextmv/__init__.py,sha256=mC-gAzCdoZJ0BOVe2fDzKNdBtbXzx8XOxHP_7DdPMdQ,3857
|
|
4
4
|
nextmv/_serialization.py,sha256=jYitMS1MU8ldsmObT-K_8V8P2Wx69tnDiEHCCgPGun4,2834
|
|
@@ -15,8 +15,20 @@ nextmv/run.py,sha256=qeT3a0eqVHKvbQiFxmX_2epaj-rz2GyfpdZJ4ROFC4U,53135
|
|
|
15
15
|
nextmv/safe.py,sha256=VAK4fGEurbLNji4Pg5Okga5XQSbI4aI9JJf95_68Z20,3867
|
|
16
16
|
nextmv/status.py,sha256=SCDLhh2om3yeO5FxO0x-_RShQsZNXEpjHNdCGdb3VUI,2787
|
|
17
17
|
nextmv/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
-
nextmv/cli/
|
|
19
|
-
nextmv/cli/
|
|
18
|
+
nextmv/cli/error.py,sha256=NldsTMKOwkujz2lBuUy3phokexJjEHKcGZE41xfH9bY,404
|
|
19
|
+
nextmv/cli/main.py,sha256=GH3mbC1AGdJgBw4vrRO7Qhx5zJmd2D18tp25mzqfc0Y,4570
|
|
20
|
+
nextmv/cli/options.py,sha256=J_BMXuViOTpo8gqCYP3zHePIt36AUVM7dun1BeztW2Q,640
|
|
21
|
+
nextmv/cli/version.py,sha256=xY2EV-L1Iz6TToCTOdUyskE7kQBzEikVM5jz3pNpQlQ,306
|
|
22
|
+
nextmv/cli/community/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
nextmv/cli/community/clone.py,sha256=eJWzFdLasZi4Igb588K-uBtfODdS1UmkJhNcvL0htyM,9165
|
|
24
|
+
nextmv/cli/community/community.py,sha256=t2l6adm9Km6hSvSFzeKHTQzacVSnwjx4wpj2kqee5nM,612
|
|
25
|
+
nextmv/cli/community/list.py,sha256=dAiPS54GMzHkLxT4i9fvCyLJ3ljTDQaGnOFm0aKdSzI,7497
|
|
26
|
+
nextmv/cli/configuration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
nextmv/cli/configuration/config.py,sha256=d0fJn_SFzxJSQUykFC-vFn9bu9vwdwZCI1lgtuxJuzQ,3558
|
|
28
|
+
nextmv/cli/configuration/configuration.py,sha256=7oryF4PKkORh8bcdgbN2k36rZrFpJsM7Xfq_J4-MkFs,516
|
|
29
|
+
nextmv/cli/configuration/create.py,sha256=HRi7z1OH9i1TOl2TLyI-coOlQTKx9JXyarXhIc11QMA,2703
|
|
30
|
+
nextmv/cli/configuration/delete.py,sha256=R10Q1D29-woaR_VFbLKB4c-rYZiyWdVSMG9yTjevinM,1523
|
|
31
|
+
nextmv/cli/configuration/list.py,sha256=XN8dAO3Brbbj7PH_Hfgcl3GDNP4OmmDHt34u5Tq3LVA,2201
|
|
20
32
|
nextmv/cloud/__init__.py,sha256=n8laWdl0BjKmJOgGdPLHYUY_eaqhmXlW0-421A3SfwM,5336
|
|
21
33
|
nextmv/cloud/acceptance_test.py,sha256=fZdp4O6pZrl7TaiUrTFPp7O4VJt-4R_W2yo4s8UAS5I,27691
|
|
22
34
|
nextmv/cloud/account.py,sha256=jIdGNyI3l3dVh2PuriAwAOrEuWRM150WgzxcBMVBNRw,6058
|
|
@@ -48,8 +60,7 @@ nextmv/local/geojson_handler.py,sha256=7FavJdkUonop-yskjis0x3qFGB8A5wZyoBUblw-bV
|
|
|
48
60
|
nextmv/local/local.py,sha256=cp56UpI8h19Ob6Jvb_Ni0ceXH5Vv3ET_iPTDe6ftq3Y,2617
|
|
49
61
|
nextmv/local/plotly_handler.py,sha256=bLb50e3AkVr_W-F6S7lXfeRdN60mG2jk3UElNmhoMWU,1930
|
|
50
62
|
nextmv/local/runner.py,sha256=Fa-G4g5yaBgLeBfYU-ePs65Q3Ses_xYvXGhPtHpAkrU,8546
|
|
51
|
-
nextmv-0.
|
|
52
|
-
nextmv-0.
|
|
53
|
-
nextmv-0.
|
|
54
|
-
nextmv-0.
|
|
55
|
-
nextmv-0.39.0.dev0.dist-info/RECORD,,
|
|
63
|
+
nextmv-0.40.0.dist-info/METADATA,sha256=jWy5xVb-LxKndjc5n40ktK39Gct9cncz0-oH-0sqj2M,16019
|
|
64
|
+
nextmv-0.40.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
65
|
+
nextmv-0.40.0.dist-info/licenses/LICENSE,sha256=ZIbK-sSWA-OZprjNbmJAglYRtl5_K4l9UwAV3PGJAPc,11349
|
|
66
|
+
nextmv-0.40.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|