nextmv 0.28.4__tar.gz → 0.29.0.dev0__tar.gz

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 (68) hide show
  1. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/PKG-INFO +2 -2
  2. nextmv-0.29.0.dev0/nextmv/__about__.py +1 -0
  3. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/__init__.py +8 -0
  4. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/application.py +125 -13
  5. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/client.py +28 -9
  6. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/manifest.py +142 -14
  7. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/package.py +1 -1
  8. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/input.py +419 -6
  9. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/model.py +12 -3
  10. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/options.py +88 -0
  11. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/output.py +535 -51
  12. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/pyproject.toml +1 -1
  13. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/requirements.txt +2 -0
  14. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/app.yaml +16 -0
  15. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/test_manifest.py +132 -3
  16. nextmv-0.29.0.dev0/tests/test_input.py +551 -0
  17. nextmv-0.29.0.dev0/tests/test_inputs/test_data.csv +4 -0
  18. nextmv-0.29.0.dev0/tests/test_inputs/test_data.json +1 -0
  19. nextmv-0.29.0.dev0/tests/test_inputs/test_data.txt +3 -0
  20. nextmv-0.29.0.dev0/tests/test_output.py +1425 -0
  21. nextmv-0.28.4/nextmv/__about__.py +0 -1
  22. nextmv-0.28.4/tests/test_input.py +0 -202
  23. nextmv-0.28.4/tests/test_output.py +0 -683
  24. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/.gitignore +0 -0
  25. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/LICENSE +0 -0
  26. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/README.md +0 -0
  27. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/__entrypoint__.py +0 -0
  28. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/_serialization.py +0 -0
  29. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/base_model.py +0 -0
  30. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/__init__.py +0 -0
  31. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/acceptance_test.py +0 -0
  32. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/account.py +0 -0
  33. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/batch_experiment.py +0 -0
  34. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/input_set.py +0 -0
  35. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/instance.py +0 -0
  36. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/run.py +0 -0
  37. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/safe.py +0 -0
  38. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/scenario.py +0 -0
  39. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/secrets.py +0 -0
  40. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/status.py +0 -0
  41. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/cloud/version.py +0 -0
  42. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/deprecated.py +0 -0
  43. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/nextmv/logger.py +0 -0
  44. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/__init__.py +0 -0
  45. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/__init__.py +0 -0
  46. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/test_application.py +0 -0
  47. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/test_client.py +0 -0
  48. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/test_package.py +0 -0
  49. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/test_run.py +0 -0
  50. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/test_safe_name_id.py +0 -0
  51. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/cloud/test_scenario.py +0 -0
  52. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/__init__.py +0 -0
  53. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options1.py +0 -0
  54. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options2.py +0 -0
  55. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options3.py +0 -0
  56. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options4.py +0 -0
  57. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options5.py +0 -0
  58. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options6.py +0 -0
  59. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options7.py +0 -0
  60. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/scripts/options_deprecated.py +0 -0
  61. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_base_model.py +0 -0
  62. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_entrypoint/__init__.py +0 -0
  63. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_entrypoint/test_entrypoint.py +0 -0
  64. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_logger.py +0 -0
  65. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_model.py +0 -0
  66. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_options.py +0 -0
  67. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_serialization.py +0 -0
  68. {nextmv-0.28.4 → nextmv-0.29.0.dev0}/tests/test_version.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextmv
3
- Version: 0.28.4
3
+ Version: 0.29.0.dev0
4
4
  Summary: The all-purpose Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
- Project-URL: Documentation, https://www.nextmv.io/docs/python-sdks/nextmv/installation
6
+ Project-URL: Documentation, https://nextmv-py.docs.nextmv.io/en/latest/nextmv/
7
7
  Project-URL: Repository, https://github.com/nextmv-io/nextmv-py
8
8
  Author-email: Nextmv <tech@nextmv.io>
9
9
  Maintainer-email: Nextmv <tech@nextmv.io>
@@ -0,0 +1 @@
1
+ __version__ = "v0.29.0.dev0"
@@ -3,12 +3,16 @@
3
3
  from .__about__ import __version__
4
4
  from .base_model import BaseModel as BaseModel
5
5
  from .base_model import from_dict as from_dict
6
+ from .input import DataFile as DataFile
6
7
  from .input import Input as Input
7
8
  from .input import InputFormat as InputFormat
8
9
  from .input import InputLoader as InputLoader
9
10
  from .input import LocalInputLoader as LocalInputLoader
11
+ from .input import csv_data_file as csv_data_file
12
+ from .input import json_data_file as json_data_file
10
13
  from .input import load as load
11
14
  from .input import load_local as load_local
15
+ from .input import text_data_file as text_data_file
12
16
  from .logger import log as log
13
17
  from .logger import redirect_stdout as redirect_stdout
14
18
  from .logger import reset_stdout as reset_stdout
@@ -27,9 +31,13 @@ from .output import ResultStatistics as ResultStatistics
27
31
  from .output import RunStatistics as RunStatistics
28
32
  from .output import Series as Series
29
33
  from .output import SeriesData as SeriesData
34
+ from .output import SolutionFile as SolutionFile
30
35
  from .output import Statistics as Statistics
31
36
  from .output import Visual as Visual
32
37
  from .output import VisualSchema as VisualSchema
38
+ from .output import csv_solution_file as csv_solution_file
39
+ from .output import json_solution_file as json_solution_file
40
+ from .output import text_solution_file as text_solution_file
33
41
  from .output import write as write
34
42
  from .output import write_local as write_local
35
43
 
@@ -23,8 +23,11 @@ poll
23
23
  """
24
24
 
25
25
  import json
26
+ import os
26
27
  import random
27
28
  import shutil
29
+ import tarfile
30
+ import tempfile
28
31
  import time
29
32
  from collections.abc import Callable
30
33
  from dataclasses import dataclass
@@ -1494,6 +1497,7 @@ class Application:
1494
1497
  batch_experiment_id: Optional[str] = None,
1495
1498
  external_result: Optional[Union[ExternalRunResult, dict[str, Any]]] = None,
1496
1499
  json_configurations: Optional[dict[str, Any]] = None,
1500
+ dir_path: Optional[str] = None,
1497
1501
  ) -> str:
1498
1502
  """
1499
1503
  Submit an input to start a new run of the application. Returns the
@@ -1503,11 +1507,35 @@ class Application:
1503
1507
  ----------
1504
1508
  input: Union[Input, dict[str, Any], BaseModel, str]
1505
1509
  Input to use for the run. This can be a `nextmv.Input` object,
1506
- `dict`, `BaseModel` or `str`. If `nextmv.Input` is used, then the
1507
- input is extracted from the `.data` property. Note that for now,
1508
- `InputFormat.CSV_ARCHIVE` is not supported as an
1509
- `input.input_format`. If an input is too large, it will be uploaded
1510
- with the `upload_large_input` method.
1510
+ `dict`, `BaseModel` or `str`.
1511
+
1512
+ If `nextmv.Input` is used, and the `input_format` is either
1513
+ `nextmv.InputFormat.JSON` or `nextmv.InputFormat.TEXT`, then the
1514
+ input data is extracted from the `.data` property.
1515
+
1516
+ If you want to work with `nextmv.InputFormat.CSV_ARCHIVE` or
1517
+ `nextmv.InputFormat.MULTI_FILE`, you should use the `dir_path`
1518
+ argument instead. This argument takes precedence over the `input`.
1519
+ If `dir_path` is specified, this function looks for files in that
1520
+ directory and tars them, to later be uploaded using the
1521
+ `upload_large_input` method. If both the `dir_path` and `input`
1522
+ arguments are provided, the `input` is ignored.
1523
+
1524
+ When `dir_path` is specified, the `configuration` argument must
1525
+ also be provided. More specifically, the
1526
+ `RunConfiguration.format.format_input.input_type` parameter
1527
+ dictates what kind of input is being submitted to the Nextmv Cloud.
1528
+ Make sure that this parameter is specified when working with the
1529
+ following input formats:
1530
+
1531
+ - `nextmv.InputFormat.CSV_ARCHIVE`
1532
+ - `nextmv.InputFormat.MULTI_FILE`
1533
+
1534
+ When working with JSON or text data, use the `input` argument
1535
+ directly.
1536
+
1537
+ In general, if an input is too large, it will be uploaded with the
1538
+ `upload_large_input` method.
1511
1539
  instance_id: Optional[str]
1512
1540
  ID of the instance to use for the run. If not provided, the default
1513
1541
  instance ID associated to the Class (`default_instance_id`) is
@@ -1560,26 +1588,36 @@ class Application:
1560
1588
  not `JSON`. If the final `options` are not of type `dict[str,str]`.
1561
1589
  """
1562
1590
 
1591
+ self.__validate_dir_path_and_configuration(dir_path, configuration)
1592
+
1593
+ tar_file = ""
1594
+ if dir_path is not None and dir_path != "":
1595
+ if not os.path.exists(dir_path):
1596
+ raise ValueError(f"Directory {dir_path} does not exist.")
1597
+
1598
+ if not os.path.isdir(dir_path):
1599
+ raise ValueError(f"Path {dir_path} is not a directory.")
1600
+
1601
+ tar_file = self.__package_inputs(dir_path)
1602
+
1563
1603
  input_data = None
1564
1604
  if isinstance(input, BaseModel):
1565
1605
  input_data = input.to_dict()
1566
1606
  elif isinstance(input, dict) or isinstance(input, str):
1567
1607
  input_data = input
1568
1608
  elif isinstance(input, Input):
1569
- if input.input_format == InputFormat.CSV_ARCHIVE:
1570
- raise ValueError("csv-archive is not supported")
1571
1609
  input_data = input.data
1572
1610
 
1573
1611
  input_size = 0
1574
1612
  if input_data is not None:
1575
1613
  input_size = get_size(input_data)
1576
1614
 
1577
- upload_url_required = input_size > _MAX_RUN_SIZE
1615
+ upload_url_required = input_size > _MAX_RUN_SIZE or tar_file != ""
1578
1616
  upload_id_used = upload_id is not None
1579
1617
 
1580
1618
  if not upload_id_used and upload_url_required:
1581
1619
  upload_url = self.upload_url()
1582
- self.upload_large_input(input=input_data, upload_url=upload_url)
1620
+ self.upload_large_input(input=input_data, upload_url=upload_url, tar_file=tar_file)
1583
1621
  upload_id = upload_url.upload_id
1584
1622
  upload_id_used = True
1585
1623
 
@@ -2797,9 +2835,10 @@ class Application:
2797
2835
 
2798
2836
  def upload_large_input(
2799
2837
  self,
2800
- input: Union[dict[str, Any], str],
2838
+ input: Optional[Union[dict[str, Any], str]],
2801
2839
  upload_url: UploadURL,
2802
2840
  json_configurations: Optional[dict[str, Any]] = None,
2841
+ tar_file: Optional[str] = None,
2803
2842
  ) -> None:
2804
2843
  """
2805
2844
  Upload large input data to the provided upload URL.
@@ -2810,14 +2849,19 @@ class Application:
2810
2849
 
2811
2850
  Parameters
2812
2851
  ----------
2813
- input : Union[dict[str, Any], str]
2852
+ input : Optional[Union[dict[str, Any], str]]
2814
2853
  Input data to upload. Can be either a dictionary that will be
2815
2854
  converted to JSON, or a pre-formatted JSON string.
2816
2855
  upload_url : UploadURL
2817
2856
  Upload URL object containing the pre-signed URL to use for uploading.
2818
2857
  json_configurations : Optional[dict[str, Any]], default=None
2819
2858
  Optional configurations for JSON serialization. If provided, these
2820
- configurations will be used when serializing the data via `json.dumps`.
2859
+ configurations will be used when serializing the data via
2860
+ `json.dumps`.
2861
+ tar_file : Optional[str], default=None
2862
+ If provided, this will be used to upload a tar file instead of
2863
+ a JSON string or dictionary. This is useful for uploading large
2864
+ files that are already packaged as a tarball.
2821
2865
 
2822
2866
  Returns
2823
2867
  -------
@@ -2841,12 +2885,13 @@ class Application:
2841
2885
  >>> app.upload_large_input(input=json_str, upload_url=url)
2842
2886
  """
2843
2887
 
2844
- if isinstance(input, dict):
2888
+ if input is not None and isinstance(input, dict):
2845
2889
  input = deflated_serialize_json(input, json_configurations=json_configurations)
2846
2890
 
2847
2891
  self.client.upload_to_presigned_url(
2848
2892
  url=upload_url.upload_url,
2849
2893
  data=input,
2894
+ tar_file=tar_file,
2850
2895
  )
2851
2896
 
2852
2897
  def upload_url(self) -> UploadURL:
@@ -3185,6 +3230,73 @@ class Application:
3185
3230
 
3186
3231
  raise ValueError(f"Unknown scenario input type: {scenario.scenario_input.scenario_input_type}")
3187
3232
 
3233
+ def __validate_dir_path_and_configuration(
3234
+ self,
3235
+ dir_path: Optional[str],
3236
+ configuration: Optional[RunConfiguration],
3237
+ ) -> None:
3238
+ """
3239
+ Auxiliary function to validate the directory path and configuration.
3240
+ """
3241
+ if dir_path is None or dir_path == "":
3242
+ return
3243
+
3244
+ if configuration is None:
3245
+ raise ValueError(
3246
+ "If dir_path is provided, a RunConfiguration must also be provided.",
3247
+ )
3248
+
3249
+ if configuration.format is None:
3250
+ raise ValueError(
3251
+ "If dir_path is provided, RunConfiguration.format must also be provided.",
3252
+ )
3253
+
3254
+ if configuration.format.format_input is None:
3255
+ raise ValueError(
3256
+ "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3257
+ )
3258
+
3259
+ input_type = configuration.format.format_input.input_type
3260
+ if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
3261
+ raise ValueError(
3262
+ "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type."
3263
+ f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
3264
+ )
3265
+
3266
+ def __package_inputs(self, dir_path: str) -> str:
3267
+ """
3268
+ This is an auxiliary function for packaging the inputs found in the
3269
+ provided `dir_path`. All the files found in the directory are tarred and
3270
+ g-zipped. This function returns the tar file path that contains the
3271
+ packaged inputs.
3272
+ """
3273
+
3274
+ # Create a temporary directory for the output
3275
+ output_dir = tempfile.mkdtemp(prefix="nextmv-inputs-out-")
3276
+
3277
+ # Define the output tar file name and path
3278
+ tar_filename = "inputs.tar.gz"
3279
+ tar_file_path = os.path.join(output_dir, tar_filename)
3280
+
3281
+ # Create the tar.gz file
3282
+ with tarfile.open(tar_file_path, "w:gz") as tar:
3283
+ for root, _, files in os.walk(dir_path):
3284
+ for file in files:
3285
+ if file == tar_filename:
3286
+ continue
3287
+
3288
+ file_path = os.path.join(root, file)
3289
+
3290
+ # Skip directories, only process files
3291
+ if os.path.isdir(file_path):
3292
+ continue
3293
+
3294
+ # Create relative path for the archive
3295
+ arcname = os.path.relpath(file_path, start=dir_path)
3296
+ tar.add(file_path, arcname=arcname)
3297
+
3298
+ return tar_file_path
3299
+
3188
3300
 
3189
3301
  def poll( # noqa: C901
3190
3302
  polling_options: PollingOptions,
@@ -323,7 +323,11 @@ class Client:
323
323
  return response
324
324
 
325
325
  def upload_to_presigned_url(
326
- self, data: Union[dict[str, Any], str], url: str, json_configurations: Optional[dict[str, Any]] = None
326
+ self,
327
+ data: Optional[Union[dict[str, Any], str]],
328
+ url: str,
329
+ json_configurations: Optional[dict[str, Any]] = None,
330
+ tar_file: Optional[str] = None,
327
331
  ) -> None:
328
332
  """
329
333
  Uploads data to a presigned URL.
@@ -333,7 +337,7 @@ class Client:
333
337
 
334
338
  Parameters
335
339
  ----------
336
- data : dict[str, Any] or str
340
+ data : Union[dict[str, Any], str], optional
337
341
  The data to upload. If a dictionary is provided, it will be
338
342
  JSON-serialized. If a string is provided, it will be uploaded
339
343
  as is.
@@ -344,6 +348,11 @@ class Client:
344
348
  customization of the Python `json.dumps` function, such as
345
349
  specifying `indent` for pretty printing or `default` for custom
346
350
  serialization functions.
351
+ tar_file : str, optional
352
+ If provided, this will be used to upload a tar file instead of
353
+ a JSON string or dictionary. This is useful for uploading large
354
+ files that are already packaged as a tarball. If this is provided,
355
+ `data` is expected to be `None`.
347
356
 
348
357
  Raises
349
358
  ------
@@ -361,12 +370,13 @@ class Client:
361
370
  """
362
371
 
363
372
  upload_data: Optional[str] = None
364
- if isinstance(data, dict):
365
- upload_data = deflated_serialize_json(data, json_configurations=json_configurations)
366
- elif isinstance(data, str):
367
- upload_data = data
368
- else:
369
- raise ValueError("data must be a dictionary or a string")
373
+ if data is not None:
374
+ if isinstance(data, dict):
375
+ upload_data = deflated_serialize_json(data, json_configurations=json_configurations)
376
+ elif isinstance(data, str):
377
+ upload_data = data
378
+ else:
379
+ raise ValueError("data must be a dictionary or a string")
370
380
 
371
381
  session = requests.Session()
372
382
  retries = Retry(
@@ -379,12 +389,21 @@ class Client:
379
389
  )
380
390
  adapter = HTTPAdapter(max_retries=retries)
381
391
  session.mount("https://", adapter)
392
+
382
393
  kwargs: dict[str, Any] = {
383
394
  "url": url,
384
395
  "timeout": self.timeout,
385
- "data": upload_data,
386
396
  }
387
397
 
398
+ if upload_data is not None:
399
+ kwargs["data"] = upload_data
400
+ elif tar_file is not None:
401
+ if not os.path.exists(tar_file):
402
+ raise ValueError(f"tar_file {tar_file} does not exist")
403
+ kwargs["data"] = open(tar_file, "rb")
404
+ else:
405
+ raise ValueError("either data or tar_file must be provided")
406
+
388
407
  response = session.put(**kwargs)
389
408
 
390
409
  try:
@@ -16,10 +16,14 @@ ManifestPythonModel
16
16
  Class for model-specific instructions for Python apps.
17
17
  ManifestPython
18
18
  Class for Python-specific instructions in the manifest.
19
+ ManifestOptionUI
20
+ Class for UI attributes of options in the manifest.
19
21
  ManifestOption
20
22
  Class representing an option for the decision model in the manifest.
21
23
  ManifestOptions
22
24
  Class containing a list of options for the decision model.
25
+ ManifestValidation
26
+ Class for validation rules for options in the manifest.
23
27
  ManifestConfiguration
24
28
  Class for configuration settings for the decision model.
25
29
  Manifest
@@ -40,7 +44,7 @@ from pydantic import AliasChoices, Field
40
44
 
41
45
  from nextmv.base_model import BaseModel
42
46
  from nextmv.model import _REQUIREMENTS_FILE, ModelConfiguration
43
- from nextmv.options import Option, Options
47
+ from nextmv.options import Option, Options, OptionsEnforcement
44
48
 
45
49
  MANIFEST_FILE_NAME = "app.yaml"
46
50
  """Name of the app manifest file.
@@ -318,6 +322,43 @@ class ManifestPython(BaseModel):
318
322
  from the app bundle.
319
323
  """
320
324
 
325
+ class ManifestOptionUI(BaseModel):
326
+ """
327
+ UI attributes for an option in the manifest.
328
+
329
+ You can import the `ManifestOptionUI` class directly from `cloud`:
330
+
331
+ ```python
332
+ from nextmv.cloud import ManifestOptionUI
333
+ ```
334
+
335
+ Parameters
336
+ ----------
337
+ control_type : str, optional
338
+ The type of control to use for the option in the Nextmv Cloud UI. This is
339
+ useful for defining how the option should be presented in the Nextmv
340
+ Cloud UI. Current control types include "input", "select", "slider", and
341
+ "toggle". This attribute is not used in the local `Options` class, but
342
+ it is used in the Nextmv Cloud UI to define the type of control to use for
343
+ the option. This will be validated by the Nextmv Cloud, and availability
344
+ is based on options_type.
345
+ hidden_from : list[str], optional
346
+ A list of team roles to which this option will be hidden in the UI. For
347
+ example, if you want to hide an option from the "operator" role, you can
348
+ pass `hidden_from=["operator"]`.
349
+
350
+ Examples
351
+ --------
352
+ >>> from nextmv.cloud import ManifestOptionUI
353
+ >>> ui_config = ManifestOptionUI(control_type="input")
354
+ >>> ui_config.control_type
355
+ 'input'
356
+ """
357
+
358
+ control_type: Optional[str] = None
359
+ """The type of control to use for the option in the Nextmv Cloud UI."""
360
+ hidden_from: Optional[list[str]] = None
361
+ """A list of team roles for which this option will be hidden in the UI."""
321
362
 
322
363
  class ManifestOption(BaseModel):
323
364
  """
@@ -349,6 +390,12 @@ class ManifestOption(BaseModel):
349
390
  length of a string or the maximum value of an integer. These
350
391
  additional attributes will be shown in the help message of the
351
392
  `Options`.
393
+ ui : Optional[ManifestOptionUI], default=None
394
+ Optional UI attributes for the option. This is a dictionary that can
395
+ contain additional information about how the option should be displayed
396
+ in the Nextmv Cloud UI. This is not used in the local `Options` class,
397
+ but it is used in the Nextmv Cloud UI to define how the option should be
398
+ presented.
352
399
 
353
400
  Examples
354
401
  --------
@@ -378,12 +425,10 @@ class ManifestOption(BaseModel):
378
425
  required: bool = False
379
426
  """Whether the option is required or not"""
380
427
  additional_attributes: Optional[dict[str, Any]] = None
381
- """Optional additional attributes for the option.
428
+ """Optional additional attributes for the option."""
429
+ ui: Optional[ManifestOptionUI] = None
430
+ """Optional UI attributes for the option."""
382
431
 
383
- The Nextmv Cloud may perform validation on these attributes. For example,
384
- the maximum length of a string or the maximum value of an integer. These
385
- additional attributes will be shown in the help message of the `Options`.
386
- """
387
432
 
388
433
  @classmethod
389
434
  def from_option(cls, option: Option) -> "ManifestOption":
@@ -435,6 +480,10 @@ class ManifestOption(BaseModel):
435
480
  description=option.description,
436
481
  required=option.required,
437
482
  additional_attributes=option.additional_attributes,
483
+ ui=ManifestOptionUI(
484
+ control_type=option.control_type,
485
+ hidden_from=option.hidden_from,
486
+ ) if option.control_type or option.hidden_from else None,
438
487
  )
439
488
 
440
489
  def to_option(self) -> Option:
@@ -481,8 +530,44 @@ class ManifestOption(BaseModel):
481
530
  description=self.description,
482
531
  required=self.required,
483
532
  additional_attributes=self.additional_attributes,
533
+ control_type=self.ui.control_type if self.ui else None,
534
+ hidden_from=self.ui.hidden_from if self.ui else None,
484
535
  )
485
536
 
537
+ class ManifestValidation(BaseModel):
538
+ """
539
+ Validation rules for options in the manifest.
540
+
541
+ You can import the `ManifestValidation` class directly from `cloud`:
542
+
543
+ ```python
544
+ from nextmv.cloud import ManifestValidation
545
+ ```
546
+
547
+ Parameters
548
+ ----------
549
+ enforce : str, default="none"
550
+ The enforcement level for the validation rules. This can be set to
551
+ "none" or "all". If set to "none", no validation will be performed
552
+ on the options prior to creating a run. If set to "all", all validation
553
+ rules will be enforced on the options, and runs will not be created
554
+ if any of the rules of the options are violated.
555
+
556
+ Examples
557
+ --------
558
+ >>> from nextmv.cloud import ManifestValidation
559
+ >>> validation = ManifestValidation(enforce="all")
560
+ >>> validation.enforce
561
+ 'all'
562
+ """
563
+
564
+ enforce: str = "none"
565
+ """The enforcement level for the validation rules.
566
+ This can be set to "none" or "all". If set to "none", no validation will
567
+ be performed on the options prior to creating a run. If set to "all", all
568
+ validation rules will be enforced on the options, and runs will not be
569
+ created if any of the rules of the options are violated.
570
+ """
486
571
 
487
572
  class ManifestOptions(BaseModel):
488
573
  """
@@ -501,12 +586,16 @@ class ManifestOptions(BaseModel):
501
586
  items : Optional[list[ManifestOption]], default=None
502
587
  The actual list of options for the decision model. An option
503
588
  is a parameter that configures the decision model.
589
+ validation: Optional[ManifestValidation], default=None
590
+ Optional validation rules for all options.
591
+
504
592
 
505
593
  Examples
506
594
  --------
507
595
  >>> from nextmv.cloud import ManifestOptions, ManifestOption
508
596
  >>> options_config = ManifestOptions(
509
597
  ... strict=True,
598
+ ... validation=ManifestValidation(enforce="all"),
510
599
  ... items=[
511
600
  ... ManifestOption(name="timeout", option_type="int", default=60),
512
601
  ... ManifestOption(name="vehicle_capacity", option_type="float", default=100.0)
@@ -520,12 +609,48 @@ class ManifestOptions(BaseModel):
520
609
 
521
610
  strict: Optional[bool] = False
522
611
  """If strict is set to `True`, only the listed options will be allowed."""
612
+ validation: Optional[ManifestValidation] = None
613
+ """Optional validation rules for all options."""
523
614
  items: Optional[list[ManifestOption]] = None
524
615
  """The actual list of options for the decision model.
525
616
 
526
617
  An option is a parameter that configures the decision model.
527
618
  """
528
619
 
620
+ @classmethod
621
+ def from_options(cls, options: Options, validation: OptionsEnforcement = None) -> "ManifestOptions":
622
+ """
623
+ Create a `ManifestOptions` from a `nextmv.Options`.
624
+
625
+ Parameters
626
+ ----------
627
+ options : nextmv.options.Options
628
+ The options to convert.
629
+ validation : Optional[OptionsEnforcement], default=None
630
+ Optional validation rules for the options. If provided, it will be
631
+ used to set the `validation` attribute of the `ManifestOptions`.
632
+
633
+ Returns
634
+ -------
635
+ ManifestOptions
636
+ The converted options.
637
+
638
+ Examples
639
+ --------
640
+ >>> from nextmv.options import Options, Option
641
+ >>> from nextmv.cloud import ManifestOptions
642
+ >>> sdk_options = Options(Option("max_vehicles", int, 5))
643
+ >>> manifest_options = ManifestOptions.from_options(sdk_options)
644
+ >>> manifest_options.items[0].name
645
+ 'max_vehicles'
646
+ """
647
+
648
+ items = [ManifestOption.from_option(option) for option in options.options]
649
+ return cls(
650
+ strict=validation.strict if validation else False,
651
+ validation=ManifestValidation(enforce="all" if validation and validation.validation_enforce else "none"),
652
+ items=items
653
+ )
529
654
 
530
655
  class ManifestConfiguration(BaseModel):
531
656
  """
@@ -852,16 +977,16 @@ class Manifest(BaseModel):
852
977
 
853
978
  if model_configuration.options is not None:
854
979
  manifest.configuration = ManifestConfiguration(
855
- options=ManifestOptions(
856
- strict=False,
857
- items=[ManifestOption.from_option(opt) for opt in model_configuration.options.options],
980
+ options=ManifestOptions.from_options(
981
+ options=model_configuration.options,
982
+ validation=model_configuration.options_enforcement,
858
983
  ),
859
984
  )
860
985
 
861
986
  return manifest
862
987
 
863
988
  @classmethod
864
- def from_options(cls, options: Options) -> "Manifest":
989
+ def from_options(cls, options: Options, validation: OptionsEnforcement = None) -> "Manifest":
865
990
  """
866
991
  Create a basic Python manifest from `nextmv.options.Options`.
867
992
 
@@ -882,6 +1007,9 @@ class Manifest(BaseModel):
882
1007
  ----------
883
1008
  options : nextmv.options.Options
884
1009
  The options to include in the manifest.
1010
+ validation : nextmv.options.OptionsEnforcement default=None
1011
+ The validation rules for the options. This is used to set the
1012
+ `validation` attribute of the `ManifestOptions`.
885
1013
 
886
1014
  Returns
887
1015
  -------
@@ -913,11 +1041,11 @@ class Manifest(BaseModel):
913
1041
  type=ManifestType.PYTHON,
914
1042
  python=ManifestPython(pip_requirements="requirements.txt"),
915
1043
  configuration=ManifestConfiguration(
916
- options=ManifestOptions(
917
- strict=False,
918
- items=[ManifestOption.from_option(opt) for opt in options.options],
1044
+ options= ManifestOptions.from_options(
1045
+ options=options,
1046
+ validation=validation
919
1047
  ),
920
- ),
1048
+ )
921
1049
  )
922
1050
 
923
1051
  return manifest
@@ -28,7 +28,7 @@ def _package(
28
28
  model_configuration: Optional[ModelConfiguration] = None,
29
29
  verbose: bool = False,
30
30
  ) -> tuple[str, str]:
31
- """Package the app into a tarball.."""
31
+ """Package the app into a tarball."""
32
32
 
33
33
  with tempfile.TemporaryDirectory(prefix="nextmv-temp-") as temp_dir:
34
34
  if manifest.type == ManifestType.PYTHON: