nextmv 1.0.0.dev2__py3-none-any.whl → 1.0.0.dev4__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 (120) hide show
  1. nextmv/__about__.py +1 -1
  2. nextmv/cli/CONTRIBUTING.md +81 -29
  3. nextmv/cli/cloud/__init__.py +2 -0
  4. nextmv/cli/cloud/acceptance/create.py +20 -22
  5. nextmv/cli/cloud/acceptance/delete.py +7 -8
  6. nextmv/cli/cloud/acceptance/get.py +9 -10
  7. nextmv/cli/cloud/acceptance/list.py +3 -3
  8. nextmv/cli/cloud/acceptance/update.py +6 -6
  9. nextmv/cli/cloud/account/__init__.py +3 -3
  10. nextmv/cli/cloud/account/create.py +11 -11
  11. nextmv/cli/cloud/account/delete.py +6 -7
  12. nextmv/cli/cloud/account/get.py +3 -3
  13. nextmv/cli/cloud/account/update.py +5 -5
  14. nextmv/cli/cloud/app/create.py +25 -26
  15. nextmv/cli/cloud/app/delete.py +5 -6
  16. nextmv/cli/cloud/app/exists.py +2 -2
  17. nextmv/cli/cloud/app/get.py +2 -2
  18. nextmv/cli/cloud/app/list.py +3 -3
  19. nextmv/cli/cloud/app/push.py +269 -45
  20. nextmv/cli/cloud/app/update.py +12 -12
  21. nextmv/cli/cloud/batch/create.py +26 -28
  22. nextmv/cli/cloud/batch/delete.py +5 -6
  23. nextmv/cli/cloud/batch/get.py +8 -8
  24. nextmv/cli/cloud/batch/list.py +3 -3
  25. nextmv/cli/cloud/batch/metadata.py +4 -4
  26. nextmv/cli/cloud/batch/update.py +6 -6
  27. nextmv/cli/cloud/data/__init__.py +1 -1
  28. nextmv/cli/cloud/data/upload.py +15 -15
  29. nextmv/cli/cloud/ensemble/__init__.py +2 -0
  30. nextmv/cli/cloud/ensemble/create.py +21 -22
  31. nextmv/cli/cloud/ensemble/delete.py +5 -6
  32. nextmv/cli/cloud/ensemble/get.py +4 -4
  33. nextmv/cli/cloud/ensemble/list.py +63 -0
  34. nextmv/cli/cloud/ensemble/update.py +9 -9
  35. nextmv/cli/cloud/input_set/create.py +20 -22
  36. nextmv/cli/cloud/input_set/get.py +3 -3
  37. nextmv/cli/cloud/input_set/list.py +3 -3
  38. nextmv/cli/cloud/input_set/update.py +24 -24
  39. nextmv/cli/cloud/instance/create.py +14 -15
  40. nextmv/cli/cloud/instance/delete.py +5 -6
  41. nextmv/cli/cloud/instance/exists.py +2 -2
  42. nextmv/cli/cloud/instance/get.py +2 -2
  43. nextmv/cli/cloud/instance/list.py +3 -3
  44. nextmv/cli/cloud/instance/update.py +14 -14
  45. nextmv/cli/cloud/managed_input/create.py +14 -16
  46. nextmv/cli/cloud/managed_input/delete.py +6 -7
  47. nextmv/cli/cloud/managed_input/get.py +3 -3
  48. nextmv/cli/cloud/managed_input/list.py +3 -3
  49. nextmv/cli/cloud/managed_input/update.py +9 -9
  50. nextmv/cli/cloud/run/cancel.py +2 -2
  51. nextmv/cli/cloud/run/create.py +32 -33
  52. nextmv/cli/cloud/run/get.py +8 -8
  53. nextmv/cli/cloud/run/input.py +4 -4
  54. nextmv/cli/cloud/run/list.py +6 -6
  55. nextmv/cli/cloud/run/logs.py +9 -10
  56. nextmv/cli/cloud/run/metadata.py +4 -4
  57. nextmv/cli/cloud/run/track.py +32 -33
  58. nextmv/cli/cloud/scenario/create.py +21 -21
  59. nextmv/cli/cloud/scenario/delete.py +5 -6
  60. nextmv/cli/cloud/scenario/get.py +8 -8
  61. nextmv/cli/cloud/scenario/list.py +3 -3
  62. nextmv/cli/cloud/scenario/metadata.py +4 -4
  63. nextmv/cli/cloud/scenario/update.py +6 -6
  64. nextmv/cli/cloud/secrets/create.py +17 -17
  65. nextmv/cli/cloud/secrets/delete.py +5 -6
  66. nextmv/cli/cloud/secrets/get.py +4 -4
  67. nextmv/cli/cloud/secrets/list.py +3 -3
  68. nextmv/cli/cloud/secrets/update.py +17 -20
  69. nextmv/cli/cloud/shadow/__init__.py +1 -1
  70. nextmv/cli/cloud/shadow/create.py +32 -32
  71. nextmv/cli/cloud/shadow/delete.py +5 -6
  72. nextmv/cli/cloud/shadow/get.py +2 -2
  73. nextmv/cli/cloud/shadow/list.py +3 -3
  74. nextmv/cli/cloud/shadow/metadata.py +4 -4
  75. nextmv/cli/cloud/shadow/start.py +3 -3
  76. nextmv/cli/cloud/shadow/stop.py +8 -10
  77. nextmv/cli/cloud/shadow/update.py +7 -6
  78. nextmv/cli/cloud/switchback/__init__.py +33 -0
  79. nextmv/cli/cloud/switchback/create.py +151 -0
  80. nextmv/cli/cloud/switchback/delete.py +67 -0
  81. nextmv/cli/cloud/switchback/get.py +62 -0
  82. nextmv/cli/cloud/switchback/list.py +63 -0
  83. nextmv/cli/cloud/switchback/metadata.py +68 -0
  84. nextmv/cli/cloud/switchback/start.py +43 -0
  85. nextmv/cli/cloud/switchback/stop.py +41 -0
  86. nextmv/cli/cloud/switchback/update.py +96 -0
  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 +32 -0
  101. nextmv/cli/main.py +27 -36
  102. nextmv/cli/message.py +2 -2
  103. nextmv/cli/options.py +14 -0
  104. nextmv/cli/version.py +1 -1
  105. nextmv/cloud/__init__.py +5 -0
  106. nextmv/cloud/application/__init__.py +192 -54
  107. nextmv/cloud/application/_batch_scenario.py +2 -2
  108. nextmv/cloud/application/_instance.py +2 -2
  109. nextmv/cloud/application/_managed_input.py +1 -1
  110. nextmv/cloud/application/_shadow.py +1 -1
  111. nextmv/cloud/application/_switchback.py +323 -0
  112. nextmv/cloud/application/_version.py +3 -2
  113. nextmv/cloud/shadow.py +43 -4
  114. nextmv/cloud/switchback.py +226 -0
  115. {nextmv-1.0.0.dev2.dist-info → nextmv-1.0.0.dev4.dist-info}/METADATA +1 -1
  116. nextmv-1.0.0.dev4.dist-info/RECORD +183 -0
  117. nextmv-1.0.0.dev2.dist-info/RECORD +0 -170
  118. {nextmv-1.0.0.dev2.dist-info → nextmv-1.0.0.dev4.dist-info}/WHEEL +0 -0
  119. {nextmv-1.0.0.dev2.dist-info → nextmv-1.0.0.dev4.dist-info}/entry_points.txt +0 -0
  120. {nextmv-1.0.0.dev2.dist-info → nextmv-1.0.0.dev4.dist-info}/licenses/LICENSE +0 -0
nextmv/cli/main.py CHANGED
@@ -13,22 +13,25 @@ about the features used here. An example of Rich markup can be found in the
13
13
  epilog of the Typer application defined below.
14
14
  """
15
15
 
16
- import os
17
16
  import sys
18
17
  from typing import Annotated
19
18
 
20
19
  import rich
21
20
  import typer
22
- from rich.prompt import Confirm
21
+ from typer import rich_utils
23
22
 
24
23
  from nextmv.cli.cloud import app as cloud_app
25
24
  from nextmv.cli.community import app as community_app
26
25
  from nextmv.cli.configuration import app as configuration_app
27
26
  from nextmv.cli.configuration.config import CONFIG_DIR, GO_CLI_PATH, load_config
27
+ from nextmv.cli.confirm import get_confirmation
28
28
  from nextmv.cli.message import error, info, success, warning
29
29
  from nextmv.cli.version import app as version_app
30
30
  from nextmv.cli.version import version_callback
31
31
 
32
+ # Disable dim text for the extended help of commands.
33
+ rich_utils.STYLE_HELPTEXT = ""
34
+
32
35
  # Main CLI application.
33
36
  app = typer.Typer(
34
37
  help="The Nextmv Command Line Interface (CLI).",
@@ -66,6 +69,15 @@ def callback(
66
69
  environment.
67
70
  """
68
71
 
72
+ # Skip checks for help commands.
73
+ if "--help" in sys.argv or "-h" in sys.argv:
74
+ return
75
+
76
+ # Skip checks for certain commands.
77
+ ignored_commands = {"configuration", "version"}
78
+ if ctx.invoked_subcommand in ignored_commands:
79
+ return
80
+
69
81
  handle_go_cli()
70
82
  handle_config_existence(ctx)
71
83
 
@@ -80,19 +92,21 @@ def handle_go_cli() -> None:
80
92
 
81
93
  exists = go_cli_exists()
82
94
  if exists:
83
- delete = Confirm.ask(
95
+ delete = get_confirmation(
84
96
  "Do you want to delete the [italic red]deprecated[/italic red] Nextmv CLI "
85
- f"at [magenta]{GO_CLI_PATH}[/magenta] now?",
86
- default=False,
97
+ f"at [magenta]{GO_CLI_PATH}[/magenta] now?"
87
98
  )
88
99
  if delete:
89
100
  remove_go_cli()
90
- else:
91
- info(
92
- msg="You can delete the [italic red]deprecated[/italic red] Nextmv CLI later by removing "
93
- f"[magenta]{GO_CLI_PATH}[/magenta]. Make sure you also clean up your [code]PATH[/code].",
94
- emoji=":bulb:",
95
- )
101
+ return
102
+
103
+ info(
104
+ msg="You can delete the [italic red]deprecated[/italic red] Nextmv CLI later by removing "
105
+ f"[magenta]{GO_CLI_PATH}[/magenta]. "
106
+ "Make sure you also clean up your [code]PATH[/code], "
107
+ f"by removing references to [magenta]{CONFIG_DIR}[/magenta] from it.",
108
+ emoji=":bulb:",
109
+ )
96
110
 
97
111
 
98
112
  def handle_config_existence(ctx: typer.Context) -> None:
@@ -105,10 +119,6 @@ def handle_config_existence(ctx: typer.Context) -> None:
105
119
  The Typer context object.
106
120
  """
107
121
 
108
- ignored_commands = {"configuration", "version"}
109
- if ctx.invoked_subcommand in ignored_commands:
110
- return
111
-
112
122
  config = load_config()
113
123
  if config == {}:
114
124
  error("No configuration found. Please run [code]nextmv configuration create[/code].")
@@ -130,11 +140,9 @@ def go_cli_exists() -> bool:
130
140
  if exists:
131
141
  warning(
132
142
  "A [italic red]deprecated[/italic red] Nextmv CLI is installed at "
133
- f"[magenta]{GO_CLI_PATH}[/magenta]. You must delete it to avoid conflicts."
143
+ f"[magenta]{GO_CLI_PATH}[/magenta]. You should delete it to avoid conflicts."
134
144
  )
135
145
 
136
- check_config_in_path()
137
-
138
146
  return exists
139
147
 
140
148
 
@@ -145,24 +153,7 @@ def remove_go_cli() -> None:
145
153
 
146
154
  if GO_CLI_PATH.exists():
147
155
  GO_CLI_PATH.unlink()
148
- success(f"Deleted deprecated [magenta]{GO_CLI_PATH}[/magenta].")
149
-
150
- check_config_in_path()
151
-
152
-
153
- def check_config_in_path() -> None:
154
- """
155
- Check if the configuration directory is in the PATH and notify the user.
156
- """
157
-
158
- path_dirs = os.environ.get("PATH", "").split(os.pathsep)
159
- config_dir_str = str(CONFIG_DIR)
160
-
161
- if config_dir_str in path_dirs:
162
- warning(
163
- f"[magenta]{CONFIG_DIR}[/magenta] was found in your [code]PATH[/code]. "
164
- f"You should remove any entries related to [magenta]{CONFIG_DIR}[/magenta] from your [code]PATH[/code]."
165
- )
156
+ success(f"Deleted [italic red]deprecated[/italic red] [magenta]{GO_CLI_PATH}[/magenta].")
166
157
 
167
158
 
168
159
  def main() -> None:
nextmv/cli/message.py CHANGED
@@ -31,7 +31,7 @@ def error(msg: str) -> None:
31
31
  if not msg.endswith("."):
32
32
  msg += "."
33
33
 
34
- rich.print(f"[red]Error:[/red] {msg}", file=sys.stderr)
34
+ rich.print(f":x: [red]Error:[/red] {msg}", file=sys.stderr)
35
35
 
36
36
  raise typer.Exit(code=1)
37
37
 
@@ -67,7 +67,7 @@ def warning(msg: str) -> None:
67
67
  if not msg.endswith("."):
68
68
  msg += "."
69
69
 
70
- rich.print(f":construction: {msg}", file=sys.stderr)
70
+ rich.print(f":construction: [yellow] Warning:[/yellow] {msg}", file=sys.stderr)
71
71
 
72
72
 
73
73
  def info(msg: str, emoji: str | None = None) -> None:
nextmv/cli/options.py CHANGED
@@ -204,3 +204,17 @@ ShadowTestIDOption = Annotated[
204
204
  metavar="SHADOW_TEST_ID",
205
205
  ),
206
206
  ]
207
+
208
+ # switchback_test_id option - can be used in any command that requires a switchback test ID.
209
+ # Define it as follows in commands or callbacks, as necessary:
210
+ # switchback_test_id: SwitchbackTestIDOption
211
+ SwitchbackTestIDOption = Annotated[
212
+ str,
213
+ typer.Option(
214
+ "--switchback-test-id",
215
+ "-s",
216
+ help="The Nextmv Cloud switchback test ID to use for this action.",
217
+ envvar="NEXTMV_SWITCHBACK_TEST_ID",
218
+ metavar="SWITCHBACK_TEST_ID",
219
+ ),
220
+ ]
nextmv/cli/version.py CHANGED
@@ -18,7 +18,7 @@ def version() -> None:
18
18
  [bold][underline]Examples[/underline][/bold]
19
19
 
20
20
  - Show the version.
21
- $ [green]nextmv version[/green]
21
+ $ [dim]nextmv version[/dim]
22
22
  """
23
23
 
24
24
  version_callback(True)
nextmv/cloud/__init__.py CHANGED
@@ -95,6 +95,11 @@ from .shadow import ShadowTestMetadata as ShadowTestMetadata
95
95
  from .shadow import StartEvents as StartEvents
96
96
  from .shadow import TerminationEvents as TerminationEvents
97
97
  from .shadow import TestComparison as TestComparison
98
+ from .switchback import SwitchbackPlan as SwitchbackPlan
99
+ from .switchback import SwitchbackPlanUnit as SwitchbackPlanUnit
100
+ from .switchback import SwitchbackTest as SwitchbackTest
101
+ from .switchback import SwitchbackTestMetadata as SwitchbackTestMetadata
102
+ from .switchback import TestComparisonSingle as TestComparisonSingle
98
103
  from .url import DownloadURL as DownloadURL
99
104
  from .url import UploadURL as UploadURL
100
105
  from .version import Version as Version
@@ -42,10 +42,13 @@ from nextmv.cloud.application._managed_input import ApplicationManagedInputMixin
42
42
  from nextmv.cloud.application._run import ApplicationRunMixin
43
43
  from nextmv.cloud.application._secrets import ApplicationSecretsMixin
44
44
  from nextmv.cloud.application._shadow import ApplicationShadowMixin
45
+ from nextmv.cloud.application._switchback import ApplicationSwitchbackMixin
45
46
  from nextmv.cloud.application._utils import _is_not_exist_error
46
47
  from nextmv.cloud.application._version import ApplicationVersionMixin
47
48
  from nextmv.cloud.client import Client
49
+ from nextmv.cloud.instance import Instance
48
50
  from nextmv.cloud.url import UploadURL
51
+ from nextmv.cloud.version import Version
49
52
  from nextmv.logger import log
50
53
  from nextmv.manifest import Manifest
51
54
  from nextmv.model import Model, ModelConfiguration
@@ -102,6 +105,7 @@ class Application(
102
105
  ApplicationInputSetMixin,
103
106
  ApplicationManagedInputMixin,
104
107
  ApplicationShadowMixin,
108
+ ApplicationSwitchbackMixin,
105
109
  ):
106
110
  """
107
111
  A published decision model that can be executed.
@@ -248,7 +252,7 @@ class Application(
248
252
  def new(
249
253
  cls,
250
254
  client: Client,
251
- name: str,
255
+ name: str | None = None,
252
256
  id: str | None = None,
253
257
  description: str | None = None,
254
258
  is_workflow: bool | None = None,
@@ -266,13 +270,13 @@ class Application(
266
270
  ----------
267
271
  client : Client
268
272
  Client to use for interacting with the Nextmv Cloud API.
269
- name : str
270
- Name of the application.
271
- 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
272
276
  ID of the application. Will be generated if not provided.
273
- description : str, optional
277
+ description : str | None = None
274
278
  Description of the application.
275
- is_workflow : bool, optional
279
+ is_workflow : bool | None = None
276
280
  Whether the application is a Decision Workflow.
277
281
  exist_ok : bool, default=False
278
282
  If True and an application with the same ID already exists,
@@ -294,7 +298,10 @@ class Application(
294
298
  >>> app = Application.new(client=client, name="My New App", id="my-app")
295
299
  """
296
300
 
297
- 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 == "":
298
305
  id = safe_id("app")
299
306
 
300
307
  if exist_ok and cls.exists(client=client, id=id):
@@ -305,6 +312,9 @@ class Application(
305
312
 
306
313
  return cls.from_dict({"client": client} | response.json())
307
314
 
315
+ if name is None or name == "":
316
+ name = id
317
+
308
318
  payload = {
309
319
  "name": name,
310
320
  "id": id,
@@ -397,10 +407,14 @@ class Application(
397
407
  model: Model | None = None,
398
408
  model_configuration: ModelConfiguration | None = None,
399
409
  rich_print: bool = False,
400
- no_version: bool = False,
410
+ auto_create: bool = False,
401
411
  version_id: str | None = None,
402
412
  version_name: str | None = None,
403
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,
404
418
  ) -> None:
405
419
  """
406
420
  Push an app to Nextmv Cloud.
@@ -418,14 +432,16 @@ class Application(
418
432
  `nextmv.Model`. The model is encoded, some dependencies and
419
433
  accompanying files are packaged, and the app is pushed to Nextmv Cloud.
420
434
 
421
- The default behavior of this function is to create a new application
422
- version _after_ the app has been pushed. You can set the `no_version`
423
- argument to `True` to skip this step. The `version_id`, `version_name`,
424
- and `version_description` arguments can be used to customize the version
425
- that is created. If the `version_id` is not specified, a randomly
426
- generated ID will be used. If the `version_name` is not specified, a
427
- generic name with a timestamp will be used. Lastly, if no description is
428
- 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.
429
445
 
430
446
  Parameters
431
447
  ----------
@@ -445,21 +461,30 @@ class Application(
445
461
  with `model`.
446
462
  rich_print : bool, default=False
447
463
  Whether to use rich printing when verbose output is enabled.
448
- no_version : bool, default=False
449
- 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.
450
467
  version_id : Optional[str], default=None
451
468
  ID of the version to create after pushing the app. If None, a unique
452
469
  ID will be generated.
453
470
  version_name : Optional[str], default=None
454
- Name of the version to create after pushing the app. If None, a name
455
- 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.
456
473
  version_description : Optional[str], default=None
457
474
  Description of the version to create after pushing the app. If None, a
458
475
  generic description with a timestamp will be generated.
459
-
460
- Returns
461
- -------
462
- 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.
463
488
 
464
489
  Raises
465
490
  ------
@@ -563,44 +588,43 @@ class Application(
563
588
  except OSError as e:
564
589
  raise Exception(f"error deleting output directory: {e}") from e
565
590
 
566
- if no_version:
567
- if verbose:
568
- if rich_print:
569
- rich.print(
570
- f":white_check_mark: Push completed for Nextmv application [magenta]{self.id}[/magenta] "
571
- "without creating a new version.",
572
- file=sys.stderr,
573
- )
574
- else:
575
- log("✅ Push completed without creating a new version for Nextmv application.")
576
-
591
+ if not auto_create:
577
592
  return
578
593
 
579
- now = datetime.now(timezone.utc)
580
- if version_id is None:
581
- version_id = safe_id(prefix="version") + f"-{now.strftime('%Y%m%d-%H%M%S')}"
582
- if version_name is None:
583
- version_name = f"Version {version_id}"
584
- if version_description is None:
585
- version_description = f"Version created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
586
-
587
- version = self.new_version(
588
- id=version_id,
589
- name=version_name,
590
- description=version_description,
591
- )
592
- version_dict = version.to_dict()
593
-
594
594
  if verbose:
595
595
  if rich_print:
596
596
  rich.print(
597
- 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...",
598
599
  file=sys.stderr,
599
600
  )
600
- rich.print_json(data=version_dict)
601
601
  else:
602
- log(f'✅ Automatically created new version "{version.id}".')
603
- 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
+ )
604
628
 
605
629
  def update(
606
630
  self,
@@ -917,6 +941,120 @@ class Application(
917
941
  log(f'💥️ Successfully pushed to application: "{self.id}".')
918
942
  log(json.dumps(data, indent=2))
919
943
 
944
+ def __version_on_push(
945
+ self,
946
+ now: datetime,
947
+ version_id: str | None = None,
948
+ version_name: str | None = None,
949
+ version_description: str | None = None,
950
+ verbose: bool = False,
951
+ rich_print: bool = False,
952
+ ) -> Version:
953
+ if version_description is None or version_description == "":
954
+ version_description = f"Version created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
955
+
956
+ version = self.new_version(
957
+ id=version_id,
958
+ name=version_name,
959
+ description=version_description,
960
+ )
961
+ version_dict = version.to_dict()
962
+
963
+ if not verbose:
964
+ return version
965
+
966
+ if rich_print:
967
+ rich.print(
968
+ f":white_check_mark: Automatically created new version [magenta]{version.id}[/magenta].",
969
+ file=sys.stderr,
970
+ )
971
+ rich.print_json(data=version_dict)
972
+
973
+ return version
974
+
975
+ log(f'✅ Automatically created new version "{version.id}".')
976
+ log(json.dumps(version_dict, indent=2))
977
+
978
+ return version
979
+
980
+ def __instance_on_push(
981
+ self,
982
+ now: datetime,
983
+ version_id: str,
984
+ instance_id: str | None = None,
985
+ instance_name: str | None = None,
986
+ instance_description: str | None = None,
987
+ verbose: bool = False,
988
+ rich_print: bool = False,
989
+ ) -> Instance:
990
+ if instance_description is None or instance_description == "":
991
+ instance_description = f"Instance created automatically from push at {now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
992
+
993
+ instance = self.new_instance(
994
+ version_id=version_id,
995
+ id=instance_id,
996
+ name=instance_name,
997
+ description=instance_description,
998
+ )
999
+ instance_dict = instance.to_dict()
1000
+
1001
+ if not verbose:
1002
+ return instance
1003
+
1004
+ if rich_print:
1005
+ rich.print(
1006
+ f":white_check_mark: Automatically created new instance [magenta]{instance.id}[/magenta] "
1007
+ f"using version [magenta]{version_id}[/magenta].",
1008
+ file=sys.stderr,
1009
+ )
1010
+ rich.print_json(data=instance_dict)
1011
+
1012
+ return instance
1013
+
1014
+ log(f'✅ Automatically created new instance "{instance.id}" using version "{version_id}".')
1015
+ log(json.dumps(instance_dict, indent=2))
1016
+
1017
+ return instance
1018
+
1019
+ def __update_on_push(
1020
+ self,
1021
+ instance: Instance,
1022
+ update_default_instance: bool = False,
1023
+ verbose: bool = False,
1024
+ rich_print: bool = False,
1025
+ ) -> None:
1026
+ if not update_default_instance:
1027
+ return
1028
+
1029
+ if verbose:
1030
+ if rich_print:
1031
+ rich.print(
1032
+ f":hourglass_flowing_sand: Updating default instance for app [magenta]{self.id}[/magenta]...",
1033
+ file=sys.stderr,
1034
+ )
1035
+ else:
1036
+ log("⌛️ Updating default instance for the Nextmv application...")
1037
+
1038
+ updated_app = self.update(default_instance_id=instance.id)
1039
+ if not verbose:
1040
+ return
1041
+
1042
+ if rich_print:
1043
+ rich.print(
1044
+ f":white_check_mark: Updated default instance to "
1045
+ f"[magenta]{updated_app.default_instance_id}[/magenta] for application "
1046
+ f"[magenta]{self.id}[/magenta].",
1047
+ file=sys.stderr,
1048
+ )
1049
+ rich.print_json(data=updated_app.to_dict())
1050
+
1051
+ return
1052
+
1053
+ log(
1054
+ f'✅ Updated default instance to "{updated_app.default_instance_id}" for application "{self.id}".',
1055
+ )
1056
+ log(json.dumps(updated_app.to_dict(), indent=2))
1057
+
920
1058
 
921
1059
  def list_applications(client: Client) -> list[Application]:
922
1060
  """
@@ -304,7 +304,7 @@ class ApplicationBatchMixin:
304
304
  """
305
305
 
306
306
  # Generate ID if not provided
307
- if id is None:
307
+ if id is None or id == "":
308
308
  id = safe_id("batch")
309
309
 
310
310
  # Use ID as name if name not provided
@@ -481,7 +481,7 @@ class ApplicationBatchMixin:
481
481
  raise ValueError("At least one scenario must be provided")
482
482
 
483
483
  # Generate ID if not provided
484
- if id is None:
484
+ if id is None or id == "":
485
485
  id = safe_id("scenario")
486
486
 
487
487
  # Use ID as name if name not provided
@@ -194,13 +194,13 @@ 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
205
  if name is None:
206
206
  name = id
@@ -147,7 +147,7 @@ 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
152
  if name is None:
153
153
  name = id
@@ -208,7 +208,7 @@ class ApplicationShadowMixin:
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(),