nextmv 1.0.0.dev6__py3-none-any.whl → 1.0.0.dev8__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 CHANGED
@@ -1 +1 @@
1
- __version__ = "v1.0.0.dev6"
1
+ __version__ = "v1.0.0.dev8"
nextmv/_serialization.py CHANGED
@@ -90,7 +90,7 @@ def _custom_serial(obj: Any) -> str:
90
90
  If the object type is not supported for serialization.
91
91
  """
92
92
 
93
- if isinstance(obj, (datetime.datetime, datetime.date)):
93
+ if isinstance(obj, datetime.datetime | datetime.date):
94
94
  return obj.isoformat()
95
95
 
96
96
  raise TypeError(f"Type {type(obj)} not serializable")
@@ -45,7 +45,7 @@ app = typer.Typer()
45
45
  - Multiple metrics as a [magenta]json[/magenta] array in a single --metrics flag.
46
46
 
47
47
  Each metric must have the following fields:
48
- - [magenta]field[/magenta]: Field of the metric to measure (e.g., "solution.objective").
48
+ - [magenta]field[/magenta]: Field of the metric to measure (e.g., "result.custom.unassigned").
49
49
  - [magenta]metric_type[/magenta]: Type of metric comparison. Allowed values: {enum_values(MetricType)}.
50
50
  - [magenta]params[/magenta]: Parameters of the metric comparison.
51
51
  - [magenta]operator[/magenta]: Comparison operator. Allowed values: {enum_values(Comparison)}.
@@ -71,8 +71,8 @@ app = typer.Typer()
71
71
  [bold][underline]Examples[/underline][/bold]
72
72
 
73
73
  - Create an acceptance test with a single metric.
74
- $ [dim]METRIC='{{
75
- "field": "solution.objective",
74
+ $ [green]METRIC='{{
75
+ "field": "result.custom.unassigned",
76
76
  "metric_type": "direct-comparison",
77
77
  "params": {{
78
78
  "operator": "lt",
@@ -85,8 +85,8 @@ app = typer.Typer()
85
85
  --metrics "$METRIC" --input-set-id input-set-123[/dim]
86
86
 
87
87
  - Create with multiple metrics by repeating the flag.
88
- $ [dim]METRIC1='{{
89
- "field": "solution.objective",
88
+ $ [green]METRIC1='{{
89
+ "field": "result.custom.unassigned",
90
90
  "metric_type": "direct-comparison",
91
91
  "params": {{
92
92
  "operator": "lt",
@@ -95,7 +95,7 @@ app = typer.Typer()
95
95
  "statistic": "mean"
96
96
  }}'
97
97
  METRIC2='{{
98
- "field": "statistics.run.duration",
98
+ "field": "run.duration",
99
99
  "metric_type": "direct-comparison",
100
100
  "params": {{
101
101
  "operator": "le",
@@ -110,7 +110,7 @@ app = typer.Typer()
110
110
  - Create with multiple metrics in a single [magenta]json[/magenta] array.
111
111
  $ [dim]METRICS='[
112
112
  {{
113
- "field": "solution.objective",
113
+ "field": "result.custom.unassigned",
114
114
  "metric_type": "direct-comparison",
115
115
  "params": {{
116
116
  "operator": "lt",
@@ -119,7 +119,7 @@ app = typer.Typer()
119
119
  "statistic": "mean"
120
120
  }},
121
121
  {{
122
- "field": "statistics.run.duration",
122
+ "field": "run.duration",
123
123
  "metric_type": "direct-comparison",
124
124
  "params": {{
125
125
  "operator": "le",
@@ -133,8 +133,8 @@ app = typer.Typer()
133
133
  --metrics "$METRICS" --input-set-id input-set-123[/dim]
134
134
 
135
135
  - Create an acceptance test and wait for it to complete.
136
- $ [dim]METRIC='{{
137
- "field": "solution.objective",
136
+ $ [green]METRIC='{{
137
+ "field": "result.custom.unassigned",
138
138
  "metric_type": "direct-comparison",
139
139
  "params": {{
140
140
  "operator": "lt",
@@ -147,8 +147,8 @@ app = typer.Typer()
147
147
  --metrics "$METRIC" --input-set-id input-set-123 --wait[/dim]
148
148
 
149
149
  - Create an acceptance test and save the results to a file, waiting for completion.
150
- $ [dim]METRIC='{{
151
- "field": "solution.objective",
150
+ $ [green]METRIC='{{
151
+ "field": "result.custom.unassigned",
152
152
  "metric_type": "direct-comparison",
153
153
  "params": {{
154
154
  "operator": "lt",
@@ -129,34 +129,26 @@ def push(
129
129
  $ [dim]nextmv cloud app push --app-id hare-app --version-yes --update-instance-id inst-1[/dim]
130
130
  """
131
131
 
132
- cloud_app = build_app(app_id=app_id, profile=profile)
133
-
134
- # If a version already exists, we cannot create it.
135
- if version_id is not None and version_id != "":
136
- exists = cloud_app.version_exists(version_id=version_id)
137
- if exists:
138
- error(
139
- f"Version [magenta]{version_id}[/magenta] already exists for application [magenta]{app_id}[/magenta]."
140
- )
141
-
142
132
  # We cannot create and update an instance at the same time.
143
133
  update_defined = update_instance_id is not None and update_instance_id != ""
144
134
  create_defined = create_instance_id is not None and create_instance_id != ""
145
135
  if update_defined and create_defined:
146
136
  error("Cannot use --update-instance-id and --create-instance-id at the same time.")
147
137
 
138
+ cloud_app = build_app(app_id=app_id, profile=profile)
139
+
148
140
  # We cannot update an instance that does not exist.
149
141
  if update_defined and not cloud_app.instance_exists(instance_id=update_instance_id):
150
142
  error(
151
- f"Used option --update-instance-id but the instance [magenta]{update_instance_id}[/magenta] "
152
- "does not exist. Use --create-instance-id instead."
143
+ f"Used option --update-instance-id but the instance {update_instance_id} does not exist. "
144
+ "Use --create-instance-id instead."
153
145
  )
154
146
 
155
147
  # We cannot create an instance that already exists.
156
148
  if create_defined and cloud_app.instance_exists(instance_id=create_instance_id):
157
149
  error(
158
- f"Used option --create-instance-id but the instance [magenta]{create_instance_id}[/magenta] "
159
- "already exists. Use --update-instance-id instead."
150
+ f"Used option --create-instance-id but the instance {create_instance_id} already exists. "
151
+ "Use --update-instance-id instead."
160
152
  )
161
153
 
162
154
  # Do the normal push first.
@@ -251,6 +243,14 @@ def _handle_version_creation(
251
243
  # If the user provides a version, and it exists, we use it directly and we
252
244
  # are done.
253
245
  if version_id is not None and version_id != "":
246
+ exists = cloud_app.version_exists(version_id=version_id)
247
+ if exists:
248
+ error(
249
+ f"Version [magenta]{version_id}[/magenta] already exists for application [magenta]{app_id}[/magenta]."
250
+ )
251
+
252
+ return "", False
253
+
254
254
  info(
255
255
  msg=f"Version [magenta]{version_id}[/magenta] does not exist. A new version will be created.",
256
256
  emoji=":bulb:",
@@ -261,7 +261,7 @@ def _handle_version_creation(
261
261
  # If we are not auto-confirming version creation, ask the user.
262
262
  if not version_yes:
263
263
  should_create = get_confirmation(
264
- msg=f"Do you want to create a new version for application [magenta]{app_id}[/magenta] now?",
264
+ msg=f"Do you want to create a new version [magenta]{app_id}[/magenta] now?",
265
265
  default=True,
266
266
  )
267
267
 
@@ -5,7 +5,6 @@ This module defines the cloud input-set command tree for the Nextmv CLI.
5
5
  import typer
6
6
 
7
7
  from nextmv.cli.cloud.input_set.create import app as create_app
8
- from nextmv.cli.cloud.input_set.delete import app as delete_app
9
8
  from nextmv.cli.cloud.input_set.get import app as get_app
10
9
  from nextmv.cli.cloud.input_set.list import app as list_app
11
10
  from nextmv.cli.cloud.input_set.update import app as update_app
@@ -13,7 +12,6 @@ from nextmv.cli.cloud.input_set.update import app as update_app
13
12
  # Set up subcommand application.
14
13
  app = typer.Typer()
15
14
  app.add_typer(create_app)
16
- app.add_typer(delete_app)
17
15
  app.add_typer(get_app)
18
16
  app.add_typer(list_app)
19
17
  app.add_typer(update_app)
@@ -129,7 +129,7 @@ def create(
129
129
  metavar="INSTANCE_ID",
130
130
  rich_help_panel="Run configuration",
131
131
  ),
132
- ] = None,
132
+ ] = "latest",
133
133
  integration_id: Annotated[
134
134
  str | None,
135
135
  typer.Option(
@@ -240,11 +240,12 @@ def create(
240
240
  specify the instance with the --instance-id flag. These are the possible
241
241
  values for this flag:
242
242
 
243
- - [yellow]unspecified[/yellow]: Run against the default instance of the
244
- application. When an application is created, the default instance is [magenta]latest[/magenta].
245
243
  - [yellow]latest[/yellow]: uses the special [magenta]latest[/magenta]
246
244
  instance of the application. This corresponds to the latest pushed
247
- executable.
245
+ executable. This is the default behavior.
246
+ - [yellow]default[/yellow]: if the application has a [italic]default[/italic]
247
+ instance configured, then it uses that instance. Setting the flag's value
248
+ to [magenta]''[/magenta] (empty string) has the same effect.
248
249
  - [yellow]<INSTANCE_ID>[/yellow]: uses the instance with the given ID.
249
250
 
250
251
  [bold][underline]Examples[/underline][/bold]
@@ -318,6 +319,10 @@ def create(
318
319
  )
319
320
  run_options = build_run_options(options)
320
321
 
322
+ # Handles the default instance.
323
+ if instance_id == "default":
324
+ instance_id = ""
325
+
321
326
  # Start the run before deciding if we should poll or not.
322
327
  input_kwarg = resolve_input_kwarg(
323
328
  stdin=stdin,
@@ -2,14 +2,11 @@
2
2
  This module defines the cloud shadow stop command for the Nextmv CLI.
3
3
  """
4
4
 
5
- from typing import Annotated
6
-
7
5
  import typer
8
6
 
9
7
  from nextmv.cli.configuration.config import build_app
10
- from nextmv.cli.message import enum_values, in_progress, success
8
+ from nextmv.cli.message import in_progress, success
11
9
  from nextmv.cli.options import AppIDOption, ProfileOption, ShadowTestIDOption
12
- from nextmv.cloud.shadow import StopIntent
13
10
 
14
11
  # Set up subcommand application.
15
12
  app = typer.Typer()
@@ -18,15 +15,6 @@ app = typer.Typer()
18
15
  @app.command()
19
16
  def stop(
20
17
  app_id: AppIDOption,
21
- intent: Annotated[
22
- StopIntent,
23
- typer.Option(
24
- "--intent",
25
- "-i",
26
- help=f"Intent for stopping the shadow test. Allowed values are: {enum_values(StopIntent)}.",
27
- metavar="INTENT",
28
- ),
29
- ],
30
18
  shadow_test_id: ShadowTestIDOption,
31
19
  profile: ProfileOption = None,
32
20
  ) -> None:
@@ -46,7 +34,7 @@ def stop(
46
34
 
47
35
  in_progress(msg="Stopping shadow test...")
48
36
  cloud_app = build_app(app_id=app_id, profile=profile)
49
- cloud_app.stop_shadow_test(shadow_test_id=shadow_test_id, intent=StopIntent(intent))
37
+ cloud_app.stop_shadow_test(shadow_test_id=shadow_test_id)
50
38
  success(
51
39
  f"Shadow test [magenta]{shadow_test_id}[/magenta] stopped successfully "
52
40
  f"in application [magenta]{app_id}[/magenta]."
@@ -2,14 +2,11 @@
2
2
  This module defines the cloud switchback stop command for the Nextmv CLI.
3
3
  """
4
4
 
5
- from typing import Annotated
6
-
7
5
  import typer
8
6
 
9
7
  from nextmv.cli.configuration.config import build_app
10
- from nextmv.cli.message import enum_values, in_progress, success
8
+ from nextmv.cli.message import in_progress, success
11
9
  from nextmv.cli.options import AppIDOption, ProfileOption, SwitchbackTestIDOption
12
- from nextmv.cloud.shadow import StopIntent
13
10
 
14
11
  # Set up subcommand application.
15
12
  app = typer.Typer()
@@ -18,15 +15,6 @@ app = typer.Typer()
18
15
  @app.command()
19
16
  def stop(
20
17
  app_id: AppIDOption,
21
- intent: Annotated[
22
- StopIntent,
23
- typer.Option(
24
- "--intent",
25
- "-i",
26
- help=f"Intent for stopping the switchback test. Allowed values are: {enum_values(StopIntent)}.",
27
- metavar="INTENT",
28
- ),
29
- ],
30
18
  switchback_test_id: SwitchbackTestIDOption,
31
19
  profile: ProfileOption = None,
32
20
  ) -> None:
@@ -46,7 +34,7 @@ def stop(
46
34
 
47
35
  in_progress(msg="Stopping switchback test...")
48
36
  cloud_app = build_app(app_id=app_id, profile=profile)
49
- cloud_app.stop_switchback_test(switchback_test_id=switchback_test_id, intent=StopIntent(intent))
37
+ cloud_app.stop_switchback_test(switchback_test_id=switchback_test_id)
50
38
  success(
51
39
  f"Switchback test [magenta]{switchback_test_id}[/magenta] stopped successfully "
52
40
  f"in application [magenta]{app_id}[/magenta]."
@@ -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()
@@ -72,15 +77,196 @@ def clone(
72
77
  $ [dim]nextmv community clone --app go-nextroute --profile hare[/dim]
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
84
  error("The --version flag cannot be an empty string.")
77
85
 
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,
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
+ # Clean and normalize directory path in an OS-independent way
101
+ if directory is not None and directory != "":
102
+ destination = os.path.normpath(directory)
103
+ else:
104
+ destination = app
105
+
106
+ full_destination = get_valid_path(destination, os.stat)
107
+ os.makedirs(full_destination, exist_ok=True)
108
+
109
+ tarball = f"{app}_{version}.tar.gz"
110
+ s3_file_path = f"{app}/{version}/{tarball}"
111
+ downloaded_object = download_object(
112
+ file=s3_file_path,
113
+ path="community-apps",
114
+ output_dir=full_destination,
115
+ output_file=tarball,
116
+ profile=profile,
117
+ )
118
+
119
+ # Extract the tarball to a temporary directory to handle nested structure
120
+ with tempfile.TemporaryDirectory() as temp_dir:
121
+ with tarfile.open(downloaded_object, "r:gz") as tar:
122
+ tar.extractall(path=temp_dir)
123
+
124
+ # Find the extracted directory (typically the app name)
125
+ extracted_items = os.listdir(temp_dir)
126
+ if len(extracted_items) == 1 and os.path.isdir(os.path.join(temp_dir, extracted_items[0])):
127
+ # Move contents from the extracted directory to full_destination
128
+ extracted_dir = os.path.join(temp_dir, extracted_items[0])
129
+ for item in os.listdir(extracted_dir):
130
+ shutil.move(os.path.join(extracted_dir, item), full_destination)
131
+ else:
132
+ # If structure is unexpected, move everything directly
133
+ for item in extracted_items:
134
+ shutil.move(os.path.join(temp_dir, item), full_destination)
135
+
136
+ # Remove the tarball after extraction
137
+ os.remove(downloaded_object)
138
+
139
+ success(
140
+ f"Successfully cloned the [magenta]{app}[/magenta] community app, "
141
+ f"using version [magenta]{original_version}[/magenta] in path: [magenta]{full_destination}[/magenta]."
86
142
  )
143
+
144
+
145
+ def app_has_version(app_obj: dict, version: str) -> bool:
146
+ """
147
+ Check if the given app object has the specified version.
148
+
149
+ Parameters
150
+ ----------
151
+ app_obj : dict
152
+ The community app object.
153
+ version : str
154
+ The version to check.
155
+
156
+ Returns
157
+ -------
158
+ bool
159
+ True if the app has the specified version, False otherwise.
160
+ """
161
+
162
+ if version == LATEST_VERSION:
163
+ version = app_obj.get("latest_app_version")
164
+
165
+ if version in app_obj.get("app_versions", []):
166
+ return True
167
+
168
+ return False
169
+
170
+
171
+ def get_valid_path(path: str, stat_fn: Callable[[str], os.stat_result], ending: str = "") -> str:
172
+ """
173
+ Validates and returns a non-existing path. If the path exists,
174
+ it will append a number to the path and return it. If the path does not
175
+ exist, it will return the path as is.
176
+
177
+ The ending parameter is used to check if the path ends with a specific
178
+ string. This is useful to specify if it is a file (like foo.json, in which
179
+ case the next iteration is foo-1.json) or a directory (like foo, in which
180
+ case the next iteration is foo-1).
181
+
182
+ Parameters
183
+ ----------
184
+ path : str
185
+ The initial path to validate.
186
+ stat_fn : Callable[[str], os.stat_result]
187
+ A function that takes a path and returns its stat result.
188
+ ending : str, optional
189
+ The expected ending of the path (e.g., file extension), by default "".
190
+
191
+ Returns
192
+ -------
193
+ str
194
+ A valid, non-existing path.
195
+
196
+ Raises
197
+ ------
198
+ Exception
199
+ If an unexpected error occurs during path validation
200
+ """
201
+ base_name = os.path.basename(path)
202
+ name_without_ending = base_name.removesuffix(ending) if ending else base_name
203
+
204
+ while True:
205
+ try:
206
+ stat_fn(path)
207
+ # If we get here, the path exists
208
+ # Get folder/file name number, increase it and create new path
209
+ name = os.path.basename(path)
210
+
211
+ # Get folder/file name number
212
+ parts = name.split("-")
213
+ last = parts[-1].removesuffix(ending) if ending else parts[-1]
214
+
215
+ # Save last folder name index to be changed
216
+ i = path.rfind(name)
217
+
218
+ try:
219
+ num = int(last)
220
+ # Increase number and create new path
221
+ if ending:
222
+ temp_path = path[:i] + f"{name_without_ending}-{num + 1}{ending}"
223
+ else:
224
+ temp_path = path[:i] + f"{base_name}-{num + 1}"
225
+ path = temp_path
226
+ except ValueError:
227
+ # If there is no number, add it
228
+ if ending:
229
+ temp_path = path[:i] + f"{name_without_ending}-1{ending}"
230
+ else:
231
+ temp_path = path[:i] + f"{name}-1"
232
+ path = temp_path
233
+
234
+ except FileNotFoundError:
235
+ # Path doesn't exist, we can use it
236
+ return path
237
+ except Exception:
238
+ # Re-raise unexpected errors
239
+ error(f"An unexpected error occurred while validating the path: {path}")
240
+
241
+
242
+ def download_object(file: str, path: str, output_dir: str, output_file: str, profile: str | None = None) -> str:
243
+ """
244
+ Downloads an object from the internal bucket and saves it to the specified
245
+ output directory.
246
+
247
+ Parameters
248
+ ----------
249
+ file : str
250
+ The name of the file to download.
251
+ path : str
252
+ The directory in the bucket where the file is located.
253
+ output_dir : str
254
+ The local directory where the file will be saved.
255
+ output_file : str
256
+ The name of the output file.
257
+ profile : str | None
258
+ The profile name to use. If None, the default profile is used.
259
+
260
+ Returns
261
+ -------
262
+ str
263
+ The path to the downloaded file.
264
+ """
265
+
266
+ response = download_file(directory=path, file=file, profile=profile)
267
+ file_name = os.path.join(output_dir, output_file)
268
+
269
+ with open(file_name, "wb") as f:
270
+ f.write(response.content)
271
+
272
+ return file_name