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.
- nextmv/__about__.py +1 -1
- nextmv/__entrypoint__.py +7 -10
- nextmv/__init__.py +3 -0
- nextmv/cloud/__init__.py +7 -1
- nextmv/cloud/application.py +541 -47
- nextmv/cloud/batch_experiment.py +58 -22
- nextmv/cloud/client.py +2 -0
- nextmv/cloud/input_set.py +26 -0
- nextmv/cloud/manifest.py +157 -8
- nextmv/cloud/run.py +8 -7
- nextmv/cloud/safe.py +83 -0
- nextmv/cloud/scenario.py +229 -0
- nextmv/deprecated.py +13 -0
- nextmv/input.py +74 -0
- nextmv/options.py +293 -78
- nextmv/output.py +64 -7
- {nextmv-0.23.0.dist-info → nextmv-0.24.0.dist-info}/METADATA +1 -1
- nextmv-0.24.0.dist-info/RECORD +30 -0
- nextmv-0.23.0.dist-info/RECORD +0 -27
- {nextmv-0.23.0.dist-info → nextmv-0.24.0.dist-info}/WHEEL +0 -0
- {nextmv-0.23.0.dist-info → nextmv-0.24.0.dist-info}/licenses/LICENSE +0 -0
nextmv/cloud/batch_experiment.py
CHANGED
|
@@ -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
|
-
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
"""
|
|
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
|
-
|
|
23
|
-
"""
|
|
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
|
-
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
"""
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
132
|
-
"""
|
|
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,
|
|
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 >
|
|
149
|
-
raise ValueError("Priority must be between 1 and
|
|
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[
|
|
167
|
-
"""
|
|
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
|