nextmv 0.23.0__py3-none-any.whl → 0.24.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.
@@ -10,36 +10,68 @@ class BatchExperimentInformation(BaseModel):
10
10
  """Information about a batch experiment. This serves as a base for all the
11
11
  other batch experiment models."""
12
12
 
13
+ id: str
14
+ """ID of the batch experiment."""
13
15
  name: str
14
16
  """Name of the batch experiment."""
15
- input_set_id: str
16
- """ID of the input set used for the experiment."""
17
- instance_ids: list[str]
18
- """List of instance IDs used for the experiment."""
17
+ created_at: datetime
18
+ """Creation date of the batch experiment."""
19
+ updated_at: datetime
20
+ """Last update date of the batch experiment."""
21
+ status: str
22
+ """Status of the batch experiment."""
19
23
 
20
24
  description: Optional[str] = None
21
25
  """Description of the batch experiment."""
22
- id: Optional[str] = None
23
- """ID of the batch experiment."""
26
+ number_of_requested_runs: Optional[int] = None
27
+ """Number of runs requested for the batch experiment."""
28
+ number_of_runs: Optional[int] = None
29
+ """Number of runs in the batch experiment."""
30
+ number_of_completed_runs: Optional[int] = None
31
+ """Number of completed runs in the batch experiment."""
32
+ type: Optional[str] = None
33
+ """Type of the batch experiment."""
34
+ option_sets: Optional[dict[str, dict[str, str]]] = None
35
+ """Option sets used for the experiment."""
24
36
 
25
37
 
26
38
  class BatchExperiment(BatchExperimentInformation):
27
39
  """A batch experiment compares two or more instances by executing all the
28
40
  inputs contained in the input set."""
29
41
 
30
- created_at: datetime
31
- """Creation date of the batch experiment."""
32
- status: str
33
- """Status of the batch experiment."""
34
-
42
+ input_set_id: str
43
+ """ID of the input set used for the experiment."""
44
+ instance_ids: list[str]
45
+ """List of instance IDs used for the experiment."""
35
46
  grouped_distributional_summaries: Optional[list[dict[str, Any]]] = None
36
47
  """Grouped distributional summaries of the batch experiment."""
37
- option_sets: Optional[dict[str, dict[str, str]]] = None
38
- """Option sets used for the experiment."""
39
48
 
40
49
 
41
50
  class BatchExperimentRun(BaseModel):
42
- """A batch experiment run is a single execution of a batch experiment."""
51
+ """
52
+ A batch experiment run is a single execution of a batch experiment. It
53
+ contains information about the experiment, the input used, and the
54
+ configuration used for the run.
55
+
56
+ Attributes
57
+ ----------
58
+ option_set : str
59
+ Option set used for the experiment.
60
+ input_id : str
61
+ ID of the input used for the experiment.
62
+ instance_id : Optional[str]
63
+ ID of the instance used for the experiment.
64
+ version_id : Optional[str]
65
+ ID of the version used for the experiment.
66
+ input_set_id : Optional[str]
67
+ ID of the input set used for the experiment.
68
+ scenario_id : Optional[str]
69
+ If the batch experiment is a scenario test, this is the ID of that test.
70
+ repetition : Optional[int]
71
+ Repetition number of the experiment.
72
+ run_number : Optional[str]
73
+ Run number of the experiment.
74
+ """
43
75
 
44
76
  option_set: str
45
77
  """Option set used for the experiment."""
@@ -50,8 +82,16 @@ class BatchExperimentRun(BaseModel):
50
82
  """ID of the instance used for the experiment."""
51
83
  version_id: Optional[str] = None
52
84
  """ID of the version used for the experiment."""
53
-
54
- def __post_init__(self):
85
+ input_set_id: Optional[str] = None
86
+ """ID of the input set used for the experiment."""
87
+ scenario_id: Optional[str] = None
88
+ """If the batch experiment is a scenario test, this is the ID of that test."""
89
+ repetition: Optional[int] = None
90
+ """Repetition number of the experiment."""
91
+ run_number: Optional[str] = None
92
+ """Run number of the experiment."""
93
+
94
+ def __post_init_post_parse__(self):
55
95
  """Logic to run after the class is initialized."""
56
96
 
57
97
  if self.instance_id is None and self.version_id is None:
@@ -61,9 +101,5 @@ class BatchExperimentRun(BaseModel):
61
101
  class BatchExperimentMetadata(BatchExperimentInformation):
62
102
  """Metadata of a batch experiment."""
63
103
 
64
- status: str
65
- """Status of the batch experiment."""
66
- created_at: datetime
67
- """Creation date of the batch experiment."""
68
- number_of_runs: int
69
- """Number of runs in the batch experiment."""
104
+ app_id: str
105
+ """ID of the application used for the batch experiment."""
nextmv/cloud/client.py CHANGED
@@ -55,6 +55,8 @@ class Client:
55
55
  """Timeout to use for requests to the Nextmv Cloud API."""
56
56
  url: str = "https://api.cloud.nextmv.io"
57
57
  """URL of the Nextmv Cloud API."""
58
+ console_url: str = "https://cloud.nextmv.io"
59
+ """URL of the Nextmv Cloud console."""
58
60
 
59
61
  def __post_init__(self):
60
62
  """Logic to run after the class is initialized."""
nextmv/cloud/input_set.py CHANGED
@@ -1,8 +1,32 @@
1
1
  """This module contains definitions for input sets."""
2
2
 
3
3
  from datetime import datetime
4
+ from typing import Optional
4
5
 
5
6
  from nextmv.base_model import BaseModel
7
+ from nextmv.cloud.run import Format
8
+
9
+
10
+ class ManagedInput(BaseModel):
11
+ """An input created for experimenting with an application."""
12
+
13
+ id: str
14
+ """ID of the input."""
15
+
16
+ name: Optional[str] = None
17
+ """Name of the input."""
18
+ description: Optional[str] = None
19
+ """Description of the input."""
20
+ run_id: Optional[str] = None
21
+ """ID of the run that created the input."""
22
+ upload_id: Optional[str] = None
23
+ """ID of the upload that created the input."""
24
+ format: Optional[Format] = None
25
+ """Format of the input."""
26
+ created_at: Optional[datetime] = None
27
+ """Creation time of the input."""
28
+ updated_at: Optional[datetime] = None
29
+ """Last update time of the input."""
6
30
 
7
31
 
8
32
  class InputSet(BaseModel):
@@ -22,3 +46,5 @@ class InputSet(BaseModel):
22
46
  """Name of the input set."""
23
47
  updated_at: datetime
24
48
  """Last update time of the input set."""
49
+ inputs: list[ManagedInput]
50
+ """List of inputs in the input set."""
nextmv/cloud/manifest.py CHANGED
@@ -9,6 +9,7 @@ from pydantic import AliasChoices, Field
9
9
 
10
10
  from nextmv.base_model import BaseModel
11
11
  from nextmv.model import _REQUIREMENTS_FILE, ModelConfiguration
12
+ from nextmv.options import Option, Options
12
13
 
13
14
  FILE_NAME = "app.yaml"
14
15
  """Name of the app manifest file."""
@@ -89,7 +90,7 @@ class ManifestPythonModel(BaseModel):
89
90
  """
90
91
  Options for the decision model. This is a data representation of the
91
92
  `nextmv.Options` class. It consists of a list of dicts. Each dict
92
- represents the `nextmv.Parameter` class. It is used to be able to
93
+ represents the `nextmv.Option` class. It is used to be able to
93
94
  reconstruct an Options object from data when loading a decision model.
94
95
  """
95
96
 
@@ -113,6 +114,94 @@ class ManifestPython(BaseModel):
113
114
  """
114
115
 
115
116
 
117
+ class ManifestOption(BaseModel):
118
+ """An option for the decision model that is recorded in the manifest."""
119
+
120
+ name: str
121
+ """The name of the option"""
122
+ option_type: str = Field(
123
+ serialization_alias="type",
124
+ validation_alias=AliasChoices("type", "option_type"),
125
+ )
126
+ """The type of the option"""
127
+
128
+ default: Optional[Any] = None
129
+ """The default value of the option"""
130
+ description: Optional[str] = ""
131
+ """The description of the option"""
132
+ required: bool = False
133
+ """Whether the option is required or not"""
134
+ choices: Optional[list[Any]] = None
135
+ """The choices for the option"""
136
+
137
+ @classmethod
138
+ def from_option(cls, option: Option) -> "ManifestOption":
139
+ """
140
+ Create a `ManifestOption` from an `Option`.
141
+
142
+ Parameters
143
+ ----------
144
+ option: Option
145
+ The option to convert.
146
+
147
+ Returns
148
+ -------
149
+ ManifestOption
150
+ The converted option.
151
+ """
152
+ option_type = option.option_type
153
+ if option_type is str:
154
+ option_type = "string"
155
+ elif option_type is bool:
156
+ option_type = "boolean"
157
+ elif option_type is int:
158
+ option_type = "integer"
159
+ elif option_type is float:
160
+ option_type = "float"
161
+ else:
162
+ raise ValueError(f"unknown option type: {option_type}")
163
+
164
+ return cls(
165
+ name=option.name,
166
+ option_type=option_type,
167
+ default=option.default,
168
+ description=option.description,
169
+ required=option.required,
170
+ choices=option.choices,
171
+ )
172
+
173
+ def to_option(self) -> Option:
174
+ """
175
+ Convert the `ManifestOption` to an `Option`.
176
+
177
+ Returns
178
+ -------
179
+ Option
180
+ The converted option.
181
+ """
182
+
183
+ option_type_string = self.option_type
184
+ if option_type_string == "string":
185
+ option_type = str
186
+ elif option_type_string == "boolean":
187
+ option_type = bool
188
+ elif option_type_string == "integer":
189
+ option_type = int
190
+ elif option_type_string == "float":
191
+ option_type = float
192
+ else:
193
+ raise ValueError(f"unknown option type: {option_type_string}")
194
+
195
+ return Option(
196
+ name=self.name,
197
+ option_type=option_type,
198
+ default=self.default,
199
+ description=self.description,
200
+ required=self.required,
201
+ choices=self.choices,
202
+ )
203
+
204
+
116
205
  class Manifest(BaseModel):
117
206
  """
118
207
  An application that runs on the Nextmv Platform must contain a file named
@@ -161,6 +250,11 @@ class Manifest(BaseModel):
161
250
  Optional. Only for Python apps. Contains further Python-specific
162
251
  attributes.
163
252
  """
253
+ options: Optional[list[ManifestOption]] = None
254
+ """
255
+ Optional. A list of options for the decision model. An option is a
256
+ parameter that configures the decision model.
257
+ """
164
258
 
165
259
  @classmethod
166
260
  def from_yaml(cls, dirpath: str) -> "Manifest":
@@ -169,7 +263,7 @@ class Manifest(BaseModel):
169
263
 
170
264
  Parameters
171
265
  ----------
172
- dirpath : str
266
+ dirpath: str
173
267
  Path to the directory containing the app.yaml file.
174
268
 
175
269
  Returns
@@ -190,7 +284,7 @@ class Manifest(BaseModel):
190
284
 
191
285
  Parameters
192
286
  ----------
193
- dirpath : str
287
+ dirpath: str
194
288
  Path to the directory where the app.yaml file will be written.
195
289
 
196
290
  """
@@ -198,14 +292,35 @@ class Manifest(BaseModel):
198
292
  with open(os.path.join(dirpath, FILE_NAME), "w") as file:
199
293
  yaml.dump(self.to_dict(), file)
200
294
 
295
+ def extract_options(self) -> Options:
296
+ """
297
+ Convert the manifest options to a `nextmv.Options` object.
298
+
299
+ Returns
300
+ -------
301
+ Options
302
+ The converted options.
303
+ """
304
+
305
+ if self.options is None:
306
+ raise ValueError("No options found in the manifest")
307
+
308
+ options = [option.to_option() for option in self.options]
309
+
310
+ return Options(*options)
311
+
201
312
  @classmethod
202
313
  def from_model_configuration(cls, model_configuration: ModelConfiguration) -> "Manifest":
203
314
  """
204
- Create a Python manifest from a Python model configuration.
315
+ Create a Python manifest from a Python model configuration. Note that
316
+ the `ModelConfiguration` is almost always used in conjunction with the
317
+ `nextmv.Model` class. If you are not implementing an instance of
318
+ `nextmv.Model`, maybe you should use the `from_options` method instead,
319
+ to initialize the manifest with the options of the model.
205
320
 
206
321
  Parameters
207
322
  ----------
208
- model_configuration : ModelConfiguration
323
+ model_configuration: ModelConfiguration
209
324
  The model configuration.
210
325
 
211
326
  Returns
@@ -222,13 +337,47 @@ class Manifest(BaseModel):
222
337
  }
223
338
 
224
339
  if model_configuration.options is not None:
225
- manifest_python_dict["model"]["options"] = model_configuration.options.parameters_dict()
340
+ manifest_python_dict["model"]["options"] = model_configuration.options.options_dict()
226
341
 
227
342
  manifest_python = ManifestPython.from_dict(manifest_python_dict)
228
-
229
- return cls(
343
+ manifest = cls(
230
344
  files=["main.py", f"{model_configuration.name}/**"],
231
345
  runtime=ManifestRuntime.PYTHON,
232
346
  type=ManifestType.PYTHON,
233
347
  python=manifest_python,
234
348
  )
349
+
350
+ if model_configuration.options is not None:
351
+ manifest.options = [ManifestOption.from_option(opt) for opt in model_configuration.options.options]
352
+
353
+ return manifest
354
+
355
+ @classmethod
356
+ def from_options(cls, options: Options) -> "Manifest":
357
+ """
358
+ Create a basic Python manifest from `Options`. If you have more files
359
+ than just a `main.py`, make sure you modify the `.files` attribute of
360
+ the resulting manifest. This method assumes that requirements are
361
+ specified in a `requirements.txt` file. You may also specify a
362
+ different requirements file once you instantiate the manifest.
363
+
364
+ Parameters
365
+ ----------
366
+ options: Options
367
+ The options to include in the manifest.
368
+
369
+ Returns
370
+ -------
371
+ Manifest
372
+ The manifest with the given options.
373
+ """
374
+
375
+ manifest = cls(
376
+ files=["main.py"],
377
+ runtime=ManifestRuntime.PYTHON,
378
+ type=ManifestType.PYTHON,
379
+ python=ManifestPython(pip_requirements="requirements.txt"),
380
+ options=[ManifestOption.from_option(opt) for opt in options.options],
381
+ )
382
+
383
+ return manifest
nextmv/cloud/run.py CHANGED
@@ -52,6 +52,7 @@ class RunInformation(BaseModel):
52
52
  """Name of the run."""
53
53
  user_email: str
54
54
  """Email of the user who submitted the run."""
55
+ console_url: str = Field(default="")
55
56
 
56
57
 
57
58
  class ErrorLog(BaseModel):
@@ -128,12 +129,12 @@ class RunTypeConfiguration(BaseModel):
128
129
  """ID of the reference for the run type."""
129
130
 
130
131
 
131
- class RunQueueing(BaseModel):
132
- """Queueing configuration for a run."""
132
+ class RunQueuing(BaseModel):
133
+ """RunQueuing configuration for a run."""
133
134
 
134
135
  priority: Optional[int] = None
135
136
  """
136
- Priority of the run in the queue. 1 is the highest priority, 10 is the
137
+ Priority of the run in the queue. 1 is the highest priority, 9 is the
137
138
  lowest priority.
138
139
  """
139
140
  disabled: Optional[bool] = None
@@ -145,8 +146,8 @@ class RunQueueing(BaseModel):
145
146
  def __post_init_post_parse__(self):
146
147
  """Validations done after parsing the model."""
147
148
 
148
- if self.priority is not None and (self.priority < 1 or self.priority > 10):
149
- raise ValueError("Priority must be between 1 and 10.")
149
+ if self.priority is not None and (self.priority < 1 or self.priority > 9):
150
+ raise ValueError("Priority must be between 1 and 9.")
150
151
 
151
152
  if self.disabled is not None and self.disabled not in {True, False}:
152
153
  raise ValueError("Disabled must be a boolean value.")
@@ -163,8 +164,8 @@ class RunConfiguration(BaseModel):
163
164
  """Run type configuration for the run."""
164
165
  secrets_collection_id: Optional[str] = None
165
166
  """ID of the secrets collection to use for the run."""
166
- queuing: Optional[RunQueueing] = None
167
- """Queueing configuration for the run."""
167
+ queuing: Optional[RunQueuing] = None
168
+ """Queuing configuration for the run."""
168
169
 
169
170
 
170
171
  class ExternalRunResult(BaseModel):
nextmv/cloud/safe.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ Utilities for generating “safe” IDs and huma-readable names
3
+ """
4
+
5
+ import re
6
+ import secrets
7
+ import string
8
+
9
+ ENTITY_ID_CHAR_COUNT_MAX: int = 40
10
+ INDEX_TAG_CHAR_COUNT: int = 3 # room reserved for “-001”, “-xyz”, etc.
11
+ RE_NON_ALNUM = re.compile(r"[^A-Za-z0-9]+")
12
+
13
+
14
+ def kebab_case(value: str) -> str:
15
+ """Convert arbitrary text to `kebab-case` (lower-case, hyphen-separated)."""
16
+
17
+ cleaned = RE_NON_ALNUM.sub(" ", value).strip()
18
+ return "-".join(word.lower() for word in cleaned.split())
19
+
20
+
21
+ def start_case(value: str) -> str:
22
+ """Convert `kebab-case` (or any hyphen/underscore string) to `Start Case`."""
23
+
24
+ cleaned = re.sub(r"[-_]+", " ", value)
25
+ return " ".join(word.capitalize() for word in cleaned.split())
26
+
27
+
28
+ def nanoid(size: int = 8, alphabet: str = string.ascii_lowercase + string.digits) -> str:
29
+ """Simple nanoid clone using the std-lib `secrets` module."""
30
+
31
+ return "".join(secrets.choice(alphabet) for _ in range(size))
32
+
33
+
34
+ def name_and_id(prefix: str, entity_id: str) -> tuple[str, str]:
35
+ """
36
+ Generate a safe ID and human-readable name from a prefix and user-supplied
37
+ identifier.
38
+
39
+ Parameters
40
+ ----------
41
+ prefix : str
42
+ Prefix to use for the ID.
43
+ entity_id : str
44
+ User-supplied identifier. This will be converted to `kebab-case` and
45
+ truncated to fit within the safe ID length.
46
+
47
+ Returns
48
+ -------
49
+ tuple[str, str]
50
+ A tuple containing the human-readable name and the safe ID.
51
+ """
52
+
53
+ if not prefix or not entity_id:
54
+ return "", ""
55
+
56
+ safe_user_defined_id = kebab_case(entity_id)
57
+ random_slug = nanoid(8)
58
+
59
+ # Space available for user text once prefix, random slug and separator "-"
60
+ # are accounted for
61
+ safe_id_max = (
62
+ ENTITY_ID_CHAR_COUNT_MAX
63
+ - INDEX_TAG_CHAR_COUNT
64
+ - len(prefix)
65
+ - (len(random_slug) + 1) # +1 for the hyphen before the slug
66
+ )
67
+
68
+ safe_id_parts: list[str] = [prefix]
69
+
70
+ for word in safe_user_defined_id.split("-"):
71
+ # Trim individual word if it alone would overflow
72
+ safe_slug = word[: safe_id_max - 1] if len(word) > safe_id_max else word
73
+
74
+ # Will the combined ID (so far) overflow if we add this slug?
75
+ prospective_len = len("-".join(safe_id_parts + [safe_slug]))
76
+ if prospective_len >= safe_id_max:
77
+ break
78
+ safe_id_parts.append(safe_slug)
79
+
80
+ safe_id = "-".join(filter(None, safe_id_parts)) + f"-{random_slug}"
81
+ safe_name = start_case(safe_id)
82
+
83
+ return safe_name, safe_id