nextmv 0.29.3__tar.gz → 0.29.4.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.
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/PKG-INFO +1 -1
- nextmv-0.29.4.dev0/nextmv/__about__.py +1 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/application.py +195 -74
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/batch_experiment.py +39 -6
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/manifest.py +27 -21
- nextmv-0.29.4.dev0/nextmv/default_app/README.md +17 -0
- nextmv-0.29.4.dev0/nextmv/default_app/app.yaml +11 -0
- nextmv-0.29.4.dev0/nextmv/default_app/requirements.txt +2 -0
- nextmv-0.29.4.dev0/nextmv/default_app/src/main.py +36 -0
- nextmv-0.29.4.dev0/nextmv/default_app/src/visuals.py +36 -0
- nextmv-0.29.4.dev0/tests/cloud/test_application.py +181 -0
- nextmv-0.29.4.dev0/tests/test_entrypoint/__init__.py +0 -0
- nextmv-0.29.3/nextmv/__about__.py +0 -1
- nextmv-0.29.3/tests/cloud/test_application.py +0 -75
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/.gitignore +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/LICENSE +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/README.md +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/__entrypoint__.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/__init__.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/_serialization.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/base_model.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/__init__.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/acceptance_test.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/account.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/client.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/input_set.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/instance.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/package.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/run.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/safe.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/scenario.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/secrets.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/status.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/cloud/version.py +0 -0
- {nextmv-0.29.3/tests → nextmv-0.29.4.dev0/nextmv/default_app/src}/__init__.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/deprecated.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/input.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/logger.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/model.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/options.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/nextmv/output.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/pyproject.toml +0 -0
- {nextmv-0.29.3/tests/cloud → nextmv-0.29.4.dev0/tests}/__init__.py +0 -0
- {nextmv-0.29.3/tests/scripts → nextmv-0.29.4.dev0/tests/cloud}/__init__.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/cloud/app.yaml +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/cloud/test_client.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/cloud/test_manifest.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/cloud/test_package.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/cloud/test_run.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/cloud/test_safe_name_id.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/cloud/test_scenario.py +0 -0
- {nextmv-0.29.3/tests/test_entrypoint → nextmv-0.29.4.dev0/tests/scripts}/__init__.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options1.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options2.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options3.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options4.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options5.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options6.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options7.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/scripts/options_deprecated.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_base_model.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_entrypoint/test_entrypoint.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_input.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_inputs/test_data.csv +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_inputs/test_data.json +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_inputs/test_data.txt +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_logger.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_model.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_options.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_output.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_serialization.py +0 -0
- {nextmv-0.29.3 → nextmv-0.29.4.dev0}/tests/test_version.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "v0.29.4-dev.0"
|
|
@@ -29,6 +29,7 @@ import shutil
|
|
|
29
29
|
import tarfile
|
|
30
30
|
import tempfile
|
|
31
31
|
import time
|
|
32
|
+
import uuid
|
|
32
33
|
from collections.abc import Callable
|
|
33
34
|
from dataclasses import dataclass
|
|
34
35
|
from datetime import datetime
|
|
@@ -45,6 +46,7 @@ from nextmv.cloud.batch_experiment import (
|
|
|
45
46
|
BatchExperimentInformation,
|
|
46
47
|
BatchExperimentMetadata,
|
|
47
48
|
BatchExperimentRun,
|
|
49
|
+
to_runs,
|
|
48
50
|
)
|
|
49
51
|
from nextmv.cloud.client import Client, get_size
|
|
50
52
|
from nextmv.cloud.input_set import InputSet, ManagedInput
|
|
@@ -273,7 +275,7 @@ class Application:
|
|
|
273
275
|
Client to use for interacting with the Nextmv Cloud API.
|
|
274
276
|
id : str
|
|
275
277
|
ID of the application.
|
|
276
|
-
default_instance_id : str, default=
|
|
278
|
+
default_instance_id : str, default=None
|
|
277
279
|
Default instance ID to use for submitting runs.
|
|
278
280
|
endpoint : str, default="v1/applications/{id}"
|
|
279
281
|
Base endpoint for the application.
|
|
@@ -294,13 +296,22 @@ class Application:
|
|
|
294
296
|
id: str
|
|
295
297
|
"""ID of the application."""
|
|
296
298
|
|
|
297
|
-
default_instance_id: str =
|
|
299
|
+
default_instance_id: str = None
|
|
298
300
|
"""Default instance ID to use for submitting runs."""
|
|
299
301
|
endpoint: str = "v1/applications/{id}"
|
|
300
302
|
"""Base endpoint for the application."""
|
|
301
303
|
experiments_endpoint: str = "{base}/experiments"
|
|
302
304
|
"""Base endpoint for the experiments in the application."""
|
|
303
305
|
|
|
306
|
+
# Local experience parameters.
|
|
307
|
+
src: Optional[str] = None
|
|
308
|
+
"""
|
|
309
|
+
Source of the application, if initialized locally. This is the path
|
|
310
|
+
to the application's source code.
|
|
311
|
+
"""
|
|
312
|
+
description: Optional[str] = None
|
|
313
|
+
"""Description of the application."""
|
|
314
|
+
|
|
304
315
|
def __post_init__(self):
|
|
305
316
|
"""Initialize the endpoint and experiments_endpoint attributes.
|
|
306
317
|
|
|
@@ -310,6 +321,158 @@ class Application:
|
|
|
310
321
|
self.endpoint = self.endpoint.format(id=self.id)
|
|
311
322
|
self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
|
|
312
323
|
|
|
324
|
+
@classmethod
|
|
325
|
+
def initialize(
|
|
326
|
+
cls,
|
|
327
|
+
name: str,
|
|
328
|
+
id: Optional[str] = None,
|
|
329
|
+
description: Optional[str] = None,
|
|
330
|
+
destination: Optional[str] = None,
|
|
331
|
+
client: Optional[Client] = None,
|
|
332
|
+
) -> "Application":
|
|
333
|
+
"""
|
|
334
|
+
Initialize a Nextmv application, locally.
|
|
335
|
+
|
|
336
|
+
This method will create a new application in the local file system. The
|
|
337
|
+
application is a folder with the name given by `name`, under the
|
|
338
|
+
location given by `destination`. If the `destination` parameter is not
|
|
339
|
+
specified, the current working directory is used as default. This
|
|
340
|
+
method will scaffold the application with the necessary files and
|
|
341
|
+
directories to have an opinionated structure for your decision model.
|
|
342
|
+
Once the application is initialized, you are encouraged to complete it
|
|
343
|
+
with the decision model itself, so that the application can be run,
|
|
344
|
+
locally or remotely.
|
|
345
|
+
|
|
346
|
+
This method differs from the `Application.new` method in that it
|
|
347
|
+
creates the application locally rather than in the Cloud.
|
|
348
|
+
|
|
349
|
+
Although not required, you are encouraged to specify the `client`
|
|
350
|
+
parameter, so that the application can be pushed and synced remotely,
|
|
351
|
+
with the Nextmv Cloud. If you don't specify the `client`, and intend to
|
|
352
|
+
interact with the Nextmv Cloud, you will encounter an error. Make sure
|
|
353
|
+
you set the `client` parameter on the `Application` instance after
|
|
354
|
+
initialization, if you don't provide it here.
|
|
355
|
+
|
|
356
|
+
Use the `destination` parameter to specify where you want the app to be
|
|
357
|
+
initialized, using the current working directory by default.
|
|
358
|
+
|
|
359
|
+
Parameters
|
|
360
|
+
----------
|
|
361
|
+
name : str
|
|
362
|
+
Name of the application.
|
|
363
|
+
id : str, optional
|
|
364
|
+
ID of the application. Will be generated if not provided.
|
|
365
|
+
description : str, optional
|
|
366
|
+
Description of the application.
|
|
367
|
+
destination : str, optional
|
|
368
|
+
Destination directory where the application will be initialized. If
|
|
369
|
+
not provided, the current working directory will be used.
|
|
370
|
+
client : Client, optional
|
|
371
|
+
Client to use for interacting with the Nextmv Cloud API.
|
|
372
|
+
|
|
373
|
+
Returns
|
|
374
|
+
-------
|
|
375
|
+
Application
|
|
376
|
+
The initialized application instance.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
destination_dir = os.getcwd() if destination is None else destination
|
|
380
|
+
app_id = id if id is not None else str(uuid.uuid4())
|
|
381
|
+
|
|
382
|
+
# Create the new directory with the given name.
|
|
383
|
+
src = os.path.join(destination_dir, name)
|
|
384
|
+
os.makedirs(src, exist_ok=True)
|
|
385
|
+
|
|
386
|
+
# Get the path to the initial app structure template.
|
|
387
|
+
current_file_dir = os.path.dirname(os.path.abspath(__file__))
|
|
388
|
+
initial_app_structure_path = os.path.join(current_file_dir, "..", "default_app")
|
|
389
|
+
initial_app_structure_path = os.path.normpath(initial_app_structure_path)
|
|
390
|
+
|
|
391
|
+
# Copy everything from initial_app_structure to the new directory.
|
|
392
|
+
if os.path.exists(initial_app_structure_path):
|
|
393
|
+
for item in os.listdir(initial_app_structure_path):
|
|
394
|
+
source_path = os.path.join(initial_app_structure_path, item)
|
|
395
|
+
dest_path = os.path.join(src, item)
|
|
396
|
+
|
|
397
|
+
if os.path.isdir(source_path):
|
|
398
|
+
shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
shutil.copy2(source_path, dest_path)
|
|
402
|
+
|
|
403
|
+
return cls(
|
|
404
|
+
id=app_id,
|
|
405
|
+
client=client,
|
|
406
|
+
src=src,
|
|
407
|
+
description=description,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
@classmethod
|
|
411
|
+
def new(
|
|
412
|
+
cls,
|
|
413
|
+
client: Client,
|
|
414
|
+
name: str,
|
|
415
|
+
id: Optional[str] = None,
|
|
416
|
+
description: Optional[str] = None,
|
|
417
|
+
is_workflow: Optional[bool] = None,
|
|
418
|
+
exist_ok: bool = False,
|
|
419
|
+
) -> "Application":
|
|
420
|
+
"""
|
|
421
|
+
Create a new application directly in Nextmv Cloud.
|
|
422
|
+
|
|
423
|
+
The application is created as an empty shell, and executable code must
|
|
424
|
+
be pushed to the app before running it remotely.
|
|
425
|
+
|
|
426
|
+
Parameters
|
|
427
|
+
----------
|
|
428
|
+
client : Client
|
|
429
|
+
Client to use for interacting with the Nextmv Cloud API.
|
|
430
|
+
name : str
|
|
431
|
+
Name of the application.
|
|
432
|
+
id : str, optional
|
|
433
|
+
ID of the application. Will be generated if not provided.
|
|
434
|
+
description : str, optional
|
|
435
|
+
Description of the application.
|
|
436
|
+
is_workflow : bool, optional
|
|
437
|
+
Whether the application is a Decision Workflow.
|
|
438
|
+
exist_ok : bool, default=False
|
|
439
|
+
If True and an application with the same ID already exists,
|
|
440
|
+
return the existing application instead of creating a new one.
|
|
441
|
+
|
|
442
|
+
Returns
|
|
443
|
+
-------
|
|
444
|
+
Application
|
|
445
|
+
The newly created (or existing) application.
|
|
446
|
+
|
|
447
|
+
Examples
|
|
448
|
+
--------
|
|
449
|
+
>>> from nextmv.cloud import Client
|
|
450
|
+
>>> client = Client(api_key="your-api-key")
|
|
451
|
+
>>> app = Application.new(client=client, name="My New App", id="my-app")
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
if exist_ok and cls.exists(client=client, id=id):
|
|
455
|
+
return Application(client=client, id=id)
|
|
456
|
+
|
|
457
|
+
payload = {
|
|
458
|
+
"name": name,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if description is not None:
|
|
462
|
+
payload["description"] = description
|
|
463
|
+
if id is not None:
|
|
464
|
+
payload["id"] = id
|
|
465
|
+
if is_workflow is not None:
|
|
466
|
+
payload["is_pipeline"] = is_workflow
|
|
467
|
+
|
|
468
|
+
response = client.request(
|
|
469
|
+
method="POST",
|
|
470
|
+
endpoint="v1/applications",
|
|
471
|
+
payload=payload,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return cls(client=client, id=response.json()["id"])
|
|
475
|
+
|
|
313
476
|
def acceptance_test(self, acceptance_test_id: str) -> AcceptanceTest:
|
|
314
477
|
"""
|
|
315
478
|
Retrieve details of an acceptance test.
|
|
@@ -901,69 +1064,6 @@ class Application:
|
|
|
901
1064
|
|
|
902
1065
|
return ManagedInput.from_dict(response.json())
|
|
903
1066
|
|
|
904
|
-
@classmethod
|
|
905
|
-
def new(
|
|
906
|
-
cls,
|
|
907
|
-
client: Client,
|
|
908
|
-
name: str,
|
|
909
|
-
id: Optional[str] = None,
|
|
910
|
-
description: Optional[str] = None,
|
|
911
|
-
is_workflow: Optional[bool] = None,
|
|
912
|
-
exist_ok: bool = False,
|
|
913
|
-
) -> "Application":
|
|
914
|
-
"""
|
|
915
|
-
Create a new application.
|
|
916
|
-
|
|
917
|
-
Parameters
|
|
918
|
-
----------
|
|
919
|
-
client : Client
|
|
920
|
-
Client to use for interacting with the Nextmv Cloud API.
|
|
921
|
-
name : str
|
|
922
|
-
Name of the application.
|
|
923
|
-
id : str, optional
|
|
924
|
-
ID of the application. Will be generated if not provided.
|
|
925
|
-
description : str, optional
|
|
926
|
-
Description of the application.
|
|
927
|
-
is_workflow : bool, optional
|
|
928
|
-
Whether the application is a Decision Workflow.
|
|
929
|
-
exist_ok : bool, default=False
|
|
930
|
-
If True and an application with the same ID already exists,
|
|
931
|
-
return the existing application instead of creating a new one.
|
|
932
|
-
|
|
933
|
-
Returns
|
|
934
|
-
-------
|
|
935
|
-
Application
|
|
936
|
-
The newly created (or existing) application.
|
|
937
|
-
|
|
938
|
-
Examples
|
|
939
|
-
--------
|
|
940
|
-
>>> from nextmv.cloud import Client
|
|
941
|
-
>>> client = Client(api_key="your-api-key")
|
|
942
|
-
>>> app = Application.new(client=client, name="My New App", id="my-app")
|
|
943
|
-
"""
|
|
944
|
-
|
|
945
|
-
if exist_ok and cls.exists(client=client, id=id):
|
|
946
|
-
return Application(client=client, id=id)
|
|
947
|
-
|
|
948
|
-
payload = {
|
|
949
|
-
"name": name,
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
if description is not None:
|
|
953
|
-
payload["description"] = description
|
|
954
|
-
if id is not None:
|
|
955
|
-
payload["id"] = id
|
|
956
|
-
if is_workflow is not None:
|
|
957
|
-
payload["is_pipeline"] = is_workflow
|
|
958
|
-
|
|
959
|
-
response = client.request(
|
|
960
|
-
method="POST",
|
|
961
|
-
endpoint="v1/applications",
|
|
962
|
-
payload=payload,
|
|
963
|
-
)
|
|
964
|
-
|
|
965
|
-
return cls(client=client, id=response.json()["id"])
|
|
966
|
-
|
|
967
1067
|
def new_acceptance_test(
|
|
968
1068
|
self,
|
|
969
1069
|
candidate_instance_id: str,
|
|
@@ -1027,12 +1127,21 @@ class Application:
|
|
|
1027
1127
|
f"batch experiment {id} does not exist, input_set_id must be defined to create a new one"
|
|
1028
1128
|
) from e
|
|
1029
1129
|
else:
|
|
1130
|
+
runs = [
|
|
1131
|
+
BatchExperimentRun(
|
|
1132
|
+
instance_id=candidate_instance_id,
|
|
1133
|
+
input_set_id=input_set_id,
|
|
1134
|
+
),
|
|
1135
|
+
BatchExperimentRun(
|
|
1136
|
+
instance_id=baseline_instance_id,
|
|
1137
|
+
input_set_id=input_set_id,
|
|
1138
|
+
),
|
|
1139
|
+
]
|
|
1030
1140
|
batch_experiment_id = self.new_batch_experiment(
|
|
1031
1141
|
name=name,
|
|
1032
|
-
input_set_id=input_set_id,
|
|
1033
|
-
instance_ids=[candidate_instance_id, baseline_instance_id],
|
|
1034
1142
|
description=description,
|
|
1035
1143
|
id=id,
|
|
1144
|
+
runs=runs,
|
|
1036
1145
|
)
|
|
1037
1146
|
|
|
1038
1147
|
if batch_experiment_id != id:
|
|
@@ -1175,6 +1284,7 @@ class Application:
|
|
|
1175
1284
|
ID of the input set to use for the batch experiment.
|
|
1176
1285
|
instance_ids: list[str]
|
|
1177
1286
|
List of instance IDs to use for the batch experiment.
|
|
1287
|
+
This argument is deprecated, use `runs` instead.
|
|
1178
1288
|
description: Optional[str]
|
|
1179
1289
|
Optional description of the batch experiment.
|
|
1180
1290
|
id: Optional[str]
|
|
@@ -1207,7 +1317,10 @@ class Application:
|
|
|
1207
1317
|
if input_set_id is not None:
|
|
1208
1318
|
payload["input_set_id"] = input_set_id
|
|
1209
1319
|
if instance_ids is not None:
|
|
1210
|
-
|
|
1320
|
+
input_set = self.input_set(input_set_id)
|
|
1321
|
+
runs = to_runs(instance_ids, input_set)
|
|
1322
|
+
payload_runs = [run.to_dict() for run in runs]
|
|
1323
|
+
payload["runs"] = payload_runs
|
|
1211
1324
|
if description is not None:
|
|
1212
1325
|
payload["description"] = description
|
|
1213
1326
|
if id is not None:
|
|
@@ -1675,9 +1788,9 @@ class Application:
|
|
|
1675
1788
|
)
|
|
1676
1789
|
payload["result"] = external_dict
|
|
1677
1790
|
|
|
1678
|
-
query_params = {
|
|
1679
|
-
|
|
1680
|
-
|
|
1791
|
+
query_params = {}
|
|
1792
|
+
if instance_id is not None or self.default_instance_id is not None:
|
|
1793
|
+
query_params["instance_id"] = instance_id if instance_id is not None else self.default_instance_id
|
|
1681
1794
|
response = self.client.request(
|
|
1682
1795
|
method="POST",
|
|
1683
1796
|
endpoint=f"{self.endpoint}/runs",
|
|
@@ -1996,8 +2109,16 @@ class Application:
|
|
|
1996
2109
|
>>> # Create a new secrets collection with API keys
|
|
1997
2110
|
>>> from nextmv.cloud import Secret
|
|
1998
2111
|
>>> secrets = [
|
|
1999
|
-
... Secret(
|
|
2000
|
-
...
|
|
2112
|
+
... Secret(
|
|
2113
|
+
... location="API_KEY",
|
|
2114
|
+
... value="your-api-key",
|
|
2115
|
+
... secret_type=SecretType.ENV,
|
|
2116
|
+
... ),
|
|
2117
|
+
... Secret(
|
|
2118
|
+
... location="DATABASE_URL",
|
|
2119
|
+
... value="your-database-url",
|
|
2120
|
+
... secret_type=SecretType.ENV,
|
|
2121
|
+
... ),
|
|
2001
2122
|
... ]
|
|
2002
2123
|
>>> collection = app.new_secrets_collection(
|
|
2003
2124
|
... secrets=secrets,
|
|
@@ -3219,7 +3340,7 @@ class Application:
|
|
|
3219
3340
|
{
|
|
3220
3341
|
"app_id": self.id,
|
|
3221
3342
|
"endpoint": self.client.url,
|
|
3222
|
-
"instance_url": f"{self.endpoint}/runs?instance_id=
|
|
3343
|
+
"instance_url": f"{self.endpoint}/runs?instance_id=latest",
|
|
3223
3344
|
},
|
|
3224
3345
|
indent=2,
|
|
3225
3346
|
)
|
|
@@ -17,6 +17,7 @@ from datetime import datetime
|
|
|
17
17
|
from typing import Any, Optional
|
|
18
18
|
|
|
19
19
|
from nextmv.base_model import BaseModel
|
|
20
|
+
from nextmv.cloud.input_set import InputSet
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class BatchExperimentInformation(BaseModel):
|
|
@@ -143,10 +144,10 @@ class BatchExperimentRun(BaseModel):
|
|
|
143
144
|
|
|
144
145
|
Parameters
|
|
145
146
|
----------
|
|
146
|
-
option_set : str
|
|
147
|
-
Option set used for the experiment.
|
|
148
147
|
input_id : str
|
|
149
148
|
ID of the input used for the experiment.
|
|
149
|
+
option_set : str
|
|
150
|
+
Option set used for the experiment. Defaults to None.
|
|
150
151
|
instance_id : str, optional
|
|
151
152
|
ID of the instance used for the experiment. Defaults to None.
|
|
152
153
|
version_id : str, optional
|
|
@@ -162,11 +163,11 @@ class BatchExperimentRun(BaseModel):
|
|
|
162
163
|
Run number of the experiment. Defaults to None.
|
|
163
164
|
"""
|
|
164
165
|
|
|
165
|
-
option_set: str
|
|
166
|
-
"""Option set used for the experiment."""
|
|
167
166
|
input_id: str
|
|
168
167
|
"""ID of the input used for the experiment."""
|
|
169
168
|
|
|
169
|
+
option_set: Optional[str] = None
|
|
170
|
+
"""Option set used for the experiment."""
|
|
170
171
|
instance_id: Optional[str] = None
|
|
171
172
|
"""ID of the instance used for the experiment."""
|
|
172
173
|
version_id: Optional[str] = None
|
|
@@ -177,8 +178,6 @@ class BatchExperimentRun(BaseModel):
|
|
|
177
178
|
"""If the batch experiment is a scenario test, this is the ID of that test."""
|
|
178
179
|
repetition: Optional[int] = None
|
|
179
180
|
"""Repetition number of the experiment."""
|
|
180
|
-
run_number: Optional[str] = None
|
|
181
|
-
"""Run number of the experiment."""
|
|
182
181
|
|
|
183
182
|
def __post_init_post_parse__(self):
|
|
184
183
|
"""
|
|
@@ -215,3 +214,37 @@ class BatchExperimentMetadata(BatchExperimentInformation):
|
|
|
215
214
|
|
|
216
215
|
app_id: Optional[str] = None
|
|
217
216
|
"""ID of the application used for the batch experiment."""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def to_runs(instance_ids: list[str], input_set: InputSet) -> list[BatchExperimentRun]:
|
|
220
|
+
"""
|
|
221
|
+
Translate a legacy batch experiment list of instance ids to runs.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
instance_ids : list[str]
|
|
226
|
+
List of instance IDs to be converted into runs.
|
|
227
|
+
input_set : InputSet
|
|
228
|
+
Input set associated with the runs.
|
|
229
|
+
|
|
230
|
+
Returns
|
|
231
|
+
-------
|
|
232
|
+
list[BatchExperimentRun]
|
|
233
|
+
A list of `BatchExperimentRun` objects created from the instance IDs.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
input_ids = input_set.input_ids
|
|
237
|
+
if len(input_set.input_ids) == 0:
|
|
238
|
+
input_ids = [i.id for i in input_set.inputs]
|
|
239
|
+
|
|
240
|
+
runs = []
|
|
241
|
+
for instance_id in instance_ids:
|
|
242
|
+
for input_id in input_ids:
|
|
243
|
+
run = BatchExperimentRun(
|
|
244
|
+
input_id=input_id,
|
|
245
|
+
instance_id=instance_id,
|
|
246
|
+
input_set_id=input_set.id,
|
|
247
|
+
)
|
|
248
|
+
runs.append(run)
|
|
249
|
+
|
|
250
|
+
return runs
|
|
@@ -322,6 +322,7 @@ class ManifestPython(BaseModel):
|
|
|
322
322
|
from the app bundle.
|
|
323
323
|
"""
|
|
324
324
|
|
|
325
|
+
|
|
325
326
|
class ManifestOptionUI(BaseModel):
|
|
326
327
|
"""
|
|
327
328
|
UI attributes for an option in the manifest.
|
|
@@ -360,6 +361,7 @@ class ManifestOptionUI(BaseModel):
|
|
|
360
361
|
hidden_from: Optional[list[str]] = None
|
|
361
362
|
"""A list of team roles for which this option will be hidden in the UI."""
|
|
362
363
|
|
|
364
|
+
|
|
363
365
|
class ManifestOption(BaseModel):
|
|
364
366
|
"""
|
|
365
367
|
An option for the decision model that is recorded in the manifest.
|
|
@@ -429,7 +431,6 @@ class ManifestOption(BaseModel):
|
|
|
429
431
|
ui: Optional[ManifestOptionUI] = None
|
|
430
432
|
"""Optional UI attributes for the option."""
|
|
431
433
|
|
|
432
|
-
|
|
433
434
|
@classmethod
|
|
434
435
|
def from_option(cls, option: Option) -> "ManifestOption":
|
|
435
436
|
"""
|
|
@@ -483,7 +484,9 @@ class ManifestOption(BaseModel):
|
|
|
483
484
|
ui=ManifestOptionUI(
|
|
484
485
|
control_type=option.control_type,
|
|
485
486
|
hidden_from=option.hidden_from,
|
|
486
|
-
)
|
|
487
|
+
)
|
|
488
|
+
if option.control_type or option.hidden_from
|
|
489
|
+
else None,
|
|
487
490
|
)
|
|
488
491
|
|
|
489
492
|
def to_option(self) -> Option:
|
|
@@ -534,6 +537,7 @@ class ManifestOption(BaseModel):
|
|
|
534
537
|
hidden_from=self.ui.hidden_from if self.ui else None,
|
|
535
538
|
)
|
|
536
539
|
|
|
540
|
+
|
|
537
541
|
class ManifestValidation(BaseModel):
|
|
538
542
|
"""
|
|
539
543
|
Validation rules for options in the manifest.
|
|
@@ -569,6 +573,7 @@ class ManifestValidation(BaseModel):
|
|
|
569
573
|
created if any of the rules of the options are violated.
|
|
570
574
|
"""
|
|
571
575
|
|
|
576
|
+
|
|
572
577
|
class ManifestOptions(BaseModel):
|
|
573
578
|
"""
|
|
574
579
|
Options for the decision model.
|
|
@@ -649,9 +654,10 @@ class ManifestOptions(BaseModel):
|
|
|
649
654
|
return cls(
|
|
650
655
|
strict=validation.strict if validation else False,
|
|
651
656
|
validation=ManifestValidation(enforce="all" if validation and validation.validation_enforce else "none"),
|
|
652
|
-
items=items
|
|
657
|
+
items=items,
|
|
653
658
|
)
|
|
654
659
|
|
|
660
|
+
|
|
655
661
|
class ManifestConfiguration(BaseModel):
|
|
656
662
|
"""
|
|
657
663
|
Configuration for the decision model.
|
|
@@ -749,14 +755,17 @@ class Manifest(BaseModel):
|
|
|
749
755
|
"""The files to include (or exclude) in the app. This is mandatory."""
|
|
750
756
|
|
|
751
757
|
runtime: ManifestRuntime = ManifestRuntime.PYTHON
|
|
752
|
-
"""
|
|
753
|
-
|
|
754
|
-
|
|
758
|
+
"""
|
|
759
|
+
The runtime to use for the app. It provides the environment in which the
|
|
760
|
+
app runs. This is mandatory.
|
|
755
761
|
"""
|
|
756
762
|
type: ManifestType = ManifestType.PYTHON
|
|
757
|
-
"""
|
|
763
|
+
"""
|
|
764
|
+
Type of application, based on the programming language. This is mandatory.
|
|
765
|
+
"""
|
|
758
766
|
build: Optional[ManifestBuild] = None
|
|
759
|
-
"""
|
|
767
|
+
"""
|
|
768
|
+
Build-specific attributes.
|
|
760
769
|
|
|
761
770
|
The `build.command` to run to build the app. This command will be executed
|
|
762
771
|
without a shell, i.e., directly. The command must exit with a status of 0
|
|
@@ -770,7 +779,8 @@ class Manifest(BaseModel):
|
|
|
770
779
|
validation_alias=AliasChoices("pre-push", "pre_push"),
|
|
771
780
|
default=None,
|
|
772
781
|
)
|
|
773
|
-
"""
|
|
782
|
+
"""
|
|
783
|
+
A command to run before the app is pushed to the Nextmv Cloud.
|
|
774
784
|
|
|
775
785
|
This command can be used to compile a binary, run tests or similar tasks.
|
|
776
786
|
One difference with what is specified under build, is that the command will
|
|
@@ -780,15 +790,14 @@ class Manifest(BaseModel):
|
|
|
780
790
|
pushed (after the build command).
|
|
781
791
|
"""
|
|
782
792
|
python: Optional[ManifestPython] = None
|
|
783
|
-
"""
|
|
784
|
-
|
|
785
|
-
|
|
793
|
+
"""
|
|
794
|
+
Python-specific attributes. Only for Python apps. Contains further
|
|
795
|
+
Python-specific attributes.
|
|
786
796
|
"""
|
|
787
797
|
configuration: Optional[ManifestConfiguration] = None
|
|
788
|
-
"""
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
configures the decision model.
|
|
798
|
+
"""
|
|
799
|
+
Configuration for the decision model. A list of options for the decision
|
|
800
|
+
model. An option is a parameter that configures the decision model.
|
|
792
801
|
"""
|
|
793
802
|
|
|
794
803
|
@classmethod
|
|
@@ -1041,11 +1050,8 @@ class Manifest(BaseModel):
|
|
|
1041
1050
|
type=ManifestType.PYTHON,
|
|
1042
1051
|
python=ManifestPython(pip_requirements="requirements.txt"),
|
|
1043
1052
|
configuration=ManifestConfiguration(
|
|
1044
|
-
options=
|
|
1045
|
-
|
|
1046
|
-
validation=validation
|
|
1047
|
-
),
|
|
1048
|
-
)
|
|
1053
|
+
options=ManifestOptions.from_options(options=options, validation=validation),
|
|
1054
|
+
),
|
|
1049
1055
|
)
|
|
1050
1056
|
|
|
1051
1057
|
return manifest
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Nextmv Application
|
|
2
|
+
|
|
3
|
+
This is the basic structure of a Nextmv application.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
├── app.yaml
|
|
7
|
+
├── README.md
|
|
8
|
+
├── requirements.txt
|
|
9
|
+
└── src
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
* `app.yaml`: App manifest, containing the configuration to run the app
|
|
13
|
+
remotely on Nextmv Cloud.
|
|
14
|
+
* `README.md`: Description of the app.
|
|
15
|
+
* `requirements.txt`: Python dependencies for the app.
|
|
16
|
+
* `src/`: Source code for the app. The `main.py` file is the entry point for
|
|
17
|
+
the app.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# This manifest holds the information the app needs to run on the Nextmv Cloud.
|
|
2
|
+
type: python
|
|
3
|
+
runtime: ghcr.io/nextmv-io/runtime/python:3.11
|
|
4
|
+
python:
|
|
5
|
+
# All listed packages will get bundled with the app.
|
|
6
|
+
pip-requirements: requirements.txt
|
|
7
|
+
|
|
8
|
+
# List all files/directories that should be included in the app. Globbing
|
|
9
|
+
# (e.g.: configs/*.json) is supported.
|
|
10
|
+
files:
|
|
11
|
+
- src/
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from visuals import create_visuals
|
|
2
|
+
|
|
3
|
+
import nextmv
|
|
4
|
+
|
|
5
|
+
# Read the input from stdin.
|
|
6
|
+
input = nextmv.load()
|
|
7
|
+
name = input.data["name"]
|
|
8
|
+
|
|
9
|
+
options = nextmv.Options(
|
|
10
|
+
nextmv.Option("details", bool, True, "Print details to logs. Default true.", False),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
##### Insert model here
|
|
14
|
+
|
|
15
|
+
# Print logs that render in the run view in Nextmv Console.
|
|
16
|
+
message = f"Hello, {name}"
|
|
17
|
+
nextmv.log(message)
|
|
18
|
+
|
|
19
|
+
if options.details:
|
|
20
|
+
detail = f"You are {input.data['distance']} million km from the sun"
|
|
21
|
+
nextmv.log(detail)
|
|
22
|
+
|
|
23
|
+
assets = create_visuals(name, input.data["radius"], input.data["distance"])
|
|
24
|
+
|
|
25
|
+
# Write output and statistics.
|
|
26
|
+
output = nextmv.Output(
|
|
27
|
+
solution=None,
|
|
28
|
+
statistics=nextmv.Statistics(
|
|
29
|
+
result=nextmv.ResultStatistics(
|
|
30
|
+
value=1.23,
|
|
31
|
+
custom={"message": message},
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
assets=assets,
|
|
35
|
+
)
|
|
36
|
+
nextmv.write(output)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import plotly.graph_objects as go
|
|
4
|
+
|
|
5
|
+
import nextmv
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_visuals(name: str, radius: float, distance: float) -> list[nextmv.Asset]:
|
|
9
|
+
"""Create a Plotly bar chart with radius and distance for a planet."""
|
|
10
|
+
|
|
11
|
+
fig = go.Figure()
|
|
12
|
+
fig.add_trace(
|
|
13
|
+
go.Bar(x=[name], y=[radius], name="Radius (km)", marker_color="red", opacity=0.5),
|
|
14
|
+
)
|
|
15
|
+
fig.add_trace(
|
|
16
|
+
go.Bar(x=[name], y=[distance], name="Distance (Millions km)", marker_color="blue", opacity=0.5),
|
|
17
|
+
)
|
|
18
|
+
fig.update_layout(
|
|
19
|
+
title="Radius and Distance by Planet", xaxis_title="Planet", yaxis_title="Values", barmode="group"
|
|
20
|
+
)
|
|
21
|
+
fig = fig.to_json()
|
|
22
|
+
|
|
23
|
+
assets = [
|
|
24
|
+
nextmv.Asset(
|
|
25
|
+
name="Plotly example",
|
|
26
|
+
content_type="json",
|
|
27
|
+
visual=nextmv.Visual(
|
|
28
|
+
visual_schema=nextmv.VisualSchema.PLOTLY,
|
|
29
|
+
visual_type="custom-tab",
|
|
30
|
+
label="Charts",
|
|
31
|
+
),
|
|
32
|
+
content=[json.loads(fig)],
|
|
33
|
+
)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
return assets
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from typing import Any
|
|
5
|
+
from unittest.mock import Mock
|
|
6
|
+
|
|
7
|
+
from nextmv.cloud.application import Application, PollingOptions, poll
|
|
8
|
+
from nextmv.cloud.client import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# This is a dummy function to avoid actually sleeping during tests.
|
|
12
|
+
def no_sleep(value: float) -> None:
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestApplication(unittest.TestCase):
|
|
17
|
+
def test_poll(self):
|
|
18
|
+
counter = 0
|
|
19
|
+
|
|
20
|
+
def polling_func() -> tuple[Any, bool]:
|
|
21
|
+
nonlocal counter
|
|
22
|
+
counter += 1
|
|
23
|
+
|
|
24
|
+
if counter < 4:
|
|
25
|
+
return "result", False
|
|
26
|
+
|
|
27
|
+
return "result", True
|
|
28
|
+
|
|
29
|
+
polling_options = PollingOptions()
|
|
30
|
+
|
|
31
|
+
result = poll(polling_options, polling_func, no_sleep)
|
|
32
|
+
|
|
33
|
+
self.assertEqual(result, "result")
|
|
34
|
+
|
|
35
|
+
def test_initialize(self):
|
|
36
|
+
"""Test the Application.initialize method."""
|
|
37
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
38
|
+
app_name = "test-app"
|
|
39
|
+
app_id = "test-app-id"
|
|
40
|
+
description = "Test application"
|
|
41
|
+
|
|
42
|
+
# Mock client
|
|
43
|
+
mock_client = Mock(spec=Client)
|
|
44
|
+
|
|
45
|
+
# Initialize the application
|
|
46
|
+
app = Application.initialize(
|
|
47
|
+
name=app_name,
|
|
48
|
+
id=app_id,
|
|
49
|
+
description=description,
|
|
50
|
+
destination=temp_dir,
|
|
51
|
+
client=mock_client,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Verify the application object
|
|
55
|
+
self.assertEqual(app.id, app_id)
|
|
56
|
+
self.assertEqual(app.client, mock_client)
|
|
57
|
+
self.assertEqual(app.description, description)
|
|
58
|
+
self.assertEqual(app.src, os.path.join(temp_dir, app_name))
|
|
59
|
+
|
|
60
|
+
# Verify the directory structure was created
|
|
61
|
+
app_dir = os.path.join(temp_dir, app_name)
|
|
62
|
+
self.assertTrue(os.path.exists(app_dir))
|
|
63
|
+
self.assertTrue(os.path.isdir(app_dir))
|
|
64
|
+
|
|
65
|
+
# Verify app.yaml was copied
|
|
66
|
+
app_yaml_path = os.path.join(app_dir, "app.yaml")
|
|
67
|
+
self.assertTrue(os.path.exists(app_yaml_path))
|
|
68
|
+
|
|
69
|
+
# Verify requirements.txt was copied
|
|
70
|
+
requirements_path = os.path.join(app_dir, "requirements.txt")
|
|
71
|
+
self.assertTrue(os.path.exists(requirements_path))
|
|
72
|
+
|
|
73
|
+
# Verify README.md was copied
|
|
74
|
+
readme_path = os.path.join(app_dir, "README.md")
|
|
75
|
+
self.assertTrue(os.path.exists(readme_path))
|
|
76
|
+
|
|
77
|
+
# Verify src directory was copied
|
|
78
|
+
src_dir = os.path.join(app_dir, "src")
|
|
79
|
+
self.assertTrue(os.path.exists(src_dir))
|
|
80
|
+
self.assertTrue(os.path.isdir(src_dir))
|
|
81
|
+
|
|
82
|
+
def test_initialize_with_defaults(self):
|
|
83
|
+
"""Test the Application.initialize method with default parameters."""
|
|
84
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
85
|
+
# Change to temp directory to test default destination
|
|
86
|
+
original_cwd = os.getcwd()
|
|
87
|
+
try:
|
|
88
|
+
os.chdir(temp_dir)
|
|
89
|
+
|
|
90
|
+
app_name = "default-test-app"
|
|
91
|
+
|
|
92
|
+
# Initialize with minimal parameters
|
|
93
|
+
app = Application.initialize(name=app_name)
|
|
94
|
+
|
|
95
|
+
# Verify the application object has generated ID
|
|
96
|
+
self.assertIsNotNone(app.id)
|
|
97
|
+
self.assertIsNone(app.client)
|
|
98
|
+
self.assertIsNone(app.description) # description should be None when not provided
|
|
99
|
+
# Use the current working directory for comparison since that's where the app is created
|
|
100
|
+
expected_src_path = os.path.join(os.getcwd(), app_name)
|
|
101
|
+
self.assertEqual(app.src, expected_src_path)
|
|
102
|
+
|
|
103
|
+
# Verify the directory structure was created in current directory
|
|
104
|
+
app_dir = os.path.join(temp_dir, app_name)
|
|
105
|
+
self.assertTrue(os.path.exists(app_dir))
|
|
106
|
+
self.assertTrue(os.path.isdir(app_dir))
|
|
107
|
+
|
|
108
|
+
# Verify basic structure exists
|
|
109
|
+
self.assertTrue(os.path.exists(os.path.join(app_dir, "app.yaml")))
|
|
110
|
+
self.assertTrue(os.path.exists(os.path.join(app_dir, "src")))
|
|
111
|
+
|
|
112
|
+
finally:
|
|
113
|
+
os.chdir(original_cwd)
|
|
114
|
+
|
|
115
|
+
def test_initialize_existing_directory(self):
|
|
116
|
+
"""Test that initialize works when the directory already exists."""
|
|
117
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
118
|
+
app_name = "existing-app"
|
|
119
|
+
app_dir = os.path.join(temp_dir, app_name)
|
|
120
|
+
|
|
121
|
+
# Pre-create the directory
|
|
122
|
+
os.makedirs(app_dir, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
# Initialize should still work
|
|
125
|
+
app = Application.initialize(
|
|
126
|
+
name=app_name,
|
|
127
|
+
destination=temp_dir,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Verify the application was created successfully
|
|
131
|
+
self.assertIsNotNone(app.id)
|
|
132
|
+
self.assertIsNone(app.description) # description should be None when not provided
|
|
133
|
+
self.assertEqual(app.src, app_dir)
|
|
134
|
+
self.assertTrue(os.path.exists(app_dir))
|
|
135
|
+
self.assertTrue(os.path.exists(os.path.join(app_dir, "app.yaml")))
|
|
136
|
+
|
|
137
|
+
def test_poll_stop_callback(self):
|
|
138
|
+
counter = 0
|
|
139
|
+
|
|
140
|
+
# The polling func would stop after 9 calls.
|
|
141
|
+
def polling_func() -> tuple[Any, bool]:
|
|
142
|
+
nonlocal counter
|
|
143
|
+
counter += 1
|
|
144
|
+
|
|
145
|
+
if counter < 10:
|
|
146
|
+
return "result", False
|
|
147
|
+
|
|
148
|
+
return "result", True
|
|
149
|
+
|
|
150
|
+
# The stop callback makes sure that the polling stops sooner, after 3
|
|
151
|
+
# calls.
|
|
152
|
+
def stop() -> bool:
|
|
153
|
+
if counter == 3:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
polling_options = PollingOptions(stop=stop)
|
|
157
|
+
|
|
158
|
+
result = poll(polling_options, polling_func, no_sleep)
|
|
159
|
+
|
|
160
|
+
self.assertIsNone(result)
|
|
161
|
+
|
|
162
|
+
def test_poll_long(self):
|
|
163
|
+
counter = 0
|
|
164
|
+
max_tries = 1000000
|
|
165
|
+
|
|
166
|
+
def polling_func() -> tuple[Any, bool]:
|
|
167
|
+
nonlocal counter
|
|
168
|
+
counter += 1
|
|
169
|
+
|
|
170
|
+
if counter < max_tries:
|
|
171
|
+
return "result", False
|
|
172
|
+
|
|
173
|
+
return "result", True
|
|
174
|
+
|
|
175
|
+
polling_options = PollingOptions(
|
|
176
|
+
max_tries=max_tries + 1,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
result = poll(polling_options, polling_func, no_sleep)
|
|
180
|
+
|
|
181
|
+
self.assertEqual(result, "result")
|
|
File without changes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "v0.29.3"
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from typing import Any
|
|
3
|
-
|
|
4
|
-
from nextmv.cloud.application import PollingOptions, poll
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# This is a dummy function to avoid actually sleeping during tests.
|
|
8
|
-
def no_sleep(value: float) -> None:
|
|
9
|
-
return
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TestApplication(unittest.TestCase):
|
|
13
|
-
def test_poll(self):
|
|
14
|
-
counter = 0
|
|
15
|
-
|
|
16
|
-
def polling_func() -> tuple[Any, bool]:
|
|
17
|
-
nonlocal counter
|
|
18
|
-
counter += 1
|
|
19
|
-
|
|
20
|
-
if counter < 4:
|
|
21
|
-
return "result", False
|
|
22
|
-
|
|
23
|
-
return "result", True
|
|
24
|
-
|
|
25
|
-
polling_options = PollingOptions()
|
|
26
|
-
|
|
27
|
-
result = poll(polling_options, polling_func, no_sleep)
|
|
28
|
-
|
|
29
|
-
self.assertEqual(result, "result")
|
|
30
|
-
|
|
31
|
-
def test_poll_stop_callback(self):
|
|
32
|
-
counter = 0
|
|
33
|
-
|
|
34
|
-
# The polling func would stop after 9 calls.
|
|
35
|
-
def polling_func() -> tuple[Any, bool]:
|
|
36
|
-
nonlocal counter
|
|
37
|
-
counter += 1
|
|
38
|
-
|
|
39
|
-
if counter < 10:
|
|
40
|
-
return "result", False
|
|
41
|
-
|
|
42
|
-
return "result", True
|
|
43
|
-
|
|
44
|
-
# The stop callback makes sure that the polling stops sooner, after 3
|
|
45
|
-
# calls.
|
|
46
|
-
def stop() -> bool:
|
|
47
|
-
if counter == 3:
|
|
48
|
-
return True
|
|
49
|
-
|
|
50
|
-
polling_options = PollingOptions(stop=stop)
|
|
51
|
-
|
|
52
|
-
result = poll(polling_options, polling_func, no_sleep)
|
|
53
|
-
|
|
54
|
-
self.assertIsNone(result)
|
|
55
|
-
|
|
56
|
-
def test_poll_long(self):
|
|
57
|
-
counter = 0
|
|
58
|
-
max_tries = 1000000
|
|
59
|
-
|
|
60
|
-
def polling_func() -> tuple[Any, bool]:
|
|
61
|
-
nonlocal counter
|
|
62
|
-
counter += 1
|
|
63
|
-
|
|
64
|
-
if counter < max_tries:
|
|
65
|
-
return "result", False
|
|
66
|
-
|
|
67
|
-
return "result", True
|
|
68
|
-
|
|
69
|
-
polling_options = PollingOptions(
|
|
70
|
-
max_tries=max_tries + 1,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
result = poll(polling_options, polling_func, no_sleep)
|
|
74
|
-
|
|
75
|
-
self.assertEqual(result, "result")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|