nextmv 0.10.3.dev0__py3-none-any.whl → 0.35.0__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 (61) hide show
  1. nextmv/__about__.py +1 -1
  2. nextmv/__entrypoint__.py +39 -0
  3. nextmv/__init__.py +57 -0
  4. nextmv/_serialization.py +96 -0
  5. nextmv/base_model.py +79 -9
  6. nextmv/cloud/__init__.py +71 -10
  7. nextmv/cloud/acceptance_test.py +888 -17
  8. nextmv/cloud/account.py +154 -10
  9. nextmv/cloud/application.py +3644 -437
  10. nextmv/cloud/batch_experiment.py +292 -33
  11. nextmv/cloud/client.py +354 -53
  12. nextmv/cloud/ensemble.py +247 -0
  13. nextmv/cloud/input_set.py +121 -4
  14. nextmv/cloud/instance.py +125 -0
  15. nextmv/cloud/package.py +474 -0
  16. nextmv/cloud/scenario.py +410 -0
  17. nextmv/cloud/secrets.py +234 -0
  18. nextmv/cloud/url.py +73 -0
  19. nextmv/cloud/version.py +174 -0
  20. nextmv/default_app/.gitignore +1 -0
  21. nextmv/default_app/README.md +32 -0
  22. nextmv/default_app/app.yaml +12 -0
  23. nextmv/default_app/input.json +5 -0
  24. nextmv/default_app/main.py +37 -0
  25. nextmv/default_app/requirements.txt +2 -0
  26. nextmv/default_app/src/__init__.py +0 -0
  27. nextmv/default_app/src/main.py +37 -0
  28. nextmv/default_app/src/visuals.py +36 -0
  29. nextmv/deprecated.py +47 -0
  30. nextmv/input.py +883 -78
  31. nextmv/local/__init__.py +5 -0
  32. nextmv/local/application.py +1263 -0
  33. nextmv/local/executor.py +1040 -0
  34. nextmv/local/geojson_handler.py +323 -0
  35. nextmv/local/local.py +97 -0
  36. nextmv/local/plotly_handler.py +61 -0
  37. nextmv/local/runner.py +274 -0
  38. nextmv/logger.py +80 -9
  39. nextmv/manifest.py +1472 -0
  40. nextmv/model.py +431 -0
  41. nextmv/options.py +968 -78
  42. nextmv/output.py +1363 -231
  43. nextmv/polling.py +287 -0
  44. nextmv/run.py +1623 -0
  45. nextmv/safe.py +145 -0
  46. nextmv/status.py +122 -0
  47. {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/METADATA +51 -288
  48. nextmv-0.35.0.dist-info/RECORD +50 -0
  49. {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/WHEEL +1 -1
  50. nextmv/cloud/status.py +0 -29
  51. nextmv/nextroute/__init__.py +0 -2
  52. nextmv/nextroute/check/__init__.py +0 -26
  53. nextmv/nextroute/check/schema.py +0 -141
  54. nextmv/nextroute/schema/__init__.py +0 -19
  55. nextmv/nextroute/schema/input.py +0 -52
  56. nextmv/nextroute/schema/location.py +0 -13
  57. nextmv/nextroute/schema/output.py +0 -136
  58. nextmv/nextroute/schema/stop.py +0 -61
  59. nextmv/nextroute/schema/vehicle.py +0 -68
  60. nextmv-0.10.3.dev0.dist-info/RECORD +0 -28
  61. {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/licenses/LICENSE +0 -0
nextmv/options.py CHANGED
@@ -1,16 +1,38 @@
1
- """Configuration for a run."""
1
+ """
2
+ Configuration management for application runs.
3
+
4
+ This module provides classes for handling configuration options for
5
+ applications. It supports reading options from command-line arguments,
6
+ environment variables, and default values in a prioritized manner. The module
7
+ includes classes for defining individual options (`Option`) and managing
8
+ collections of options (`Options`).
9
+
10
+ Classes
11
+ -------
12
+ Option
13
+ Class for defining individual options for configuration.
14
+ Options
15
+ Class for managing collections of options.
16
+ """
2
17
 
3
18
  import argparse
19
+ import builtins
20
+ import copy
21
+ import json
4
22
  import os
5
23
  from dataclasses import dataclass
6
- from typing import Any, Dict, Optional
24
+ from typing import Any
7
25
 
8
26
  from nextmv.base_model import BaseModel
27
+ from nextmv.deprecated import deprecated
9
28
 
10
29
 
11
30
  @dataclass
12
31
  class Parameter:
13
32
  """
33
+ !!! warning
34
+ `Parameter` is deprecated, use `Option` instead.
35
+
14
36
  Parameter that is used in a `Configuration`. When a parameter is required,
15
37
  it is a good practice to provide a default value for it. This is because
16
38
  the configuration will raise an error if a required parameter is not
@@ -21,18 +43,30 @@ class Parameter:
21
43
  ----------
22
44
  name : str
23
45
  The name of the parameter.
46
+
24
47
  param_type : type
25
48
  The type of the parameter.
49
+
26
50
  default : Any, optional
27
51
  The default value of the parameter. Even though this is optional, it is
28
52
  recommended to provide a default value for all parameters.
53
+
29
54
  description : str, optional
30
55
  An optional description of the parameter. This is useful for generating
31
56
  help messages for the configuration.
57
+
32
58
  required : bool, optional
33
59
  Whether the parameter is required. If a parameter is required, it will
34
- be an error to not provide a value for it, either trough a command-line
60
+ be an error to not provide a value for it, either through a command-line
35
61
  argument, an environment variable or a default value.
62
+
63
+ choices : list[Optional[Any]], optional
64
+ Limits values to a specific set of choices.
65
+
66
+ Examples
67
+ --------
68
+ >>> from nextmv.options import Parameter
69
+ >>> parameter = Parameter("timeout", int, 60, "The maximum timeout in seconds", required=True)
36
70
  """
37
71
 
38
72
  name: str
@@ -40,74 +74,778 @@ class Parameter:
40
74
  param_type: type
41
75
  """The type of the parameter."""
42
76
 
43
- default: Optional[Any] = None
77
+ default: Any | None = None
44
78
  """The default value of the parameter. Even though this is optional, it is
45
79
  recommended to provide a default value for all parameters."""
46
- description: Optional[str] = None
80
+ description: str | None = None
47
81
  """An optional description of the parameter. This is useful for generating
48
82
  help messages for the configuration."""
49
83
  required: bool = False
50
84
  """Whether the parameter is required. If a parameter is required, it will
51
85
  be an error to not provide a value for it, either trough a command-line
52
86
  argument, an environment variable or a default value."""
87
+ choices: list[Any | None] = None
88
+ """Limits values to a specific set of choices."""
89
+
90
+ def __post_init__(self):
91
+ """
92
+ Post-initialization hook that marks this class as deprecated.
93
+
94
+ This method is automatically called after the object is initialized.
95
+ It displays a deprecation warning to inform users to use the `Option` class instead.
96
+ """
97
+ deprecated(
98
+ name="Parameter",
99
+ reason="`Parameter` is deprecated, use `Option` instead",
100
+ )
101
+
102
+ @classmethod
103
+ def from_dict(cls, data: dict[str, Any]) -> "Parameter":
104
+ """
105
+ !!! warning
106
+ `Parameter` is deprecated, use `Option` instead.
107
+ `Parameter.from_dict` -> `Option.from_dict`
108
+
109
+ Creates an instance of `Parameter` from a dictionary.
110
+
111
+ Parameters
112
+ ----------
113
+ data : dict[str, Any]
114
+ The dictionary representation of a parameter.
115
+
116
+ Returns
117
+ -------
118
+ Parameter
119
+ An instance of `Parameter`.
120
+ """
121
+
122
+ deprecated(
123
+ name="Parameter.from_dict",
124
+ reason="`Parameter` is deprecated, use `Option` instead. Parameter.from_dict -> Option.from_dict",
125
+ )
126
+
127
+ param_type_string = data["param_type"]
128
+ param_type = getattr(builtins, param_type_string.split("'")[1])
129
+
130
+ return Parameter(
131
+ name=data["name"],
132
+ param_type=param_type,
133
+ default=data.get("default"),
134
+ description=data.get("description"),
135
+ required=data.get("required", False),
136
+ choices=data.get("choices"),
137
+ )
138
+
139
+ def to_dict(self) -> dict[str, Any]:
140
+ """
141
+ !!! warning
142
+ `Parameter` is deprecated, use `Option` instead.
143
+ `Parameter.to_dict` -> `Option.to_dict`
144
+
145
+ Converts the parameter to a dict.
146
+
147
+ Returns
148
+ -------
149
+ dict[str, Any]
150
+ The parameter as a dict with its name, type, default value,
151
+ description, required flag, and choices.
152
+
153
+ Examples
154
+ --------
155
+ >>> param = Parameter("timeout", int, 60, "Maximum time in seconds", True)
156
+ >>> param_dict = param.to_dict()
157
+ >>> param_dict["name"]
158
+ 'timeout'
159
+ >>> param_dict["default"]
160
+ 60
161
+ """
162
+
163
+ deprecated(
164
+ name="Parameter.to_dict",
165
+ reason="`Parameter` is deprecated, use `Option` instead. Parameter.to_dict -> Option.to_dict",
166
+ )
167
+
168
+ return {
169
+ "name": self.name,
170
+ "param_type": str(self.param_type),
171
+ "default": self.default,
172
+ "description": self.description,
173
+ "required": self.required,
174
+ "choices": self.choices,
175
+ }
176
+
177
+
178
+ @dataclass
179
+ class Option:
180
+ """
181
+ An option that is used in `Options`.
182
+
183
+ You can import the `Option` class directly from `nextmv`:
184
+
185
+ ```python
186
+ from nextmv import Option
187
+ ```
188
+
189
+ Options provide a way to configure application behavior. When an `Option`
190
+ is required, it is a good practice to provide a default value for it. This
191
+ is because the `Options` will raise an error if a required `Option` is not
192
+ provided through a command-line argument, an environment variable or a
193
+ default value.
194
+
195
+ Parameters
196
+ ----------
197
+ name : str
198
+ `name`. The name of the option.
199
+ option_type : type
200
+ The type of the option.
201
+ default : Any, optional
202
+ The default value of the option. Even though this is optional, it is
203
+ recommended to provide a default value for all options.
204
+ description : str, optional
205
+ An optional description of the option. This is useful for generating
206
+ help messages for the `Options`.
207
+ required : bool, optional
208
+ Whether the option is required. If an option is required, it will
209
+ be an error to not provide a value for it, either through a command-line
210
+ argument, an environment variable or a default value.
211
+ choices : list[Optional[Any]], optional
212
+ Limits values to a specific set of choices.
213
+ additional_attributes : dict[str, Any], optional
214
+ Optional additional attributes for the option. The Nextmv Cloud may
215
+ perform validation on these attributes. For example, the maximum length
216
+ of a string or the maximum value of an integer. These additional
217
+ attributes will be shown in the help message of the `Options`.
218
+ control_type : str, optional
219
+ The type of control to use for the option in the Nextmv Cloud UI. This is
220
+ useful for defining how the option should be presented in the Nextmv
221
+ Cloud UI. Current control types include "input", "select", "slider", and
222
+ "toggle". This attribute is not used in the local `Options` class, but '
223
+ it is used in the Nextmv Cloud UI to define the type of control to use for
224
+ the option. This will be validated by the Nextmv Cloud, and availability
225
+ is based on options_type.
226
+ hidden_from : list[str], optional
227
+ A list of team roles to which this option will be hidden in the UI. For
228
+ example, if you want to hide an option from the "operator" role, you can
229
+ pass `hidden_from=["operator"]`.
230
+ display_name : str, optional
231
+ An optional display name for the option. This is useful for making
232
+ the option more user-friendly in the UI.
233
+
234
+ Examples
235
+ --------
236
+ ```python
237
+ from nextmv.options import Option
238
+ opt = Option("duration", str, "30s", description="solver duration", required=False)
239
+ opt.name
240
+ opt.default
241
+ ```
242
+ """
243
+
244
+ name: str
245
+ """The name of the option."""
246
+ option_type: type
247
+ """The type of the option."""
248
+
249
+ default: Any | None = None
250
+ """
251
+ The default value of the option. Even though this is optional, it is
252
+ recommended to provide a default value for all options.
253
+ """
254
+ description: str | None = None
255
+ """
256
+ An optional description of the option. This is useful for generating help
257
+ messages for the `Options`.
258
+ """
259
+ required: bool = False
260
+ """
261
+ Whether the option is required. If a option is required, it will be an
262
+ error to not provide a value for it, either trough a command-line argument,
263
+ an environment variable or a default value.
264
+ """
265
+ choices: list[Any] | None = None
266
+ """Limits values to a specific set of choices."""
267
+ additional_attributes: dict[str, Any] | None = None
268
+ """
269
+ Optional additional attributes for the option. The Nextmv Cloud may
270
+ perform validation on these attributes. For example, the maximum length of
271
+ a string or the maximum value of an integer. These additional attributes
272
+ will be shown in the help message of the `Options`.
273
+ """
274
+ control_type: str | None = None
275
+ """
276
+ The type of control to use for the option in the Nextmv Cloud UI. This is
277
+ useful for defining how the option should be presented in the Nextmv
278
+ Cloud UI. Current control types include "input", "select", "slider", and
279
+ "toggle". This attribute is not used in the local `Options` class, but it
280
+ is used in the Nextmv Cloud UI to define the type of control to use for
281
+ the option. This will be validated by the Nextmv Cloud, and availability
282
+ is based on options_type.
283
+ """
284
+ hidden_from: list[str] | None = None
285
+ """
286
+ A list of team roles for which this option will be hidden in the UI. For
287
+ example, if you want to hide an option from the "operator" role, you can
288
+ pass `hidden_from=["operator"]`.
289
+ """
290
+ display_name: str | None = None
291
+ """
292
+ An optional display name for the option. This is useful for making
293
+ the option more user-friendly in the UI.
294
+ """
295
+
296
+ @classmethod
297
+ def from_dict(cls, data: dict[str, Any]) -> "Option":
298
+ """
299
+ Creates an instance of `Option` from a dictionary.
300
+
301
+ Parameters
302
+ ----------
303
+
304
+ data: dict[str, Any]
305
+ The dictionary representation of an option.
306
+
307
+ Returns
308
+ -------
309
+ Option
310
+ An instance of `Option`.
311
+
312
+ Examples
313
+ --------
314
+ >>> opt_dict = {"name": "timeout", "option_type": "<class 'int'>", "default": 60}
315
+ >>> option = Option.from_dict(opt_dict)
316
+ >>> option.name
317
+ 'timeout'
318
+ >>> option.default
319
+ 60
320
+ """
321
+
322
+ option_type_string = data["option_type"]
323
+ option_type = getattr(builtins, option_type_string.split("'")[1])
324
+
325
+ return cls(
326
+ name=data["name"],
327
+ option_type=option_type,
328
+ default=data.get("default"),
329
+ description=data.get("description"),
330
+ required=data.get("required", False),
331
+ choices=data.get("choices"),
332
+ additional_attributes=data.get("additional_attributes"),
333
+ control_type=data.get("control_type"),
334
+ hidden_from=data.get("hidden_from"),
335
+ display_name=data.get("display_name"),
336
+ )
337
+
338
+ def to_dict(self) -> dict[str, Any]:
339
+ """
340
+ Converts the option to a dict.
341
+
342
+ Returns
343
+ -------
344
+ dict[str, Any]
345
+ The option as a dict with all its attributes.
346
+
347
+ Examples
348
+ --------
349
+ >>> opt = Option("duration", str, "30s", description="solver duration")
350
+ >>> opt_dict = opt.to_dict()
351
+ >>> opt_dict["name"]
352
+ 'duration'
353
+ >>> opt_dict["default"]
354
+ '30s'
355
+ """
356
+
357
+ return {
358
+ "name": self.name,
359
+ "option_type": str(self.option_type),
360
+ "default": self.default,
361
+ "description": self.description,
362
+ "required": self.required,
363
+ "choices": self.choices,
364
+ "additional_attributes": self.additional_attributes,
365
+ "control_type": self.control_type,
366
+ "hidden_from": self.hidden_from,
367
+ "display_name": self.display_name,
368
+ }
53
369
 
54
370
 
55
371
  class Options:
56
372
  """
57
- Options for a run. To initialize options, pass in one or more `Parameter`
58
- objects. The options will look for the values of the given parameters in
59
- the following order: command-line arguments, environment variables, default
60
- values.
373
+ Options container for application configuration.
61
374
 
62
- Once the options are initialized, you can access the parameters as
63
- attributes of the `Options` object. For example, if you have a
64
- `Parameter` object with the name "duration", you can access it as
375
+ You can import the `Options` class directly from `nextmv`:
376
+
377
+ ```python
378
+ from nextmv import Options
379
+ ```
380
+
381
+ To initialize options, pass in one or more `Option` objects. The options
382
+ will look for the values of the given parameters in the following order:
383
+ command-line arguments, environment variables, default values.
384
+
385
+ Once the `Options` are initialized, you can access the underlying options as
386
+ attributes of the `Options` object. For example, if you have an
387
+ `Option` object with the name "duration", you can access it as
65
388
  `options.duration`.
66
389
 
67
- If a parameter is required and not provided through a command-line
390
+ If an option is required and not provided through a command-line
68
391
  argument, an environment variable or a default value, an error will be
69
392
  raised.
70
393
 
71
394
  Options works as a Namespace, so you can assign new attributes to it. For
72
395
  example, you can do `options.foo = "bar"`.
73
396
 
397
+ Options are parsed from the given sources when an attribute is accessed.
398
+ Alternatively, you can call the `parse` method to parse the options
399
+ manually. Options that are _not_ parsed may be merged with other unparsed
400
+ options, by using the `merge` method. Once options are parsed, they cannot
401
+ be merged with other options. After options are parsed, you may get the
402
+ help message by running the script with the `-h/--help` flag.
403
+
74
404
  Parameters
75
405
  ----------
76
- *parameters : Parameter
77
- The parameters that are used in the options. At least one
78
- parameter is required.
406
+ *options : Option
407
+ The list of `Option` objects that are used in the options. At least one
408
+ option is required.
79
409
 
80
410
  Examples
81
411
  --------
82
412
  >>> import nextmv
83
413
  >>>
84
414
  >>> options = nextmv.Options(
85
- ... nextmv.Parameter("duration", str, "30s", description="solver duration", required=True),
86
- ... nextmv.Parameter("threads", int, 4, description="computer threads", required=True),
415
+ ... nextmv.Option("duration", str, "30s", description="solver duration", required=False),
416
+ ... nextmv.Option("threads", int, 4, description="computer threads", required=False),
87
417
  ... )
88
418
  >>>
89
419
  >>> print(options.duration, options.threads, options.to_dict())
90
-
91
420
  30s 4 {"duration": "30s", "threads": 4}
92
421
 
93
422
  Raises
94
423
  ------
95
424
  ValueError
96
- If no parameters are provided.
97
- ValueError
98
- If a required parameter is not provided through a command-line
425
+ If a required option is not provided through a command-line
99
426
  argument, an environment variable or a default value.
100
427
  TypeError
101
- If a parameter is not a `Parameter` object.
428
+ If an option is not either an `Option` or `Parameter` (deprecated)
429
+ object.
102
430
  ValueError
103
431
  If an environment variable is not of the type of the corresponding
104
432
  parameter.
105
433
  """
106
434
 
107
- def __init__(self, *parameters: Parameter):
108
- """Initializes the options."""
435
+ PARSED = False
436
+
437
+ def __init__(self, *options: Option):
438
+ """
439
+ Initialize an Options instance with the provided option objects.
440
+
441
+ Parameters
442
+ ----------
443
+ *options : Option
444
+ The option objects to include in this Options instance.
445
+ """
446
+ self.options = copy.deepcopy(options)
447
+
448
+ def to_dict(self) -> dict[str, Any]:
449
+ """
450
+ Converts the options to a dict. As a side effect, this method parses
451
+ the options if they have not been parsed yet. See the `parse` method
452
+ for more information.
453
+
454
+ Returns
455
+ -------
456
+ dict[str, Any]
457
+ The options as a dict where keys are option names and values
458
+ are the corresponding option values.
459
+
460
+ Examples
461
+ --------
462
+ >>> options = Options(Option("duration", str, "30s"), Option("threads", int, 4))
463
+ >>> options_dict = options.to_dict()
464
+ >>> options_dict["duration"]
465
+ '30s'
466
+ >>> options_dict["threads"]
467
+ 4
468
+ """
469
+
470
+ if not self.PARSED:
471
+ self._parse()
472
+
473
+ class model(BaseModel):
474
+ config: dict[str, Any]
475
+
476
+ self_dict = copy.deepcopy(self.__dict__)
477
+
478
+ rm_keys = ["PARSED", "options"]
479
+ for key in rm_keys:
480
+ if key in self_dict:
481
+ self_dict.pop(key)
482
+
483
+ m = model.from_dict(data={"config": self_dict})
484
+
485
+ return m.to_dict()["config"]
486
+
487
+ def to_dict_cloud(self) -> dict[str, str]:
488
+ """
489
+ Converts the options to a dict that can be used in the Nextmv Cloud.
490
+
491
+ Cloud has a hard requirement that options are passed as strings. This
492
+ method converts the options to a dict with string values. This is
493
+ useful for passing options to the Nextmv Cloud.
494
+
495
+ As a side effect, this method parses the options if they have not been
496
+ parsed yet. See the `parse` method for more information.
497
+
498
+ Returns
499
+ -------
500
+ dict[str, str]
501
+ The options as a dict with string values where non-string values
502
+ are JSON-encoded.
503
+
504
+ Examples
505
+ --------
506
+ >>> options = Options(Option("duration", str, "30s"), Option("threads", int, 4))
507
+ >>> cloud_dict = options.to_dict_cloud()
508
+ >>> cloud_dict["duration"]
509
+ '30s'
510
+ >>> cloud_dict["threads"]
511
+ '4'
512
+ """
513
+
514
+ options_dict = self.to_dict()
515
+
516
+ cloud_dict = {}
517
+ for k, v in options_dict.items():
518
+ if isinstance(v, str):
519
+ cloud_dict[k] = v
520
+ else:
521
+ cloud_dict[k] = json.dumps(v)
522
+
523
+ return cloud_dict
524
+
525
+ def parameters_dict(self) -> list[dict[str, Any]]:
526
+ """
527
+ !!! warning
528
+ `Parameter` is deprecated, use `Option` instead. `Options.parameters_dict` -> `Options.options_dict`
529
+
530
+ Converts the options to a list of dicts. Each dict is the dict
531
+ representation of a `Parameter`.
532
+
533
+ Returns
534
+ -------
535
+ list[dict[str, Any]]
536
+ The list of dictionaries (parameter entries).
537
+ """
538
+
539
+ deprecated(
540
+ name="Options.parameters_dict",
541
+ reason="`Parameter` is deprecated, use `Option` instead. Options.parameters_dict -> Options.options_dict",
542
+ )
543
+
544
+ return [param.to_dict() for param in self.options]
545
+
546
+ def options_dict(self) -> list[dict[str, Any]]:
547
+ """
548
+ Converts the `Options` to a list of dicts. Each dict is the dict
549
+ representation of an `Option`.
550
+
551
+ Returns
552
+ -------
553
+ list[dict[str, Any]]
554
+ The list of dictionaries (`Option` entries).
555
+
556
+ Examples
557
+ --------
558
+ >>> options = Options(Option("duration", str, "30s"), Option("threads", int, 4))
559
+ >>> opt_dicts = options.options_dict()
560
+ >>> opt_dicts[0]["name"]
561
+ 'duration'
562
+ >>> opt_dicts[1]["name"]
563
+ 'threads'
564
+ """
565
+
566
+ return [opt.to_dict() for opt in self.options]
567
+
568
+ def parse(self):
569
+ """
570
+ Parses the options using command-line arguments, environment variables
571
+ and default values, in that order. Under the hood, the `argparse`
572
+ library is used. When command-line arguments are parsed, the help menu
573
+ is created, thus parsing Options more than once may result in
574
+ unexpected behavior.
575
+
576
+ This method is called automatically when an attribute is accessed. If
577
+ you want to parse the options manually, you can call this method.
578
+
579
+ After Options have been parsed, they cannot be merged with other
580
+ Options. If you need to merge Options, do so before parsing them.
581
+
582
+ Examples
583
+ -------
584
+ >>> import nextmv
585
+ >>>
586
+ >>> options = nextmv.Options(
587
+ ... nextmv.Option("duration", str, "30s", description="solver duration", required=False),
588
+ ... nextmv.Option("threads", int, 4, description="computer threads", required=False),
589
+ ... )
590
+ >>> options.parse() # Does not raise an exception.
591
+
592
+ >>> import nextmv
593
+ >>>
594
+ >>> options = nextmv.Options(
595
+ ... nextmv.Option("duration", str, "30s", description="solver duration", required=False),
596
+ ... nextmv.Option("threads", int, 4, description="computer threads", required=False),
597
+ ... )
598
+ >>> print(options.duration) # Parses the options.
599
+ >>> options.parse() # Raises an exception because the options have already been parsed.
600
+
601
+ Raises
602
+ ------
603
+ RuntimeError
604
+ If the options have already been parsed.
605
+ ValueError
606
+ If a required option is not provided through a command-line
607
+ argument, an environment variable or a default value.
608
+ TypeError
609
+ If an option is not an `Option` or `Parameter` (deprecated) object.
610
+ ValueError
611
+ If an environment variable is not of the type of the corresponding
612
+ parameter.
613
+ """
614
+
615
+ if self.PARSED:
616
+ raise RuntimeError("options have already been parsed")
617
+
618
+ self._parse()
619
+
620
+ def merge(self, *new: "Options", skip_parse: bool = False) -> "Options":
621
+ """
622
+ Merges the current options with the new options.
623
+
624
+ This method cannot be used if any of the options have been parsed. When
625
+ options are parsed, values are read from the command-line arguments,
626
+ environment variables and default values. Merging options after parsing
627
+ would result in unpredictable behavior.
628
+
629
+ Parameters
630
+ ----------
631
+ new : Options
632
+ The new options to merge with the current options. At least one new option set
633
+ is required to merge. Multiple `Options` instances can be passed.
634
+ skip_parse : bool, optional
635
+ If True, the merged options will not be parsed after merging. This is useful
636
+ if you want to merge further options after this merge. The default is False.
637
+
638
+ Returns
639
+ -------
640
+ Options
641
+ The merged options object (self).
642
+
643
+ Raises
644
+ ------
645
+ RuntimeError
646
+ If the current options have already been parsed.
647
+ RuntimeError
648
+ If the new options have already been parsed.
649
+
650
+ Examples
651
+ --------
652
+ >>> opt1 = Options(Option("duration", str, "30s"))
653
+ >>> opt2 = Options(Option("threads", int, 4))
654
+ >>> opt3 = Options(Option("verbose", bool, False))
655
+ >>> merged = opt1.merge(opt2, opt3)
656
+ >>> merged.duration
657
+ '30s'
658
+ >>> merged.threads
659
+ 4
660
+ >>> merged.verbose
661
+ False
662
+ """
663
+
664
+ if self.PARSED:
665
+ raise RuntimeError(
666
+ "base options have already been parsed, cannot merge. See `Options.parse()` for more information."
667
+ )
668
+
669
+ if not new:
670
+ raise ValueError("at least one new Options instance is required to merge")
671
+
672
+ for i, opt in enumerate(new):
673
+ if not isinstance(opt, Options):
674
+ raise TypeError(f"expected an <Options> object, but got {type(opt)} in index {i}")
675
+ if opt.PARSED:
676
+ raise RuntimeError(
677
+ f"new options at index {i} have already been parsed, cannot merge. "
678
+ + "See `Options.parse()` for more information."
679
+ )
680
+
681
+ # Add the new options to the current options.
682
+ for n in new:
683
+ self.options += n.options
684
+
685
+ if not skip_parse:
686
+ self.parse()
687
+
688
+ return self
689
+
690
+ @classmethod
691
+ def from_dict(cls, data: dict[str, Any]) -> "Options":
692
+ """
693
+ Creates an instance of `Options` from a dictionary.
694
+
695
+ The dictionary should have the following structure:
696
+
697
+ ```python
698
+ {
699
+ "duration": "30",
700
+ "threads": 4,
701
+ }
702
+ ```
703
+
704
+ Parameters
705
+ ----------
706
+ data : dict[str, Any]
707
+ The dictionary representation of the options.
708
+
709
+ Returns
710
+ -------
711
+ Options
712
+ An instance of `Options` with options created from the dictionary.
713
+
714
+ Examples
715
+ --------
716
+ >>> data = {"duration": "30s", "threads": 4}
717
+ >>> options = Options.from_dict(data)
718
+ >>> options.duration
719
+ '30s'
720
+ >>> options.threads
721
+ 4
722
+ """
723
+
724
+ options = []
725
+ for key, value in data.items():
726
+ opt = Option(name=key, option_type=type(value), default=value)
727
+ options.append(opt)
728
+
729
+ return cls(*options)
730
+
731
+ @classmethod
732
+ def from_parameters_dict(cls, parameters_dict: list[dict[str, Any]]) -> "Options":
733
+ """
734
+ !!! warning
735
+
736
+ `Parameter` is deprecated, use `Option` instead.
737
+ `Options.from_parameters_dict` -> `Options.from_options_dict`
738
+
739
+ Creates an instance of `Options` from parameters in dict form. Each
740
+ entry is the dict representation of a `Parameter`.
741
+
742
+ Parameters
743
+ ----------
744
+ parameters_dict : list[dict[str, Any]]
745
+ The list of dictionaries (parameter entries).
746
+
747
+ Returns
748
+ -------
749
+ Options
750
+ An instance of `Options`.
751
+ """
752
+
753
+ deprecated(
754
+ name="Options.from_parameters_dict",
755
+ reason="`Parameter` is deprecated, use `Option` instead. "
756
+ "Options.from_parameters_dict -> Options.from_options_dict",
757
+ )
758
+
759
+ parameters = []
760
+ for parameter_dict in parameters_dict:
761
+ parameter = Parameter.from_dict(parameter_dict)
762
+ parameters.append(parameter)
763
+
764
+ return cls(*parameters)
765
+
766
+ @classmethod
767
+ def from_options_dict(cls, options_dict: list[dict[str, Any]]) -> "Options":
768
+ """
769
+ Creates an instance of `Options` from a list of `Option` objects in
770
+ dict form. Each entry is the dict representation of an `Option`.
771
+
772
+ Parameters
773
+ ----------
774
+ options_dict : list[dict[str, Any]]
775
+ The list of dictionaries (`Option` entries).
776
+
777
+ Returns
778
+ -------
779
+ Options
780
+ An instance of `Options`.
781
+
782
+ Examples
783
+ --------
784
+ >>> options_dict = [
785
+ ... {"name": "duration", "option_type": "<class 'str'>", "default": "30s"},
786
+ ... {"name": "threads", "option_type": "<class 'int'>", "default": 4}
787
+ ... ]
788
+ >>> options = Options.from_options_dict(options_dict)
789
+ >>> options.duration
790
+ '30s'
791
+ >>> options.threads
792
+ 4
793
+ """
794
+
795
+ options = []
796
+ for opt_dict in options_dict:
797
+ opt = Option.from_dict(opt_dict)
798
+ options.append(opt)
109
799
 
110
- if not parameters:
800
+ return cls(*options)
801
+
802
+ def __getattr__(self, name: str) -> Any:
803
+ """
804
+ Gets an attribute of the options.
805
+
806
+ This is called when an attribute is accessed. It parses the options
807
+ if they have not been parsed yet.
808
+
809
+ Parameters
810
+ ----------
811
+ name : str
812
+ The name of the attribute to get.
813
+
814
+ Returns
815
+ -------
816
+ Any
817
+ The value of the attribute.
818
+ """
819
+
820
+ if not self.PARSED:
821
+ self._parse()
822
+
823
+ return super().__getattribute__(name)
824
+
825
+ def _parse(self): # noqa: C901
826
+ """
827
+ Parses the options using command-line arguments, environment variables
828
+ and default values.
829
+
830
+ This is an internal method that is called by `parse()` and `__getattr__()`.
831
+ It sets the `PARSED` flag to True and sets the values of the options
832
+ based on command-line arguments, environment variables, and default values.
833
+
834
+ Raises
835
+ ------
836
+ ValueError
837
+ If a required option is not provided through a command-line
838
+ argument, an environment variable or a default value.
839
+ TypeError
840
+ If an option is not an `Option` or `Parameter` (deprecated) object.
841
+ ValueError
842
+ If an environment variable is not of the type of the corresponding
843
+ parameter.
844
+ """
845
+
846
+ self.PARSED = True
847
+
848
+ if not self.options:
111
849
  return
112
850
 
113
851
  parser = argparse.ArgumentParser(
@@ -115,112 +853,264 @@ class Options:
115
853
  usage="%(prog)s [options]",
116
854
  description="Options for %(prog)s. Use command-line arguments (highest precedence) "
117
855
  + "or environment variables.",
856
+ allow_abbrev=False,
118
857
  )
119
- params_by_name: Dict[str, Parameter] = {}
858
+ options_by_field_name: dict[str, Option] = {}
859
+
860
+ for ix, option in enumerate(self.options):
861
+ if not isinstance(option, Option) and not isinstance(option, Parameter):
862
+ raise TypeError(
863
+ f"expected an <Option> (or deprecated <Parameter>) object, but got {type(option)} in index {ix}"
864
+ )
120
865
 
121
- for p, param in enumerate(parameters):
122
- if not isinstance(param, Parameter):
123
- raise TypeError(f"expected a <Parameter> object, but got {type(param)} in index {p}")
866
+ # See comment below about ipykernel adding a `-f` argument. We
867
+ # restrict options from having the name 'f' or 'fff' for that
868
+ # reason.
869
+ if option.name == "f" or option.name == "fff":
870
+ raise ValueError("option names 'f', 'fff' are reserved for internal use")
871
+
872
+ if option.name == "PARSED":
873
+ raise ValueError("option name 'PARSED' is reserved for internal use")
874
+
875
+ # Remove any leading '-'. This is in line with argparse's behavior.
876
+ option.name = option.name.lstrip("-")
877
+
878
+ kwargs = {
879
+ "type": self._option_type(option) if self._option_type(option) is not bool else str,
880
+ "help": self._description(option),
881
+ }
882
+
883
+ if option.choices is not None:
884
+ kwargs["choices"] = option.choices
124
885
 
125
886
  parser.add_argument(
126
- f"-{param.name}",
127
- f"--{param.name}",
128
- type=param.param_type if param.param_type is not bool else str,
129
- help=self._description(param),
887
+ f"-{option.name}",
888
+ f"--{option.name}",
889
+ **kwargs,
130
890
  )
131
- params_by_name[param.name] = param
132
891
 
892
+ # Store the option by its field name for easy access later. argparse
893
+ # replaces '-' with '_', so we do the same here.
894
+ options_by_field_name[option.name.replace("-", "_")] = option
895
+
896
+ # The ipkyernel uses a `-f` argument by default that it passes to the
897
+ # execution. We don't want to ignore this argument because we get an
898
+ # error. Fix source: https://stackoverflow.com/a/56349168
899
+ parser.add_argument(
900
+ "-f",
901
+ "--f",
902
+ "--fff",
903
+ help=argparse.SUPPRESS,
904
+ default="1",
905
+ )
133
906
  args = parser.parse_args()
134
907
 
135
908
  for arg in vars(args):
136
- param = params_by_name[arg]
909
+ if arg == "fff" or arg == "f":
910
+ continue
137
911
 
138
- # First, attempt to set the value of a parameter from the
912
+ option = options_by_field_name[arg]
913
+
914
+ # First, attempt to set the value of an option from the
139
915
  # command-line args.
140
916
  arg_value = getattr(args, arg)
141
917
  if arg_value is not None:
142
- value = self._parameter_value(param, arg_value)
918
+ value = self._option_value(option, arg_value)
143
919
  setattr(self, arg, value)
144
920
  continue
145
921
 
146
- # Second, attempt to set the value of a parameter from the
922
+ # Second, attempt to set the value of am option from the
147
923
  # environment variables.
148
924
  upper_name = arg.upper()
149
925
  env_value = os.getenv(upper_name)
150
926
  if env_value is not None:
151
927
  try:
152
- typed_env_value = param.param_type(env_value) if param.param_type is not bool else env_value
928
+ typed_env_value = (
929
+ self._option_type(option)(env_value) if self._option_type(option) is not bool else env_value
930
+ )
153
931
  except ValueError:
154
- raise ValueError(f'environment variable "{upper_name}" is not of type {param.param_type}') from None
932
+ raise ValueError(
933
+ f'environment variable "{upper_name}" is not of type {self._option_type(option)}'
934
+ ) from None
155
935
 
156
- value = self._parameter_value(param, typed_env_value)
936
+ value = self._option_value(option, typed_env_value)
157
937
  setattr(self, arg, value)
158
938
  continue
159
939
 
160
- # Finally, attempt to set the value of a parameter from the default
161
- # value.
162
- if param.default is not None:
163
- setattr(self, arg, param.default)
164
- continue
165
-
166
- if not param.required:
940
+ # Finally, attempt to set a default value. This is only allowed
941
+ # for non-required options.
942
+ if not option.required:
943
+ setattr(self, arg, option.default)
167
944
  continue
168
945
 
169
- # At this point, the parameter is required and no value was
946
+ # At this point, the option is required and no value was
170
947
  # provided
171
948
  raise ValueError(
172
- f'parameter "{arg}" is required but not provided through: command-line args, env vars, or default value'
949
+ f'option "{arg}" is required but not provided through: command-line args, env vars, or default value'
173
950
  )
174
951
 
175
- def to_dict(self) -> Dict[str, Any]:
952
+ def _description(self, option: Option) -> str:
176
953
  """
177
- Converts the options to a dict.
954
+ Returns a description for an option.
955
+
956
+ This is an internal method used to create the help text for options
957
+ in the command-line argument parser.
958
+
959
+ Parameters
960
+ ----------
961
+ option : Option
962
+ The option to get the description for.
178
963
 
179
964
  Returns
180
965
  -------
181
- Dict[str, Any]
182
- The options as a dict.
966
+ str
967
+ A formatted description string for the option.
183
968
  """
184
969
 
185
- class model(BaseModel):
186
- config: Dict[str, Any]
970
+ description = ""
971
+ if isinstance(option, Parameter):
972
+ description = "DEPRECATED (initialized with <Parameter>, use <Option> instead) "
187
973
 
188
- m = model.from_dict(data={"config": self.__dict__})
974
+ description += f"[env var: {option.name.upper()}]"
189
975
 
190
- return m.to_dict()["config"]
976
+ if option.required:
977
+ description += " (required)"
191
978
 
192
- @staticmethod
193
- def _description(param: Parameter) -> str:
194
- """Returns a description for a parameter."""
979
+ if option.default is not None:
980
+ description += f" (default: {option.default})"
195
981
 
196
- description = f"[env var: {param.name.upper()}]"
982
+ description += f" (type: {self._option_type(option).__name__})"
197
983
 
198
- if param.required:
199
- description += " (required)"
984
+ if isinstance(option, Option) and option.additional_attributes is not None:
985
+ description += f" (additional attributes: {option.additional_attributes})"
986
+
987
+ if isinstance(option, Option) and option.control_type is not None:
988
+ description += f" (control type: {option.control_type})"
200
989
 
201
- if param.default is not None:
202
- description += f" (default: {param.default})"
990
+ if isinstance(option, Option) and option.hidden_from:
991
+ description += f" (hidden from: {', '.join(option.hidden_from)})"
203
992
 
204
- description += f" (type: {param.param_type.__name__})"
993
+ if isinstance(option, Option) and option.display_name is not None:
994
+ description += f" (display name: {option.display_name})"
205
995
 
206
- if param.description is not None:
207
- description += f": {param.description}"
996
+ if option.description is not None and option.description != "":
997
+ description += f": {option.description}"
208
998
 
209
999
  return description
210
1000
 
211
- @staticmethod
212
- def _parameter_value(parameter: Parameter, value: Any) -> Any:
213
- """Handles how the value of a parameter is extracted."""
1001
+ def _option_value(self, option: Option, value: Any) -> Any:
1002
+ """
1003
+ Handles how the value of an option is extracted.
1004
+
1005
+ This is an internal method that converts string values to boolean
1006
+ values for boolean options.
214
1007
 
215
- param_type = parameter.param_type
216
- if param_type is not bool:
1008
+ Parameters
1009
+ ----------
1010
+ option : Option
1011
+ The option to extract the value for.
1012
+ value : Any
1013
+ The value to extract.
1014
+
1015
+ Returns
1016
+ -------
1017
+ Any
1018
+ The extracted value. For boolean options, string values like
1019
+ "true", "1", "t", "y", and "yes" are converted to True, and
1020
+ other values are converted to False.
1021
+ """
1022
+
1023
+ opt_type = self._option_type(option)
1024
+ if opt_type is not bool:
217
1025
  return value
218
1026
 
219
1027
  value = str(value).lower()
220
1028
 
221
1029
  if value in ("true", "1", "t", "y", "yes"):
222
1030
  return True
223
- if value in ("false", "0", "f", "n", "no"):
224
- return False
225
1031
 
226
- raise argparse.ArgumentTypeError(f"invalid value for bool parameter '{parameter.name}': {value}")
1032
+ return False
1033
+
1034
+ @staticmethod
1035
+ def _option_type(option: Option | Parameter) -> type:
1036
+ """
1037
+ Get the type of an option.
1038
+
1039
+ This auxiliary function was introduced for backwards compatibility with
1040
+ the deprecated `Parameter` class. Once `Parameter` is removed, this function
1041
+ can be removed as well. When the function is removed, use the
1042
+ `option.option_type` attribute directly, instead of calling this function.
1043
+
1044
+ Parameters
1045
+ ----------
1046
+ option : Union[Option, Parameter]
1047
+ The option to get the type for.
1048
+
1049
+ Returns
1050
+ -------
1051
+ type
1052
+ The type of the option.
1053
+
1054
+ Raises
1055
+ ------
1056
+ TypeError
1057
+ If the option is not an `Option` or `Parameter` object.
1058
+ """
1059
+
1060
+ if isinstance(option, Option):
1061
+ return option.option_type
1062
+ elif isinstance(option, Parameter):
1063
+ return option.param_type
1064
+ else:
1065
+ raise TypeError(f"expected an <Option> (or deprecated <Parameter>) object, but got {type(option)}")
1066
+
1067
+
1068
+ class OptionsEnforcement:
1069
+ """
1070
+ OptionsEnforcement is a class that provides rules for how the options
1071
+ are enforced on Nextmv Cloud.
1072
+
1073
+ This class is used to enforce options in the Nextmv Cloud. It is not used
1074
+ in the local `Options` class, but it is used to control validation when a run
1075
+ is submitted to the Nextmv Cloud.
1076
+
1077
+ Parameters
1078
+ ----------
1079
+ strict: bool default = False
1080
+ If True, the options additional options that are configured will not
1081
+ pass validation. This means that only the options that are defined in the
1082
+ `Options` class will be allowed. If False, additional options that are
1083
+ not defined in the `Options` class will be allowed.
1084
+ validation_enforce: bool default = False
1085
+ If True, the options will be validated against your option configuration
1086
+ validation rules. If False, the options will not be validated.
1087
+ """
1088
+
1089
+ strict: bool = False
1090
+ """
1091
+ If True, the options additional options that are configured will not
1092
+ pass validation. This means that only the options that are defined in the
1093
+ `Options` class will be allowed. If False, additional options that are
1094
+ not defined in the `Options` class will be allowed.
1095
+ """
1096
+ validation_enforce: bool = False
1097
+ """
1098
+ If True, the options will be validated against your option configuration
1099
+ validation rules. If False, the options will not be validated.
1100
+ """
1101
+
1102
+ def __init__(self, strict: bool = False, validation_enforce: bool = False):
1103
+ """
1104
+ Initialize an OptionsEnforcement instance with the provided rules.
1105
+
1106
+ Parameters
1107
+ ----------
1108
+ strict : bool, optional
1109
+ If True, only options defined in the `Options` class will be allowed.
1110
+ Defaults to False.
1111
+ validation_enforced : bool, optional
1112
+ If True, options will be validated against the configuration rules.
1113
+ Defaults to False.
1114
+ """
1115
+ self.strict = strict
1116
+ self.validation_enforce = validation_enforce