nextmv 1.0.0.dev5__py3-none-any.whl → 1.0.0.dev6__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.
@@ -5,6 +5,7 @@ Application mixin for managing switchback tests.
5
5
  from datetime import datetime
6
6
  from typing import TYPE_CHECKING
7
7
 
8
+ from nextmv.cloud.shadow import StopIntent
8
9
  from nextmv.cloud.switchback import SwitchbackTest, SwitchbackTestMetadata, TestComparisonSingle
9
10
  from nextmv.run import Run
10
11
  from nextmv.safe import safe_id
@@ -257,7 +258,7 @@ class ApplicationSwitchbackMixin:
257
258
  endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}/start",
258
259
  )
259
260
 
260
- def stop_switchback_test(self: "Application", switchback_test_id: str) -> None:
261
+ def stop_switchback_test(self: "Application", switchback_test_id: str, intent: StopIntent) -> None:
261
262
  """
262
263
  Stop a switchback test. The test should already have started before using
263
264
  this method.
@@ -267,15 +268,23 @@ class ApplicationSwitchbackMixin:
267
268
  switchback_test_id : str
268
269
  ID of the switchback test to stop.
269
270
 
271
+ intent : StopIntent
272
+ Intent for stopping the switchback test.
273
+
270
274
  Raises
271
275
  ------
272
276
  requests.HTTPError
273
277
  If the response status code is not 2xx.
274
278
  """
275
279
 
280
+ payload = {
281
+ "intent": intent.value,
282
+ }
283
+
276
284
  _ = self.client.request(
277
285
  method="PUT",
278
286
  endpoint=f"{self.experiments_endpoint}/switchback/{switchback_test_id}/stop",
287
+ payload=payload,
279
288
  )
280
289
 
281
290
  def update_switchback_test(
@@ -30,7 +30,9 @@ class ExperimentStatus(str, Enum):
30
30
 
31
31
  You can import the `ExperimentStatus` class directly from `cloud`:
32
32
 
33
- ```python from nextmv.cloud import ExperimentStatus ```
33
+ ```python
34
+ from nextmv.cloud import ExperimentStatus
35
+ ```
34
36
 
35
37
  This enum represents the comprehensive set of possible states for an
36
38
  experiment in Nextmv Cloud.
nextmv/cloud/client.py CHANGED
@@ -303,7 +303,7 @@ class Client:
303
303
  if data is not None:
304
304
  kwargs["data"] = data
305
305
  if payload is not None:
306
- if isinstance(payload, dict | list):
306
+ if isinstance(payload, (dict, list)):
307
307
  data = deflated_serialize_json(payload, json_configurations=json_configurations)
308
308
  kwargs["data"] = data
309
309
  else:
@@ -0,0 +1,441 @@
1
+ """
2
+ This module contains functionality for working with Nextmv community apps.
3
+
4
+ Community apps are pre-built decision models. They are maintained in the
5
+ following GitHub repository: https://github.com/nextmv-io/community-apps
6
+
7
+ Classes
8
+ -------
9
+ CommunityApp
10
+ Representation of a Nextmv Cloud Community App.
11
+
12
+ Functions
13
+ ---------
14
+ list_community_apps
15
+ List the available Nextmv community apps.
16
+ clone_community_app
17
+ Clone a community app locally.
18
+ """
19
+
20
+ import os
21
+ import shutil
22
+ import sys
23
+ import tarfile
24
+ import tempfile
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ import requests
29
+ import rich
30
+ import yaml
31
+ from pydantic import AliasChoices, Field
32
+
33
+ from nextmv.base_model import BaseModel
34
+ from nextmv.cloud.client import Client
35
+ from nextmv.logger import log
36
+
37
+ # Helpful constants.
38
+ LATEST_VERSION = "latest"
39
+
40
+
41
+ class CommunityApp(BaseModel):
42
+ """
43
+ Information about a Nextmv community app.
44
+
45
+ You can import the `CommunityApp` class directly from `cloud`:
46
+
47
+ ```python
48
+ from nextmv.cloud import CommunityApp
49
+ ```
50
+
51
+ Parameters
52
+ ----------
53
+ app_versions : list[str]
54
+ Available versions of the community app.
55
+ description : str
56
+ Description of the community app.
57
+ latest_app_version : str
58
+ The latest version of the community app.
59
+ latest_marketplace_version : str
60
+ The latest version of the community app in the Nextmv Marketplace.
61
+ marketplace_versions : list[str]
62
+ Available versions of the community app in the Nextmv Marketplace.
63
+ name : str
64
+ Name of the community app.
65
+ app_type : str
66
+ Type of the community app.
67
+ """
68
+
69
+ description: str
70
+ """Description of the community app."""
71
+ name: str
72
+ """Name of the community app."""
73
+ app_type: str = Field(
74
+ serialization_alias="type",
75
+ validation_alias=AliasChoices("type", "app_type"),
76
+ )
77
+ """Type of the community app."""
78
+
79
+ app_versions: list[str] | None = None
80
+ """Available versions of the community app."""
81
+ latest_app_version: str | None = None
82
+ """The latest version of the community app."""
83
+ latest_marketplace_version: str | None = None
84
+ """The latest version of the community app in the Nextmv Marketplace."""
85
+ marketplace_versions: list[str] | None = None
86
+ """Available versions of the community app in the Nextmv Marketplace."""
87
+
88
+ def has_version(self, version: str) -> bool:
89
+ """
90
+ Check if the community app has the specified version.
91
+
92
+ Parameters
93
+ ----------
94
+ version : str
95
+ The version to check.
96
+
97
+ Returns
98
+ -------
99
+ bool
100
+ True if the app has the specified version, False otherwise.
101
+ """
102
+
103
+ if version == LATEST_VERSION:
104
+ version = self.latest_app_version
105
+
106
+ if self.app_versions is not None and version in self.app_versions:
107
+ return True
108
+
109
+ return False
110
+
111
+
112
+ def list_community_apps(client: Client) -> list[CommunityApp]:
113
+ """
114
+ List the available Nextmv community apps.
115
+
116
+ You can import the `list_community_apps` function directly from `cloud`:
117
+
118
+ ```python
119
+ from nextmv.cloud import list_community_apps
120
+ ```
121
+
122
+ Parameters
123
+ ----------
124
+ manifest : dict[str, Any]
125
+ The community apps manifest.
126
+
127
+ Returns
128
+ -------
129
+ list[CommunityApp]
130
+ A list of available community apps.
131
+ """
132
+
133
+ manifest = _download_manifest(client)
134
+ dict_apps = manifest.get("apps", [])
135
+ apps = [CommunityApp.from_dict(app) for app in dict_apps]
136
+
137
+ return apps
138
+
139
+
140
+ def clone_community_app(
141
+ client: Client,
142
+ app: str,
143
+ directory: str | None = None,
144
+ version: str | None = LATEST_VERSION,
145
+ verbose: bool = False,
146
+ rich_print: bool = False,
147
+ ) -> None:
148
+ """
149
+ Clone a community app locally.
150
+
151
+ By default, the `latest` version will be used. You can
152
+ specify a version with the `version` parameter, and customize the output
153
+ directory with the `directory` parameter. If you want to list the available
154
+ apps, use the `list_community_apps` function.
155
+
156
+ You can import the `clone_community_app` function directly from `cloud`:
157
+
158
+ ```python
159
+ from nextmv.cloud import clone_community_app
160
+ ```
161
+
162
+ Parameters
163
+ ----------
164
+ client : Client
165
+ The Nextmv Cloud client to use for the request.
166
+ app : str
167
+ The name of the community app to clone.
168
+ directory : str | None, optional
169
+ The directory in which to clone the app. Default is the name of the app at current directory.
170
+ version : str | None, optional
171
+ The version of the community app to clone. Default is `latest`.
172
+ verbose : bool, optional
173
+ Whether to print verbose output.
174
+ rich_print : bool, optional
175
+ Whether to use rich printing for output messages.
176
+ """
177
+ comm_app = _find_app(client, app)
178
+
179
+ if version is not None and version == "":
180
+ raise ValueError("`version` cannot be an empty string.")
181
+
182
+ if not comm_app.has_version(version):
183
+ raise ValueError(f"Community app '{app}' does not have version '{version}'.")
184
+
185
+ original_version = version
186
+ if version == LATEST_VERSION:
187
+ version = comm_app.latest_app_version
188
+
189
+ # Clean and normalize directory path in an OS-independent way
190
+ if directory is not None and directory != "":
191
+ destination = os.path.normpath(directory)
192
+ else:
193
+ destination = app
194
+
195
+ full_destination = _get_valid_path(destination, os.stat)
196
+ os.makedirs(full_destination, exist_ok=True)
197
+
198
+ tarball = f"{app}_{version}.tar.gz"
199
+ s3_file_path = f"{app}/{version}/{tarball}"
200
+ downloaded_object = _download_object(
201
+ client=client,
202
+ file=s3_file_path,
203
+ path="community-apps",
204
+ output_dir=full_destination,
205
+ output_file=tarball,
206
+ )
207
+
208
+ # Extract the tarball to a temporary directory to handle nested structure
209
+ with tempfile.TemporaryDirectory() as temp_dir:
210
+ with tarfile.open(downloaded_object, "r:gz") as tar:
211
+ tar.extractall(path=temp_dir)
212
+
213
+ # Find the extracted directory (typically the app name)
214
+ extracted_items = os.listdir(temp_dir)
215
+ if len(extracted_items) == 1 and os.path.isdir(os.path.join(temp_dir, extracted_items[0])):
216
+ # Move contents from the extracted directory to full_destination
217
+ extracted_dir = os.path.join(temp_dir, extracted_items[0])
218
+ for item in os.listdir(extracted_dir):
219
+ shutil.move(os.path.join(extracted_dir, item), full_destination)
220
+ else:
221
+ # If structure is unexpected, move everything directly
222
+ for item in extracted_items:
223
+ shutil.move(os.path.join(temp_dir, item), full_destination)
224
+
225
+ # Remove the tarball after extraction
226
+ os.remove(downloaded_object)
227
+
228
+ if not verbose:
229
+ return
230
+
231
+ if rich_print:
232
+ rich.print(
233
+ f":white_check_mark: Successfully cloned the [magenta]{app}[/magenta] community app, "
234
+ f"using version [magenta]{original_version}[/magenta] in path: [magenta]{full_destination}[/magenta].",
235
+ file=sys.stderr,
236
+ )
237
+ return
238
+
239
+ log(
240
+ f"✅ Successfully cloned the {app} community app, using version {original_version} in path: {full_destination}."
241
+ )
242
+
243
+
244
+ def _download_manifest(client: Client) -> dict[str, Any]:
245
+ """
246
+ Downloads and returns the community apps manifest.
247
+
248
+ Parameters
249
+ ----------
250
+ client : Client
251
+ The Nextmv Cloud client to use for the request.
252
+
253
+ Returns
254
+ -------
255
+ dict[str, Any]
256
+ The community apps manifest as a dictionary.
257
+
258
+ Raises
259
+ requests.HTTPError
260
+ If the response status code is not 2xx.
261
+ """
262
+
263
+ response = _download_file(client=client, directory="community-apps", file="manifest.yml")
264
+ manifest = yaml.safe_load(response.text)
265
+
266
+ return manifest
267
+
268
+
269
+ def _download_file(
270
+ client: Client,
271
+ directory: str,
272
+ file: str,
273
+ ) -> requests.Response:
274
+ """
275
+ Gets a file from an internal bucket and return it.
276
+
277
+ Parameters
278
+ ----------
279
+ client : Client
280
+ The Nextmv Cloud client to use for the request.
281
+ directory : str
282
+ The directory in the bucket where the file is located.
283
+ file : str
284
+ The name of the file to download.
285
+
286
+ Returns
287
+ -------
288
+ requests.Response
289
+ The response object containing the file data.
290
+
291
+ Raises
292
+ requests.HTTPError
293
+ If the response status code is not 2xx.
294
+ """
295
+
296
+ # Request the download URL for the file.
297
+ response = client.request(
298
+ method="GET",
299
+ endpoint="v0/internal/tools",
300
+ headers=client.headers | {"request-source": "cli"}, # Pass `client.headers` to preserve auth.
301
+ query_params={"file": f"{directory}/{file}"},
302
+ )
303
+
304
+ # Use the URL obtained to download the file.
305
+ body = response.json()
306
+ download_response = client.request(
307
+ method="GET",
308
+ endpoint=body.get("url"),
309
+ headers={"Content-Type": "application/json"},
310
+ )
311
+
312
+ return download_response
313
+
314
+
315
+ def _download_object(client: Client, file: str, path: str, output_dir: str, output_file: str) -> str:
316
+ """
317
+ Downloads an object from the internal bucket and saves it to the specified
318
+ output directory.
319
+
320
+ Parameters
321
+ ----------
322
+ client : Client
323
+ The Nextmv Cloud client to use for the request.
324
+ file : str
325
+ The name of the file to download.
326
+ path : str
327
+ The directory in the bucket where the file is located.
328
+ output_dir : str
329
+ The local directory where the file will be saved.
330
+ output_file : str
331
+ The name of the output file.
332
+
333
+ Returns
334
+ -------
335
+ str
336
+ The path to the downloaded file.
337
+ """
338
+
339
+ response = _download_file(client=client, directory=path, file=file)
340
+ file_name = os.path.join(output_dir, output_file)
341
+
342
+ with open(file_name, "wb") as f:
343
+ f.write(response.content)
344
+
345
+ return file_name
346
+
347
+
348
+ def _get_valid_path(path: str, stat_fn: Callable[[str], os.stat_result], ending: str = "") -> str:
349
+ """
350
+ Validates and returns a non-existing path. If the path exists,
351
+ it will append a number to the path and return it. If the path does not
352
+ exist, it will return the path as is.
353
+
354
+ The ending parameter is used to check if the path ends with a specific
355
+ string. This is useful to specify if it is a file (like foo.json, in which
356
+ case the next iteration is foo-1.json) or a directory (like foo, in which
357
+ case the next iteration is foo-1).
358
+
359
+ Parameters
360
+ ----------
361
+ path : str
362
+ The initial path to validate.
363
+ stat_fn : Callable[[str], os.stat_result]
364
+ A function that takes a path and returns its stat result.
365
+ ending : str, optional
366
+ The expected ending of the path (e.g., file extension), by default "".
367
+
368
+ Returns
369
+ -------
370
+ str
371
+ A valid, non-existing path.
372
+
373
+ Raises
374
+ ------
375
+ Exception
376
+ If an unexpected error occurs during path validation
377
+ """
378
+ base_name = os.path.basename(path)
379
+ name_without_ending = base_name.removesuffix(ending) if ending else base_name
380
+
381
+ while True:
382
+ try:
383
+ stat_fn(path)
384
+ # If we get here, the path exists
385
+ # Get folder/file name number, increase it and create new path
386
+ name = os.path.basename(path)
387
+
388
+ # Get folder/file name number
389
+ parts = name.split("-")
390
+ last = parts[-1].removesuffix(ending) if ending else parts[-1]
391
+
392
+ # Save last folder name index to be changed
393
+ i = path.rfind(name)
394
+
395
+ try:
396
+ num = int(last)
397
+ # Increase number and create new path
398
+ if ending:
399
+ temp_path = path[:i] + f"{name_without_ending}-{num + 1}{ending}"
400
+ else:
401
+ temp_path = path[:i] + f"{base_name}-{num + 1}"
402
+ path = temp_path
403
+ except ValueError:
404
+ # If there is no number, add it
405
+ if ending:
406
+ temp_path = path[:i] + f"{name_without_ending}-1{ending}"
407
+ else:
408
+ temp_path = path[:i] + f"{name}-1"
409
+ path = temp_path
410
+
411
+ except FileNotFoundError:
412
+ # Path doesn't exist, we can use it
413
+ return path
414
+ except Exception as e:
415
+ # Re-raise unexpected errors
416
+ raise RuntimeError(f"An unexpected error occurred while validating the path: {path} ") from e
417
+
418
+
419
+ def _find_app(client: Client, app: str) -> CommunityApp | None:
420
+ """
421
+ Finds and returns a community app from the manifest by its name.
422
+
423
+ Parameters
424
+ ----------
425
+ client : Client
426
+ The Nextmv Cloud client to use for the request.
427
+ app : str
428
+ The name of the community app to find.
429
+
430
+ Returns
431
+ -------
432
+ CommunityApp | None
433
+ The community app if found, otherwise None.
434
+ """
435
+
436
+ comm_apps = list_community_apps(client)
437
+ for comm_app in comm_apps:
438
+ if comm_app.name == app:
439
+ return comm_app
440
+
441
+ raise ValueError(f"Community app '{app}' not found.")
nextmv/cloud/shadow.py CHANGED
@@ -19,6 +19,7 @@ ShadowTest
19
19
  """
20
20
 
21
21
  from datetime import datetime
22
+ from enum import Enum
22
23
  from typing import Any
23
24
 
24
25
  from pydantic import AliasChoices, Field
@@ -227,3 +228,27 @@ class ShadowTest(BaseModel):
227
228
  """Grouped distributional summaries of the shadow test."""
228
229
  runs: list[Run] | None = None
229
230
  """List of runs in the shadow test."""
231
+
232
+
233
+ class StopIntent(str, Enum):
234
+ """
235
+ Intent for stopping a shadow test.
236
+
237
+ You can import the `StopIntent` class directly from `cloud`:
238
+
239
+ ```python
240
+ from nextmv.cloud import StopIntent
241
+ ```
242
+
243
+ Attributes
244
+ ----------
245
+ complete : str
246
+ The test is marked as complete.
247
+ cancel : str
248
+ The test is canceled.
249
+ """
250
+
251
+ complete = "complete"
252
+ """The test is marked as complete."""
253
+ cancel = "cancel"
254
+ """The test is canceled."""
@@ -26,10 +26,12 @@ assets = create_visuals(name, input.data["radius"], input.data["distance"])
26
26
  output = nextmv.Output(
27
27
  options=options,
28
28
  solution={"message": message},
29
- metrics={
30
- "value": 1.23,
31
- "custom": {"message": message},
32
- },
29
+ statistics=nextmv.Statistics(
30
+ result=nextmv.ResultStatistics(
31
+ value=1.23,
32
+ custom={"message": message},
33
+ ),
34
+ ),
33
35
  assets=assets,
34
36
  )
35
37
  nextmv.write(output)
nextmv/local/executor.py CHANGED
@@ -22,8 +22,6 @@ process_run_information
22
22
  Function to update run metadata including duration and status.
23
23
  process_run_logs
24
24
  Function to process and save run logs.
25
- process_run_metrics
26
- Function to process and save run metrics.
27
25
  process_run_statistics
28
26
  Function to process and save run statistics.
29
27
  process_run_assets
@@ -59,16 +57,7 @@ from nextmv.local.local import (
59
57
  )
60
58
  from nextmv.local.plotly_handler import handle_plotly_visual
61
59
  from nextmv.manifest import Manifest, ManifestType
62
- from nextmv.output import (
63
- ASSETS_KEY,
64
- METRICS_KEY,
65
- OUTPUTS_KEY,
66
- SOLUTIONS_KEY,
67
- STATISTICS_KEY,
68
- Asset,
69
- OutputFormat,
70
- VisualSchema,
71
- )
60
+ from nextmv.output import ASSETS_KEY, OUTPUTS_KEY, SOLUTIONS_KEY, STATISTICS_KEY, Asset, OutputFormat, VisualSchema
72
61
  from nextmv.status import StatusV2
73
62
 
74
63
 
@@ -316,7 +305,7 @@ def process_run_output(
316
305
  ) -> None:
317
306
  """
318
307
  Processes the result of the subprocess run. This function is in charge of
319
- handling the run results, including solutions, statistics, metrics, logs, assets,
308
+ handling the run results, including solutions, statistics, logs, assets,
320
309
  and visuals.
321
310
 
322
311
  Parameters
@@ -358,13 +347,6 @@ def process_run_output(
358
347
  result=result,
359
348
  stdout_output=stdout_output,
360
349
  )
361
- process_run_metrics(
362
- temp_run_outputs_dir=temp_run_outputs_dir,
363
- outputs_dir=outputs_dir,
364
- stdout_output=stdout_output,
365
- temp_src=temp_src,
366
- manifest=manifest,
367
- )
368
350
  process_run_statistics(
369
351
  temp_run_outputs_dir=temp_run_outputs_dir,
370
352
  outputs_dir=outputs_dir,
@@ -517,65 +499,6 @@ def process_run_logs(
517
499
 
518
500
  f.write(std_err)
519
501
 
520
- def process_run_metrics(
521
- temp_run_outputs_dir: str,
522
- outputs_dir: str,
523
- stdout_output: str | dict[str, Any],
524
- temp_src: str,
525
- manifest: Manifest,
526
- ) -> None:
527
- """
528
- Processes the metrics of the run. Checks for an outputs/metrics folder
529
- or custom metrics file location from manifest. If found, copies to run
530
- directory. Otherwise, attempts to extract metrics from stdout.
531
-
532
- Parameters
533
- ----------
534
- temp_run_outputs_dir : str
535
- The path to the temporary outputs directory.
536
- outputs_dir : str
537
- The path to the outputs directory in the run directory.
538
- stdout_output : Union[str, dict[str, Any]]
539
- The stdout output of the run, either as raw string or parsed dictionary.
540
- temp_src : str
541
- The path to the temporary source directory.
542
- manifest : Manifest
543
- The application manifest containing configuration and custom paths.
544
- """
545
-
546
- metrics_dst = os.path.join(outputs_dir, METRICS_KEY)
547
- os.makedirs(metrics_dst, exist_ok=True)
548
- metrics_file = f"{METRICS_KEY}.json"
549
-
550
- # Check for custom location in manifest and override metrics_src if needed.
551
- if (
552
- manifest.configuration is not None
553
- and manifest.configuration.content is not None
554
- and manifest.configuration.content.format == OutputFormat.MULTI_FILE
555
- and manifest.configuration.content.multi_file is not None
556
- ):
557
- metrics_src_file = os.path.join(temp_src, manifest.configuration.content.multi_file.output.metrics)
558
-
559
- # If the custom metrics file exists, copy it to the metrics destination
560
- if os.path.exists(metrics_src_file) and os.path.isfile(metrics_src_file):
561
- metrics_dst_file = os.path.join(metrics_dst, metrics_file)
562
- shutil.copy2(metrics_src_file, metrics_dst_file)
563
- return
564
-
565
- metrics_src = os.path.join(temp_run_outputs_dir, METRICS_KEY)
566
- if os.path.exists(metrics_src) and os.path.isdir(metrics_src):
567
- shutil.copytree(metrics_src, metrics_dst, dirs_exist_ok=True)
568
- return
569
-
570
- if not isinstance(stdout_output, dict):
571
- return
572
-
573
- if METRICS_KEY not in stdout_output:
574
- return
575
-
576
- with open(os.path.join(metrics_dst, metrics_file), "w") as f:
577
- metrics = {METRICS_KEY: stdout_output[METRICS_KEY]}
578
- json.dump(metrics, f, indent=2)
579
502
 
580
503
  def process_run_statistics(
581
504
  temp_run_outputs_dir: str,
@@ -585,9 +508,6 @@ def process_run_statistics(
585
508
  manifest: Manifest,
586
509
  ) -> None:
587
510
  """
588
- !!! warning
589
- `process_run_statistics` is deprecated, use `process_run_metrics` instead.
590
-
591
511
  Processes the statistics of the run. Checks for an outputs/statistics folder
592
512
  or custom statistics file location from manifest. If found, copies to run
593
513
  directory. Otherwise, attempts to extract statistics from stdout.
@@ -928,7 +848,7 @@ def _copy_new_or_modified_files( # noqa: C901
928
848
  This function identifies files that are either new (not present in the original
929
849
  source) or have been modified (different content, checksum, or modification time)
930
850
  compared to the original source. It excludes files that exist in specified
931
- exclusion directories to avoid copying input data, statistics, metrics, or assets as
851
+ exclusion directories to avoid copying input data, statistics, or assets as
932
852
  solution outputs.
933
853
 
934
854
  Parameters
@@ -111,7 +111,7 @@ def extract_coordinates(coords, all_coords) -> None:
111
111
  like Polygons and MultiPolygons
112
112
  """
113
113
  if isinstance(coords, list):
114
- if len(coords) == 2 and isinstance(coords[0], int | float) and isinstance(coords[1], int | float):
114
+ if len(coords) == 2 and isinstance(coords[0], (int, float)) and isinstance(coords[1], (int, float)):
115
115
  # This is a coordinate pair [lon, lat]
116
116
  all_coords.append(coords)
117
117
  else: