nextmv 1.0.0.dev3__py3-none-any.whl → 1.0.0.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. nextmv/__about__.py +1 -1
  2. nextmv/__entrypoint__.py +1 -2
  3. nextmv/__init__.py +0 -4
  4. nextmv/_serialization.py +1 -1
  5. nextmv/cli/CONTRIBUTING.md +81 -29
  6. nextmv/cli/cloud/acceptance/create.py +24 -26
  7. nextmv/cli/cloud/acceptance/delete.py +7 -8
  8. nextmv/cli/cloud/acceptance/get.py +9 -10
  9. nextmv/cli/cloud/acceptance/list.py +3 -3
  10. nextmv/cli/cloud/acceptance/update.py +6 -6
  11. nextmv/cli/cloud/account/__init__.py +3 -3
  12. nextmv/cli/cloud/account/create.py +11 -11
  13. nextmv/cli/cloud/account/delete.py +6 -7
  14. nextmv/cli/cloud/account/get.py +3 -3
  15. nextmv/cli/cloud/account/update.py +5 -5
  16. nextmv/cli/cloud/app/create.py +25 -26
  17. nextmv/cli/cloud/app/delete.py +5 -6
  18. nextmv/cli/cloud/app/exists.py +2 -2
  19. nextmv/cli/cloud/app/get.py +2 -2
  20. nextmv/cli/cloud/app/list.py +3 -3
  21. nextmv/cli/cloud/app/push.py +368 -54
  22. nextmv/cli/cloud/app/update.py +12 -12
  23. nextmv/cli/cloud/batch/create.py +26 -28
  24. nextmv/cli/cloud/batch/delete.py +5 -6
  25. nextmv/cli/cloud/batch/get.py +8 -8
  26. nextmv/cli/cloud/batch/list.py +3 -3
  27. nextmv/cli/cloud/batch/metadata.py +4 -4
  28. nextmv/cli/cloud/batch/update.py +6 -6
  29. nextmv/cli/cloud/data/__init__.py +1 -1
  30. nextmv/cli/cloud/data/upload.py +15 -15
  31. nextmv/cli/cloud/ensemble/__init__.py +2 -0
  32. nextmv/cli/cloud/ensemble/create.py +21 -22
  33. nextmv/cli/cloud/ensemble/delete.py +5 -6
  34. nextmv/cli/cloud/ensemble/get.py +4 -4
  35. nextmv/cli/cloud/ensemble/list.py +63 -0
  36. nextmv/cli/cloud/ensemble/update.py +9 -9
  37. nextmv/cli/cloud/input_set/create.py +20 -22
  38. nextmv/cli/cloud/input_set/get.py +3 -3
  39. nextmv/cli/cloud/input_set/list.py +3 -3
  40. nextmv/cli/cloud/input_set/update.py +24 -24
  41. nextmv/cli/cloud/instance/create.py +14 -15
  42. nextmv/cli/cloud/instance/delete.py +5 -6
  43. nextmv/cli/cloud/instance/exists.py +2 -2
  44. nextmv/cli/cloud/instance/get.py +2 -2
  45. nextmv/cli/cloud/instance/list.py +3 -3
  46. nextmv/cli/cloud/instance/update.py +14 -14
  47. nextmv/cli/cloud/managed_input/create.py +14 -16
  48. nextmv/cli/cloud/managed_input/delete.py +6 -7
  49. nextmv/cli/cloud/managed_input/get.py +3 -3
  50. nextmv/cli/cloud/managed_input/list.py +3 -3
  51. nextmv/cli/cloud/managed_input/update.py +9 -9
  52. nextmv/cli/cloud/run/cancel.py +2 -2
  53. nextmv/cli/cloud/run/create.py +32 -33
  54. nextmv/cli/cloud/run/get.py +8 -8
  55. nextmv/cli/cloud/run/input.py +4 -4
  56. nextmv/cli/cloud/run/list.py +6 -6
  57. nextmv/cli/cloud/run/logs.py +9 -10
  58. nextmv/cli/cloud/run/metadata.py +4 -4
  59. nextmv/cli/cloud/run/track.py +32 -33
  60. nextmv/cli/cloud/scenario/create.py +21 -21
  61. nextmv/cli/cloud/scenario/delete.py +5 -6
  62. nextmv/cli/cloud/scenario/get.py +8 -8
  63. nextmv/cli/cloud/scenario/list.py +3 -3
  64. nextmv/cli/cloud/scenario/metadata.py +4 -4
  65. nextmv/cli/cloud/scenario/update.py +6 -6
  66. nextmv/cli/cloud/secrets/create.py +17 -17
  67. nextmv/cli/cloud/secrets/delete.py +5 -6
  68. nextmv/cli/cloud/secrets/get.py +4 -4
  69. nextmv/cli/cloud/secrets/list.py +3 -3
  70. nextmv/cli/cloud/secrets/update.py +17 -20
  71. nextmv/cli/cloud/shadow/create.py +31 -31
  72. nextmv/cli/cloud/shadow/delete.py +5 -6
  73. nextmv/cli/cloud/shadow/get.py +2 -2
  74. nextmv/cli/cloud/shadow/list.py +3 -3
  75. nextmv/cli/cloud/shadow/metadata.py +4 -4
  76. nextmv/cli/cloud/shadow/start.py +3 -3
  77. nextmv/cli/cloud/shadow/stop.py +4 -6
  78. nextmv/cli/cloud/shadow/update.py +6 -6
  79. nextmv/cli/cloud/switchback/create.py +19 -15
  80. nextmv/cli/cloud/switchback/delete.py +5 -6
  81. nextmv/cli/cloud/switchback/get.py +3 -3
  82. nextmv/cli/cloud/switchback/list.py +3 -3
  83. nextmv/cli/cloud/switchback/metadata.py +6 -6
  84. nextmv/cli/cloud/switchback/start.py +4 -4
  85. nextmv/cli/cloud/switchback/stop.py +4 -6
  86. nextmv/cli/cloud/switchback/update.py +6 -6
  87. nextmv/cli/cloud/upload/create.py +2 -2
  88. nextmv/cli/cloud/version/create.py +9 -10
  89. nextmv/cli/cloud/version/delete.py +5 -6
  90. nextmv/cli/cloud/version/exists.py +2 -2
  91. nextmv/cli/cloud/version/get.py +2 -2
  92. nextmv/cli/cloud/version/list.py +3 -3
  93. nextmv/cli/cloud/version/update.py +8 -8
  94. nextmv/cli/community/clone.py +12 -10
  95. nextmv/cli/community/list.py +9 -9
  96. nextmv/cli/configuration/config.py +43 -10
  97. nextmv/cli/configuration/create.py +3 -3
  98. nextmv/cli/configuration/delete.py +7 -7
  99. nextmv/cli/configuration/list.py +3 -3
  100. nextmv/cli/confirm.py +34 -0
  101. nextmv/cli/main.py +27 -36
  102. nextmv/cli/message.py +2 -2
  103. nextmv/cli/version.py +1 -1
  104. nextmv/cloud/__init__.py +0 -38
  105. nextmv/cloud/acceptance_test.py +1 -65
  106. nextmv/cloud/account.py +1 -6
  107. nextmv/cloud/application/__init__.py +192 -54
  108. nextmv/cloud/application/_batch_scenario.py +4 -19
  109. nextmv/cloud/application/_instance.py +3 -3
  110. nextmv/cloud/application/_managed_input.py +2 -2
  111. nextmv/cloud/application/_run.py +8 -1
  112. nextmv/cloud/application/_shadow.py +2 -2
  113. nextmv/cloud/application/_switchback.py +12 -4
  114. nextmv/cloud/application/_version.py +4 -3
  115. nextmv/cloud/client.py +1 -1
  116. nextmv/cloud/shadow.py +43 -4
  117. nextmv/cloud/switchback.py +46 -9
  118. nextmv/default_app/main.py +4 -6
  119. nextmv/deprecated.py +5 -3
  120. nextmv/input.py +0 -52
  121. nextmv/local/executor.py +83 -3
  122. nextmv/local/geojson_handler.py +1 -1
  123. nextmv/local/runner.py +1 -1
  124. nextmv/manifest.py +11 -7
  125. nextmv/model.py +2 -2
  126. nextmv/options.py +10 -255
  127. nextmv/output.py +57 -83
  128. nextmv/run.py +13 -13
  129. nextmv/status.py +1 -51
  130. {nextmv-1.0.0.dev3.dist-info → nextmv-1.0.0.dev5.dist-info}/METADATA +1 -1
  131. nextmv-1.0.0.dev5.dist-info/RECORD +183 -0
  132. nextmv-1.0.0.dev3.dist-info/RECORD +0 -181
  133. {nextmv-1.0.0.dev3.dist-info → nextmv-1.0.0.dev5.dist-info}/WHEEL +0 -0
  134. {nextmv-1.0.0.dev3.dist-info → nextmv-1.0.0.dev5.dist-info}/entry_points.txt +0 -0
  135. {nextmv-1.0.0.dev3.dist-info → nextmv-1.0.0.dev5.dist-info}/licenses/LICENSE +0 -0
@@ -46,7 +46,9 @@ from nextmv.cloud.application._switchback import ApplicationSwitchbackMixin
46
46
  from nextmv.cloud.application._utils import _is_not_exist_error
47
47
  from nextmv.cloud.application._version import ApplicationVersionMixin
48
48
  from nextmv.cloud.client import Client
49
+ from nextmv.cloud.instance import Instance
49
50
  from nextmv.cloud.url import UploadURL
51
+ from nextmv.cloud.version import Version
50
52
  from nextmv.logger import log
51
53
  from nextmv.manifest import Manifest
52
54
  from nextmv.model import Model, ModelConfiguration
@@ -250,7 +252,7 @@ class Application(
250
252
  def new(
251
253
  cls,
252
254
  client: Client,
253
- name: str,
255
+ name: str | None = None,
254
256
  id: str | None = None,
255
257
  description: str | None = None,
256
258
  is_workflow: bool | None = None,
@@ -268,13 +270,13 @@ class Application(
268
270
  ----------
269
271
  client : Client
270
272
  Client to use for interacting with the Nextmv Cloud API.
271
- name : str
272
- Name of the application.
273
- id : str, optional
273
+ name : str | None = None
274
+ Name of the application. Uses the ID as the name if not provided.
275
+ id : str | None = None
274
276
  ID of the application. Will be generated if not provided.
275
- description : str, optional
277
+ description : str | None = None
276
278
  Description of the application.
277
- is_workflow : bool, optional
279
+ is_workflow : bool | None = None
278
280
  Whether the application is a Decision Workflow.
279
281
  exist_ok : bool, default=False
280
282
  If True and an application with the same ID already exists,
@@ -296,7 +298,10 @@ class Application(
296
298
  >>> app = Application.new(client=client, name="My New App", id="my-app")
297
299
  """
298
300
 
299
- if id is None:
301
+ if exist_ok and (id is None or id == ""):
302
+ raise ValueError("If exist_ok is True, id must be provided")
303
+
304
+ if id is None or id == "":
300
305
  id = safe_id("app")
301
306
 
302
307
  if exist_ok and cls.exists(client=client, id=id):
@@ -307,6 +312,9 @@ class Application(
307
312
 
308
313
  return cls.from_dict({"client": client} | response.json())
309
314
 
315
+ if name is None or name == "":
316
+ name = id
317
+
310
318
  payload = {
311
319
  "name": name,
312
320
  "id": id,
@@ -399,10 +407,14 @@ class Application(
399
407
  model: Model | None = None,
400
408
  model_configuration: ModelConfiguration | None = None,
401
409
  rich_print: bool = False,
402
- no_version: bool = False,
410
+ auto_create: bool = False,
403
411
  version_id: str | None = None,
404
412
  version_name: str | None = None,
405
413
  version_description: str | None = None,
414
+ instance_id: str | None = None,
415
+ instance_name: str | None = None,
416
+ instance_description: str | None = None,
417
+ update_default_instance: bool = False,
406
418
  ) -> None:
407
419
  """
408
420
  Push an app to Nextmv Cloud.
@@ -420,14 +432,16 @@ class Application(
420
432
  `nextmv.Model`. The model is encoded, some dependencies and
421
433
  accompanying files are packaged, and the app is pushed to Nextmv Cloud.
422
434
 
423
- The default behavior of this function is to create a new application
424
- version _after_ the app has been pushed. You can set the `no_version`
425
- argument to `True` to skip this step. The `version_id`, `version_name`,
426
- and `version_description` arguments can be used to customize the version
427
- that is created. If the `version_id` is not specified, a randomly
428
- generated ID will be used. If the `version_name` is not specified, a
429
- generic name with a timestamp will be used. Lastly, if no description is
430
- specified, then a generic description will also be used.
435
+ By default, this function only pushes the app. If you want to
436
+ automatically create a new version and instance after pushing, set
437
+ `auto_create=True`. The `version_id`, `version_name`, and
438
+ `version_description` arguments allow you to customize the new version
439
+ that is created. If not specified, defaults will be generated (random
440
+ ID, timestamped name/description). Similarly, `instance_id`,
441
+ `instance_name`, and `instance_description` can be used to customize
442
+ the new instance. If `update_default_instance` is True, the
443
+ application's default instance will be updated to the newly created
444
+ instance.
431
445
 
432
446
  Parameters
433
447
  ----------
@@ -447,21 +461,30 @@ class Application(
447
461
  with `model`.
448
462
  rich_print : bool, default=False
449
463
  Whether to use rich printing when verbose output is enabled.
450
- no_version : bool, default=False
451
- If True, do not create a new version after pushing the app.
464
+ auto_create : bool, default=False
465
+ If True, automatically create a new version and instance after
466
+ pushing the app.
452
467
  version_id : Optional[str], default=None
453
468
  ID of the version to create after pushing the app. If None, a unique
454
469
  ID will be generated.
455
470
  version_name : Optional[str], default=None
456
- Name of the version to create after pushing the app. If None, a name
457
- with a timestamp will be generated.
471
+ Name of the version to create after pushing the app. If None, a
472
+ name will be generated.
458
473
  version_description : Optional[str], default=None
459
474
  Description of the version to create after pushing the app. If None, a
460
475
  generic description with a timestamp will be generated.
461
-
462
- Returns
463
- -------
464
- None
476
+ instance_id : Optional[str], default=None
477
+ ID of the instance to create after pushing the app. If None, a unique
478
+ ID will be generated.
479
+ instance_name : Optional[str], default=None
480
+ Name of the instance to create after pushing the app. If None, a
481
+ name will be generated.
482
+ instance_description : Optional[str], default=None
483
+ Description of the instance to create after pushing the app. If None,
484
+ a generic description with a timestamp will be generated.
485
+ update_default_instance : bool, default=False
486
+ If True, update the application's default instance to the newly
487
+ created instance.
465
488
 
466
489
  Raises
467
490
  ------
@@ -565,44 +588,43 @@ class Application(
565
588
  except OSError as e:
566
589
  raise Exception(f"error deleting output directory: {e}") from e
567
590
 
568
- if no_version:
569
- if verbose:
570
- if rich_print:
571
- rich.print(
572
- f":white_check_mark: Push completed for Nextmv application [magenta]{self.id}[/magenta] "
573
- "without creating a new version.",
574
- file=sys.stderr,
575
- )
576
- else:
577
- log("✅ Push completed without creating a new version for Nextmv application.")
578
-
591
+ if not auto_create:
579
592
  return
580
593
 
581
- now = datetime.now(timezone.utc)
582
- if version_id is None:
583
- version_id = safe_id(prefix="version") + f"-{now.strftime('%Y%m%d-%H%M%S')}"
584
- if version_name is None:
585
- version_name = f"Version {version_id}"
586
- if version_description is None:
587
- version_description = f"Version created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
588
-
589
- version = self.new_version(
590
- id=version_id,
591
- name=version_name,
592
- description=version_description,
593
- )
594
- version_dict = version.to_dict()
595
-
596
594
  if verbose:
597
595
  if rich_print:
598
596
  rich.print(
599
- f":white_check_mark: Automatically created new version [magenta]{version.id}[/magenta].",
597
+ f":hourglass_flowing_sand: Push completed for Nextmv application [magenta]{self.id}[/magenta], "
598
+ "creating a new version and instance...",
600
599
  file=sys.stderr,
601
600
  )
602
- rich.print_json(data=version_dict)
603
601
  else:
604
- log(f'✅ Automatically created new version "{version.id}".')
605
- log(json.dumps(version_dict, indent=2))
602
+ log("⌛️ Push completed for the Nextmv application, creating a new version and instance...")
603
+
604
+ now = datetime.now(timezone.utc)
605
+ version = self.__version_on_push(
606
+ now=now,
607
+ version_id=version_id,
608
+ version_name=version_name,
609
+ version_description=version_description,
610
+ verbose=verbose,
611
+ rich_print=rich_print,
612
+ )
613
+ instance = self.__instance_on_push(
614
+ now=now,
615
+ version_id=version.id,
616
+ instance_id=instance_id,
617
+ instance_name=instance_name,
618
+ instance_description=instance_description,
619
+ verbose=verbose,
620
+ rich_print=rich_print,
621
+ )
622
+ self.__update_on_push(
623
+ instance=instance,
624
+ update_default_instance=update_default_instance,
625
+ verbose=verbose,
626
+ rich_print=rich_print,
627
+ )
606
628
 
607
629
  def update(
608
630
  self,
@@ -845,6 +867,8 @@ class Application(
845
867
  output_config = multi_config["output_configuration"] = {}
846
868
  if content.multi_file.output.statistics:
847
869
  output_config["statistics_path"] = content.multi_file.output.statistics
870
+ if content.multi_file.output.metrics:
871
+ output_config["metrics_path"] = content.multi_file.output.metrics
848
872
  if content.multi_file.output.assets:
849
873
  output_config["assets_path"] = content.multi_file.output.assets
850
874
  if content.multi_file.output.solutions:
@@ -919,6 +943,120 @@ class Application(
919
943
  log(f'💥️ Successfully pushed to application: "{self.id}".')
920
944
  log(json.dumps(data, indent=2))
921
945
 
946
+ def __version_on_push(
947
+ self,
948
+ now: datetime,
949
+ version_id: str | None = None,
950
+ version_name: str | None = None,
951
+ version_description: str | None = None,
952
+ verbose: bool = False,
953
+ rich_print: bool = False,
954
+ ) -> Version:
955
+ if version_description is None or version_description == "":
956
+ version_description = f"Version created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
957
+
958
+ version = self.new_version(
959
+ id=version_id,
960
+ name=version_name,
961
+ description=version_description,
962
+ )
963
+ version_dict = version.to_dict()
964
+
965
+ if not verbose:
966
+ return version
967
+
968
+ if rich_print:
969
+ rich.print(
970
+ f":white_check_mark: Automatically created new version [magenta]{version.id}[/magenta].",
971
+ file=sys.stderr,
972
+ )
973
+ rich.print_json(data=version_dict)
974
+
975
+ return version
976
+
977
+ log(f'✅ Automatically created new version "{version.id}".')
978
+ log(json.dumps(version_dict, indent=2))
979
+
980
+ return version
981
+
982
+ def __instance_on_push(
983
+ self,
984
+ now: datetime,
985
+ version_id: str,
986
+ instance_id: str | None = None,
987
+ instance_name: str | None = None,
988
+ instance_description: str | None = None,
989
+ verbose: bool = False,
990
+ rich_print: bool = False,
991
+ ) -> Instance:
992
+ if instance_description is None or instance_description == "":
993
+ instance_description = f"Instance created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
994
+
995
+ instance = self.new_instance(
996
+ version_id=version_id,
997
+ id=instance_id,
998
+ name=instance_name,
999
+ description=instance_description,
1000
+ )
1001
+ instance_dict = instance.to_dict()
1002
+
1003
+ if not verbose:
1004
+ return instance
1005
+
1006
+ if rich_print:
1007
+ rich.print(
1008
+ f":white_check_mark: Automatically created new instance [magenta]{instance.id}[/magenta] "
1009
+ f"using version [magenta]{version_id}[/magenta].",
1010
+ file=sys.stderr,
1011
+ )
1012
+ rich.print_json(data=instance_dict)
1013
+
1014
+ return instance
1015
+
1016
+ log(f'✅ Automatically created new instance "{instance.id}" using version "{version_id}".')
1017
+ log(json.dumps(instance_dict, indent=2))
1018
+
1019
+ return instance
1020
+
1021
+ def __update_on_push(
1022
+ self,
1023
+ instance: Instance,
1024
+ update_default_instance: bool = False,
1025
+ verbose: bool = False,
1026
+ rich_print: bool = False,
1027
+ ) -> None:
1028
+ if not update_default_instance:
1029
+ return
1030
+
1031
+ if verbose:
1032
+ if rich_print:
1033
+ rich.print(
1034
+ f":hourglass_flowing_sand: Updating default instance for app [magenta]{self.id}[/magenta]...",
1035
+ file=sys.stderr,
1036
+ )
1037
+ else:
1038
+ log("⌛️ Updating default instance for the Nextmv application...")
1039
+
1040
+ updated_app = self.update(default_instance_id=instance.id)
1041
+ if not verbose:
1042
+ return
1043
+
1044
+ if rich_print:
1045
+ rich.print(
1046
+ f":white_check_mark: Updated default instance to "
1047
+ f"[magenta]{updated_app.default_instance_id}[/magenta] for application "
1048
+ f"[magenta]{self.id}[/magenta].",
1049
+ file=sys.stderr,
1050
+ )
1051
+ rich.print_json(data=updated_app.to_dict())
1052
+
1053
+ return
1054
+
1055
+ log(
1056
+ f'✅ Updated default instance to "{updated_app.default_instance_id}" for application "{self.id}".',
1057
+ )
1058
+ log(json.dumps(updated_app.to_dict(), indent=2))
1059
+
922
1060
 
923
1061
  def list_applications(client: Client) -> list[Application]:
924
1062
  """
@@ -10,7 +10,6 @@ from nextmv.cloud.batch_experiment import (
10
10
  BatchExperimentMetadata,
11
11
  BatchExperimentRun,
12
12
  ExperimentStatus,
13
- to_runs,
14
13
  )
15
14
  from nextmv.cloud.input_set import InputSet, ManagedInput
16
15
  from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
@@ -258,7 +257,6 @@ class ApplicationBatchMixin:
258
257
  self: "Application",
259
258
  name: str | None = None,
260
259
  input_set_id: str | None = None,
261
- instance_ids: list[str] | None = None,
262
260
  description: str | None = None,
263
261
  id: str | None = None,
264
262
  option_sets: dict[str, dict[str, str]] | None = None,
@@ -274,9 +272,6 @@ class ApplicationBatchMixin:
274
272
  Name of the batch experiment. If not provided, the ID will be used as the name.
275
273
  input_set_id: str | None
276
274
  ID of the input set to use for the batch experiment.
277
- instance_ids: list[str]
278
- This argument is deprecated, use `runs` instead.
279
- List of instance IDs to use for the batch experiment.
280
275
  description: Optional[str]
281
276
  Optional description of the batch experiment.
282
277
  id: Optional[str]
@@ -304,11 +299,11 @@ class ApplicationBatchMixin:
304
299
  """
305
300
 
306
301
  # Generate ID if not provided
307
- if id is None:
302
+ if id is None or id == "":
308
303
  id = safe_id("batch")
309
304
 
310
305
  # Use ID as name if name not provided
311
- if name is None:
306
+ if name is None or name == "":
312
307
  name = id
313
308
 
314
309
  payload = {
@@ -317,11 +312,6 @@ class ApplicationBatchMixin:
317
312
  }
318
313
  if input_set_id is not None:
319
314
  payload["input_set_id"] = input_set_id
320
- if instance_ids is not None:
321
- input_set = self.input_set(input_set_id)
322
- runs = to_runs(instance_ids, input_set)
323
- payload_runs = [run.to_dict() for run in runs]
324
- payload["runs"] = payload_runs
325
315
  if description is not None:
326
316
  payload["description"] = description
327
317
  if option_sets is not None:
@@ -346,7 +336,6 @@ class ApplicationBatchMixin:
346
336
  self: "Application",
347
337
  name: str | None = None,
348
338
  input_set_id: str | None = None,
349
- instance_ids: list[str] | None = None,
350
339
  description: str | None = None,
351
340
  id: str | None = None,
352
341
  option_sets: dict[str, dict[str, str]] | None = None,
@@ -368,9 +357,6 @@ class ApplicationBatchMixin:
368
357
  Name of the batch experiment. If not provided, the ID will be used as the name.
369
358
  input_set_id: str
370
359
  ID of the input set to use for the batch experiment.
371
- instance_ids: list[str]
372
- List of instance IDs to use for the batch experiment. This argument
373
- is deprecated, use `runs` instead.
374
360
  description: Optional[str]
375
361
  Optional description of the batch experiment.
376
362
  id: Optional[str]
@@ -402,7 +388,6 @@ class ApplicationBatchMixin:
402
388
  batch_id = self.new_batch_experiment(
403
389
  name=name,
404
390
  input_set_id=input_set_id,
405
- instance_ids=instance_ids,
406
391
  description=description,
407
392
  id=id,
408
393
  option_sets=option_sets,
@@ -481,11 +466,11 @@ class ApplicationBatchMixin:
481
466
  raise ValueError("At least one scenario must be provided")
482
467
 
483
468
  # Generate ID if not provided
484
- if id is None:
469
+ if id is None or id == "":
485
470
  id = safe_id("scenario")
486
471
 
487
472
  # Use ID as name if name not provided
488
- if name is None:
473
+ if name is None or name == "":
489
474
  name = id
490
475
 
491
476
  scenarios_by_id = _scenarios_by_id(scenarios)
@@ -194,15 +194,15 @@ class ApplicationInstanceMixin:
194
194
  'Production Instance'
195
195
  """
196
196
 
197
- if exist_ok and id is None:
197
+ if exist_ok and (id is None or id == ""):
198
198
  raise ValueError("If exist_ok is True, id must be provided")
199
199
 
200
200
  if exist_ok and self.instance_exists(instance_id=id):
201
201
  return self.instance(instance_id=id)
202
202
 
203
- if id is None:
203
+ if id is None or id == "":
204
204
  id = safe_id(prefix="instance")
205
- if name is None:
205
+ if name is None or name == "":
206
206
  name = id
207
207
 
208
208
  payload = {
@@ -147,9 +147,9 @@ class ApplicationManagedInputMixin:
147
147
  if upload_id is None and run_id is None:
148
148
  raise ValueError("Either upload_id or run_id must be specified")
149
149
 
150
- if id is None:
150
+ if id is None or id == "":
151
151
  id = safe_id(prefix="managed-input")
152
- if name is None:
152
+ if name is None or name == "":
153
153
  name = id
154
154
 
155
155
  payload = {
@@ -21,7 +21,14 @@ from nextmv.cloud.url import DownloadURL
21
21
  from nextmv.input import Input, InputFormat
22
22
  from nextmv.logger import log
23
23
  from nextmv.options import Options
24
- from nextmv.output import ASSETS_KEY, STATISTICS_KEY, Asset, Output, OutputFormat, Statistics
24
+ from nextmv.output import (
25
+ ASSETS_KEY,
26
+ STATISTICS_KEY,
27
+ Asset,
28
+ Output,
29
+ OutputFormat,
30
+ Statistics,
31
+ )
25
32
  from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
26
33
  from nextmv.run import (
27
34
  ExternalRunResult,
@@ -204,11 +204,11 @@ class ApplicationShadowMixin:
204
204
  shadow_test_id = safe_id("shadow")
205
205
 
206
206
  # Use ID as name if name not provided
207
- if name is None:
207
+ if name is None or name == "":
208
208
  name = shadow_test_id
209
209
 
210
210
  payload = {
211
- "id": id,
211
+ "id": shadow_test_id,
212
212
  "name": name,
213
213
  "comparisons": comparisons,
214
214
  "termination_events": termination_events.to_dict(),
@@ -172,9 +172,11 @@ class ApplicationSwitchbackMixin:
172
172
  comparison : TestComparisonSingle
173
173
  Comparison defining the baseline and candidate instances.
174
174
  unit_duration_minutes : float
175
- Duration of each interval in minutes.
175
+ Duration of each interval in minutes. The value must be between 1
176
+ and 10080.
176
177
  units : int
177
- Total number of intervals in the switchback test.
178
+ Total number of intervals in the switchback test. The value must be
179
+ between 1 and 1000.
178
180
  switchback_test_id : Optional[str], default=None
179
181
  Optional ID for the switchback test. Will be generated if not
180
182
  provided.
@@ -197,16 +199,22 @@ class ApplicationSwitchbackMixin:
197
199
  If the response status code is not 2xx.
198
200
  """
199
201
 
202
+ if unit_duration_minutes < 1 or unit_duration_minutes > 10080:
203
+ raise ValueError("unit_duration_minutes must be between 1 and 10080")
204
+
205
+ if units < 1 or units > 1000:
206
+ raise ValueError("units must be between 1 and 1000")
207
+
200
208
  # Generate ID if not provided
201
209
  if switchback_test_id is None:
202
210
  switchback_test_id = safe_id("switchback")
203
211
 
204
212
  # Use ID as name if name not provided
205
- if name is None:
213
+ if name is None or name == "":
206
214
  name = switchback_test_id
207
215
 
208
216
  payload = {
209
- "id": id,
217
+ "id": switchback_test_id,
210
218
  "name": name,
211
219
  "comparison": comparison,
212
220
  "generate_random_plan": {
@@ -132,15 +132,16 @@ class ApplicationVersionMixin:
132
132
  ... )
133
133
  """
134
134
 
135
- if exist_ok and id is None:
135
+ if exist_ok and (id is None or id == ""):
136
136
  raise ValueError("If exist_ok is True, id must be provided")
137
137
 
138
138
  if exist_ok and self.version_exists(version_id=id):
139
139
  return self.version(version_id=id)
140
140
 
141
- if id is None:
141
+ if id is None or id == "":
142
142
  id = safe_id(prefix="version")
143
- if name is None:
143
+
144
+ if name is None or name == "":
144
145
  name = id
145
146
 
146
147
  payload = {
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:
nextmv/cloud/shadow.py CHANGED
@@ -90,7 +90,7 @@ class TerminationEvents(BaseModel):
90
90
 
91
91
  maximum_runs: int
92
92
  """
93
- Maximum number of runs for the test. Min should be 1, max should be 300.
93
+ Maximum number of runs for the test. Value must be between 1 and 300.
94
94
  """
95
95
  time: datetime | None = None
96
96
  """
@@ -99,8 +99,8 @@ class TerminationEvents(BaseModel):
99
99
  """
100
100
 
101
101
  def model_post_init(self, __context):
102
- if self.maximum_runs < 1:
103
- raise ValueError("maximum_runs must be at least 1")
102
+ if self.maximum_runs < 1 or self.maximum_runs > 300:
103
+ raise ValueError("maximum_runs must be between 1 and 300")
104
104
 
105
105
 
106
106
  class ShadowTestMetadata(BaseModel):
@@ -151,7 +151,10 @@ class ShadowTestMetadata(BaseModel):
151
151
  """The current status of the shadow test."""
152
152
 
153
153
 
154
- class ShadowTest(ShadowTestMetadata):
154
+ # This class uses some fields defined in ShadowTestMetadata. We are not
155
+ # using inheritance to help the user understand the full structure when using
156
+ # tools like intellisense.
157
+ class ShadowTest(BaseModel):
155
158
  """
156
159
  A Nextmv Cloud shadow test definition.
157
160
 
@@ -166,6 +169,20 @@ class ShadowTest(ShadowTestMetadata):
166
169
 
167
170
  Parameters
168
171
  ----------
172
+ shadow_test_id : str, optional
173
+ The unique identifier of the shadow test.
174
+ name : str, optional
175
+ Name of the shadow test.
176
+ description : str, optional
177
+ Description of the shadow test.
178
+ app_id : str, optional
179
+ ID of the application to which the shadow test belongs.
180
+ created_at : datetime, optional
181
+ Creation date of the shadow test.
182
+ updated_at : datetime, optional
183
+ Last update date of the shadow test.
184
+ status : ExperimentStatus, optional
185
+ The current status of the shadow test.
169
186
  completed_at : datetime, optional
170
187
  Completion date of the shadow test, if applicable.
171
188
  comparisons : list[TestComparison], optional
@@ -174,8 +191,30 @@ class ShadowTest(ShadowTestMetadata):
174
191
  Start events for the shadow test.
175
192
  termination_events : TerminationEvents, optional
176
193
  Termination events for the shadow test.
194
+ grouped_distributional_summaries : list[dict[str, Any]], optional
195
+ Grouped distributional summaries of the shadow test.
196
+ runs : list[Run], optional
197
+ List of runs in the shadow test.
177
198
  """
178
199
 
200
+ shadow_test_id: str | None = Field(
201
+ serialization_alias="id",
202
+ validation_alias=AliasChoices("id", "shadow_test_id"),
203
+ default=None,
204
+ )
205
+ """The unique identifier of the shadow test."""
206
+ name: str | None = None
207
+ """Name of the shadow test."""
208
+ description: str | None = None
209
+ """Description of the shadow test."""
210
+ app_id: str | None = None
211
+ """ID of the application to which the shadow test belongs."""
212
+ created_at: datetime | None = None
213
+ """Creation date of the shadow test."""
214
+ updated_at: datetime | None = None
215
+ """Last update date of the shadow test."""
216
+ status: ExperimentStatus | None = None
217
+ """The current status of the shadow test."""
179
218
  completed_at: datetime | None = None
180
219
  """Completion date of the shadow test, if applicable."""
181
220
  comparisons: list[TestComparison] | None = None