nextmv 0.25.0__tar.gz → 0.26.1__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.25.0 → nextmv-0.26.1}/PKG-INFO +1 -1
- nextmv-0.26.1/nextmv/__about__.py +1 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/__init__.py +1 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/application.py +169 -13
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/batch_experiment.py +3 -3
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/run.py +34 -2
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/logger.py +9 -2
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/output.py +11 -5
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_output.py +28 -1
- nextmv-0.25.0/nextmv/__about__.py +0 -1
- {nextmv-0.25.0 → nextmv-0.26.1}/.gitignore +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/LICENSE +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/README.md +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/__entrypoint__.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/__init__.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/base_model.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/acceptance_test.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/account.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/client.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/input_set.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/instance.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/manifest.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/package.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/safe.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/scenario.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/secrets.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/status.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/version.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/deprecated.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/input.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/model.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/options.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/pyproject.toml +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/requirements.txt +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/__init__.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/__init__.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/app.yaml +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_application.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_client.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_manifest.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_package.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_safe_name_id.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_scenario.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/__init__.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options1.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options2.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options3.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options4.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options5.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options6.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options7.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options_deprecated.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_base_model.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_entrypoint/__init__.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_entrypoint/test_entrypoint.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_input.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_logger.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_model.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_options.py +0 -0
- {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_version.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "v0.26.1"
|
|
@@ -53,6 +53,7 @@ from .run import RunType as RunType
|
|
|
53
53
|
from .run import RunTypeConfiguration as RunTypeConfiguration
|
|
54
54
|
from .run import TrackedRun as TrackedRun
|
|
55
55
|
from .run import TrackedRunStatus as TrackedRunStatus
|
|
56
|
+
from .run import run_duration as run_duration
|
|
56
57
|
from .scenario import Scenario as Scenario
|
|
57
58
|
from .scenario import ScenarioConfiguration as ScenarioConfiguration
|
|
58
59
|
from .scenario import ScenarioInput as ScenarioInput
|
|
@@ -14,7 +14,12 @@ import requests
|
|
|
14
14
|
from nextmv.base_model import BaseModel
|
|
15
15
|
from nextmv.cloud import package
|
|
16
16
|
from nextmv.cloud.acceptance_test import AcceptanceTest, ExperimentStatus, Metric
|
|
17
|
-
from nextmv.cloud.batch_experiment import
|
|
17
|
+
from nextmv.cloud.batch_experiment import (
|
|
18
|
+
BatchExperiment,
|
|
19
|
+
BatchExperimentInformation,
|
|
20
|
+
BatchExperimentMetadata,
|
|
21
|
+
BatchExperimentRun,
|
|
22
|
+
)
|
|
18
23
|
from nextmv.cloud.client import Client, get_size
|
|
19
24
|
from nextmv.cloud.input_set import InputSet, ManagedInput
|
|
20
25
|
from nextmv.cloud.instance import Instance, InstanceConfiguration
|
|
@@ -312,18 +317,7 @@ class Application:
|
|
|
312
317
|
# If the request was successful, the application exists.
|
|
313
318
|
return True
|
|
314
319
|
except requests.HTTPError as e:
|
|
315
|
-
if (
|
|
316
|
-
# Check whether the error is caused by a 404 status code - meaning the app does not exist.
|
|
317
|
-
(hasattr(e, "response") and hasattr(e.response, "status_code") and e.response.status_code == 404)
|
|
318
|
-
or
|
|
319
|
-
# Check a possibly nested exception as well.
|
|
320
|
-
(
|
|
321
|
-
hasattr(e, "__cause__")
|
|
322
|
-
and hasattr(e.__cause__, "response")
|
|
323
|
-
and hasattr(e.__cause__.response, "status_code")
|
|
324
|
-
and e.__cause__.response.status_code == 404
|
|
325
|
-
)
|
|
326
|
-
):
|
|
320
|
+
if _is_not_exist_error(e):
|
|
327
321
|
return False
|
|
328
322
|
# Re-throw the exception if it is not the expected 404 error.
|
|
329
323
|
raise e from None
|
|
@@ -370,6 +364,25 @@ class Application:
|
|
|
370
364
|
|
|
371
365
|
return Instance.from_dict(response.json())
|
|
372
366
|
|
|
367
|
+
def instance_exists(self, instance_id: str) -> bool:
|
|
368
|
+
"""
|
|
369
|
+
Check if an instance exists.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
instance_id: ID of the instance.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
True if the instance exists, False otherwise.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
self.instance(instance_id=instance_id)
|
|
380
|
+
return True
|
|
381
|
+
except requests.HTTPError as e:
|
|
382
|
+
if _is_not_exist_error(e):
|
|
383
|
+
return False
|
|
384
|
+
raise e
|
|
385
|
+
|
|
373
386
|
def list_acceptance_tests(self) -> list[AcceptanceTest]:
|
|
374
387
|
"""
|
|
375
388
|
List all acceptance tests.
|
|
@@ -576,6 +589,8 @@ class Application:
|
|
|
576
589
|
id: ID of the application. Will be generated if not provided.
|
|
577
590
|
description: Description of the application.
|
|
578
591
|
is_workflow: Whether the application is a Decision Workflow.
|
|
592
|
+
exist_ok: If True and an application with the same ID already exists,
|
|
593
|
+
return the existing application instead of creating a new one.
|
|
579
594
|
|
|
580
595
|
Returns:
|
|
581
596
|
The new application.
|
|
@@ -927,6 +942,7 @@ class Application:
|
|
|
927
942
|
name: str,
|
|
928
943
|
description: Optional[str] = None,
|
|
929
944
|
configuration: Optional[InstanceConfiguration] = None,
|
|
945
|
+
exist_ok: bool = False,
|
|
930
946
|
) -> Instance:
|
|
931
947
|
"""
|
|
932
948
|
Create a new instance and associate it with a version.
|
|
@@ -937,6 +953,8 @@ class Application:
|
|
|
937
953
|
name: Name of the instance. Will be generated if not provided.
|
|
938
954
|
description: Description of the instance. Will be generated if not provided.
|
|
939
955
|
configuration: Configuration to use for the instance.
|
|
956
|
+
exist_ok: If True and an instance with the same ID already exists,
|
|
957
|
+
return the existing instance instead of creating a new one.
|
|
940
958
|
|
|
941
959
|
Returns:
|
|
942
960
|
Instance.
|
|
@@ -945,6 +963,12 @@ class Application:
|
|
|
945
963
|
requests.HTTPError: If the response status code is not 2xx.
|
|
946
964
|
"""
|
|
947
965
|
|
|
966
|
+
if exist_ok and id is None:
|
|
967
|
+
raise ValueError("If exist_ok is True, id must be provided")
|
|
968
|
+
|
|
969
|
+
if exist_ok and self.instance_exists(instance_id=id):
|
|
970
|
+
return self.instance(instance_id=id)
|
|
971
|
+
|
|
948
972
|
payload = {
|
|
949
973
|
"version_id": version_id,
|
|
950
974
|
}
|
|
@@ -1473,6 +1497,7 @@ class Application:
|
|
|
1473
1497
|
id: Optional[str] = None,
|
|
1474
1498
|
name: Optional[str] = None,
|
|
1475
1499
|
description: Optional[str] = None,
|
|
1500
|
+
exist_ok: bool = False,
|
|
1476
1501
|
) -> Version:
|
|
1477
1502
|
"""
|
|
1478
1503
|
Create a new version using the current dev binary.
|
|
@@ -1481,6 +1506,8 @@ class Application:
|
|
|
1481
1506
|
id: ID of the version. Will be generated if not provided.
|
|
1482
1507
|
name: Name of the version. Will be generated if not provided.
|
|
1483
1508
|
description: Description of the version. Will be generated if not provided.
|
|
1509
|
+
exist_ok: If True and a version with the same ID already exists,
|
|
1510
|
+
return the existing version instead of creating a new one.
|
|
1484
1511
|
|
|
1485
1512
|
Returns:
|
|
1486
1513
|
Version.
|
|
@@ -1489,6 +1516,12 @@ class Application:
|
|
|
1489
1516
|
requests.HTTPError: If the response status code is not 2xx.
|
|
1490
1517
|
"""
|
|
1491
1518
|
|
|
1519
|
+
if exist_ok and id is None:
|
|
1520
|
+
raise ValueError("If exist_ok is True, id must be provided")
|
|
1521
|
+
|
|
1522
|
+
if exist_ok and self.version_exists(version_id=id):
|
|
1523
|
+
return self.version(version_id=id)
|
|
1524
|
+
|
|
1492
1525
|
payload = {}
|
|
1493
1526
|
|
|
1494
1527
|
if id is not None:
|
|
@@ -1955,6 +1988,47 @@ class Application:
|
|
|
1955
1988
|
|
|
1956
1989
|
return Instance.from_dict(response.json())
|
|
1957
1990
|
|
|
1991
|
+
def update_batch_experiment(
|
|
1992
|
+
self,
|
|
1993
|
+
batch_experiment_id: str,
|
|
1994
|
+
name: str,
|
|
1995
|
+
description: str,
|
|
1996
|
+
) -> BatchExperimentInformation:
|
|
1997
|
+
"""
|
|
1998
|
+
Update a batch experiment.
|
|
1999
|
+
|
|
2000
|
+
Parameters
|
|
2001
|
+
----------
|
|
2002
|
+
batch_experiment_id : str
|
|
2003
|
+
ID of the batch experiment to update.
|
|
2004
|
+
name : str
|
|
2005
|
+
Name of the batch experiment.
|
|
2006
|
+
description : str
|
|
2007
|
+
Description of the batch experiment.
|
|
2008
|
+
|
|
2009
|
+
Returns
|
|
2010
|
+
-------
|
|
2011
|
+
BatchExperimentInformation
|
|
2012
|
+
The information with the updated batch experiment.
|
|
2013
|
+
|
|
2014
|
+
Raises
|
|
2015
|
+
------
|
|
2016
|
+
requests.HTTPError
|
|
2017
|
+
If the response status code is not 2xx.
|
|
2018
|
+
"""
|
|
2019
|
+
|
|
2020
|
+
payload = {
|
|
2021
|
+
"name": name,
|
|
2022
|
+
"description": description,
|
|
2023
|
+
}
|
|
2024
|
+
response = self.client.request(
|
|
2025
|
+
method="PATCH",
|
|
2026
|
+
endpoint=f"{self.experiments_endpoint}/batch/{batch_experiment_id}",
|
|
2027
|
+
payload=payload,
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
return BatchExperimentInformation.from_dict(response.json())
|
|
2031
|
+
|
|
1958
2032
|
def update_managed_input(
|
|
1959
2033
|
self,
|
|
1960
2034
|
managed_input_id: str,
|
|
@@ -1994,6 +2068,43 @@ class Application:
|
|
|
1994
2068
|
payload=payload,
|
|
1995
2069
|
)
|
|
1996
2070
|
|
|
2071
|
+
def update_scenario_test(
|
|
2072
|
+
self,
|
|
2073
|
+
scenario_test_id: str,
|
|
2074
|
+
name: str,
|
|
2075
|
+
description: str,
|
|
2076
|
+
) -> BatchExperimentInformation:
|
|
2077
|
+
"""
|
|
2078
|
+
Update a scenario test. Scenario tests use the batch experiments API,
|
|
2079
|
+
so this method calls the `update_batch_experiment` method, and thus the
|
|
2080
|
+
return type is the same.
|
|
2081
|
+
|
|
2082
|
+
Parameters
|
|
2083
|
+
----------
|
|
2084
|
+
scenario_test_id : str
|
|
2085
|
+
ID of the scenario test to update.
|
|
2086
|
+
name : str
|
|
2087
|
+
Name of the scenario test.
|
|
2088
|
+
description : str
|
|
2089
|
+
Description of the scenario test.
|
|
2090
|
+
|
|
2091
|
+
Returns
|
|
2092
|
+
-------
|
|
2093
|
+
BatchExperimentInformation
|
|
2094
|
+
The information with the updated scenario test.
|
|
2095
|
+
|
|
2096
|
+
Raises
|
|
2097
|
+
------
|
|
2098
|
+
requests.HTTPError
|
|
2099
|
+
If the response status code is not 2xx.
|
|
2100
|
+
"""
|
|
2101
|
+
|
|
2102
|
+
return self.update_batch_experiment(
|
|
2103
|
+
batch_experiment_id=scenario_test_id,
|
|
2104
|
+
name=name,
|
|
2105
|
+
description=description,
|
|
2106
|
+
)
|
|
2107
|
+
|
|
1997
2108
|
def update_secrets_collection(
|
|
1998
2109
|
self,
|
|
1999
2110
|
secrets_collection_id: str,
|
|
@@ -2118,6 +2229,25 @@ class Application:
|
|
|
2118
2229
|
|
|
2119
2230
|
return Version.from_dict(response.json())
|
|
2120
2231
|
|
|
2232
|
+
def version_exists(self, version_id: str) -> bool:
|
|
2233
|
+
"""
|
|
2234
|
+
Check if a version exists.
|
|
2235
|
+
|
|
2236
|
+
Args:
|
|
2237
|
+
version_id: ID of the version.
|
|
2238
|
+
|
|
2239
|
+
Returns:
|
|
2240
|
+
bool: True if the version exists, False otherwise.
|
|
2241
|
+
"""
|
|
2242
|
+
|
|
2243
|
+
try:
|
|
2244
|
+
self.version(version_id=version_id)
|
|
2245
|
+
return True
|
|
2246
|
+
except requests.HTTPError as e:
|
|
2247
|
+
if _is_not_exist_error(e):
|
|
2248
|
+
return False
|
|
2249
|
+
raise e
|
|
2250
|
+
|
|
2121
2251
|
def __run_result(
|
|
2122
2252
|
self,
|
|
2123
2253
|
run_id: str,
|
|
@@ -2353,3 +2483,29 @@ def poll(polling_options: PollingOptions, polling_func: Callable[[], tuple[any,
|
|
|
2353
2483
|
raise RuntimeError(
|
|
2354
2484
|
f"polling did not succeed after {polling_options.max_tries} tries",
|
|
2355
2485
|
)
|
|
2486
|
+
|
|
2487
|
+
|
|
2488
|
+
def _is_not_exist_error(e: requests.HTTPError) -> bool:
|
|
2489
|
+
"""
|
|
2490
|
+
Check if the error is a known 404 Not Found error.
|
|
2491
|
+
|
|
2492
|
+
Args:
|
|
2493
|
+
e: HTTPError to check.
|
|
2494
|
+
|
|
2495
|
+
Returns:
|
|
2496
|
+
True if the error is a 404 Not Found error, False otherwise.
|
|
2497
|
+
"""
|
|
2498
|
+
if (
|
|
2499
|
+
# Check whether the error is caused by a 404 status code - meaning the app does not exist.
|
|
2500
|
+
(hasattr(e, "response") and hasattr(e.response, "status_code") and e.response.status_code == 404)
|
|
2501
|
+
or
|
|
2502
|
+
# Check a possibly nested exception as well.
|
|
2503
|
+
(
|
|
2504
|
+
hasattr(e, "__cause__")
|
|
2505
|
+
and hasattr(e.__cause__, "response")
|
|
2506
|
+
and hasattr(e.__cause__.response, "status_code")
|
|
2507
|
+
and e.__cause__.response.status_code == 404
|
|
2508
|
+
)
|
|
2509
|
+
):
|
|
2510
|
+
return True
|
|
2511
|
+
return False
|
|
@@ -18,9 +18,9 @@ class BatchExperimentInformation(BaseModel):
|
|
|
18
18
|
"""Creation date of the batch experiment."""
|
|
19
19
|
updated_at: datetime
|
|
20
20
|
"""Last update date of the batch experiment."""
|
|
21
|
-
status: str
|
|
22
|
-
"""Status of the batch experiment."""
|
|
23
21
|
|
|
22
|
+
status: Optional[str] = None
|
|
23
|
+
"""Status of the batch experiment."""
|
|
24
24
|
description: Optional[str] = None
|
|
25
25
|
"""Description of the batch experiment."""
|
|
26
26
|
number_of_requested_runs: Optional[int] = None
|
|
@@ -101,5 +101,5 @@ class BatchExperimentRun(BaseModel):
|
|
|
101
101
|
class BatchExperimentMetadata(BatchExperimentInformation):
|
|
102
102
|
"""Metadata of a batch experiment."""
|
|
103
103
|
|
|
104
|
-
app_id: str
|
|
104
|
+
app_id: Optional[str] = None
|
|
105
105
|
"""ID of the application used for the batch experiment."""
|
|
@@ -14,6 +14,38 @@ from nextmv.input import Input, InputFormat
|
|
|
14
14
|
from nextmv.output import Output, OutputFormat
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def run_duration(
|
|
18
|
+
start: Union[datetime, float],
|
|
19
|
+
end: Union[datetime, float],
|
|
20
|
+
) -> int:
|
|
21
|
+
"""
|
|
22
|
+
Calculate the duration of a run in milliseconds.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
start : Union[datetime, float]
|
|
27
|
+
The start time of the run. Can be a datetime object or a float
|
|
28
|
+
representing the start time in seconds since the epoch.
|
|
29
|
+
end : Union[datetime, float]
|
|
30
|
+
The end time of the run. Can be a datetime object or a float
|
|
31
|
+
representing the end time in seconds since the epoch.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
int
|
|
36
|
+
The duration of the run in milliseconds.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(start, float) and isinstance(end, float):
|
|
39
|
+
if start > end:
|
|
40
|
+
raise ValueError("Start time must be before end time.")
|
|
41
|
+
return int(round((end - start) * 1000))
|
|
42
|
+
if isinstance(start, datetime) and isinstance(end, datetime):
|
|
43
|
+
if start > end:
|
|
44
|
+
raise ValueError("Start time must be before end time.")
|
|
45
|
+
return int(round((end - start).total_seconds() * 1000))
|
|
46
|
+
raise TypeError("Start and end must be either datetime or float.")
|
|
47
|
+
|
|
48
|
+
|
|
17
49
|
class Metadata(BaseModel):
|
|
18
50
|
"""Metadata of a run, whether it was successful or not."""
|
|
19
51
|
|
|
@@ -181,7 +213,7 @@ class ExternalRunResult(BaseModel):
|
|
|
181
213
|
error_message: Optional[str] = None
|
|
182
214
|
"""Error message of the run."""
|
|
183
215
|
execution_duration: Optional[int] = None
|
|
184
|
-
"""Duration of the run, in
|
|
216
|
+
"""Duration of the run, in milliseconds."""
|
|
185
217
|
|
|
186
218
|
def __post_init_post_parse__(self):
|
|
187
219
|
"""Validations done after parsing the model."""
|
|
@@ -246,7 +278,7 @@ class TrackedRun:
|
|
|
246
278
|
"""The status of the run being tracked"""
|
|
247
279
|
|
|
248
280
|
duration: Optional[int] = None
|
|
249
|
-
"""The duration of the run being tracked, in
|
|
281
|
+
"""The duration of the run being tracked, in milliseconds."""
|
|
250
282
|
error: Optional[str] = None
|
|
251
283
|
"""An error message if the run failed. You should only specify this if the
|
|
252
284
|
run failed, otherwise an exception will be raised."""
|
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
5
|
__original_stdout = None
|
|
6
|
+
__stdout_redirected = False
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def redirect_stdout() -> None:
|
|
9
10
|
"""Redirect all messages written to stdout to stderr. When you do not want
|
|
10
11
|
to redirect stdout anymore, call `reset_stdout`."""
|
|
11
12
|
|
|
12
|
-
global __original_stdout
|
|
13
|
+
global __original_stdout, __stdout_redirected
|
|
14
|
+
if __stdout_redirected:
|
|
15
|
+
return
|
|
16
|
+
__stdout_redirected = True
|
|
13
17
|
|
|
14
18
|
__original_stdout = sys.stdout
|
|
15
19
|
sys.stdout = sys.stderr
|
|
@@ -19,7 +23,10 @@ def reset_stdout() -> None:
|
|
|
19
23
|
"""Reset stdout to its original value. This function should always be
|
|
20
24
|
called after `redirect_stdout` to avoid unexpected behavior."""
|
|
21
25
|
|
|
22
|
-
global __original_stdout
|
|
26
|
+
global __original_stdout, __stdout_redirected
|
|
27
|
+
if not __stdout_redirected:
|
|
28
|
+
return
|
|
29
|
+
__stdout_redirected = False
|
|
23
30
|
|
|
24
31
|
if __original_stdout is None:
|
|
25
32
|
sys.stdout = sys.__stdout__
|
|
@@ -349,7 +349,7 @@ class Output:
|
|
|
349
349
|
class OutputWriter:
|
|
350
350
|
"""Base class for writing outputs."""
|
|
351
351
|
|
|
352
|
-
def write(self, output: Output, *args, **kwargs) -> None:
|
|
352
|
+
def write(self, output: Union[Output, dict[str, Any], BaseModel], *args, **kwargs) -> None:
|
|
353
353
|
"""
|
|
354
354
|
Write the output data. This method should be implemented by subclasses.
|
|
355
355
|
"""
|
|
@@ -364,7 +364,7 @@ class LocalOutputWriter(OutputWriter):
|
|
|
364
364
|
"""
|
|
365
365
|
|
|
366
366
|
def _write_json(
|
|
367
|
-
output: Union[Output, dict[str, Any]],
|
|
367
|
+
output: Union[Output, dict[str, Any], BaseModel],
|
|
368
368
|
options: dict[str, Any],
|
|
369
369
|
statistics: dict[str, Any],
|
|
370
370
|
assets: list[dict[str, Any]],
|
|
@@ -372,6 +372,8 @@ class LocalOutputWriter(OutputWriter):
|
|
|
372
372
|
) -> None:
|
|
373
373
|
if isinstance(output, dict):
|
|
374
374
|
final_output = output
|
|
375
|
+
elif isinstance(output, BaseModel):
|
|
376
|
+
final_output = output.to_dict()
|
|
375
377
|
else:
|
|
376
378
|
solution = output.solution if output.solution is not None else {}
|
|
377
379
|
final_output = {
|
|
@@ -447,7 +449,7 @@ class LocalOutputWriter(OutputWriter):
|
|
|
447
449
|
|
|
448
450
|
def write(
|
|
449
451
|
self,
|
|
450
|
-
output: Union[Output, dict[str, Any]],
|
|
452
|
+
output: Union[Output, dict[str, Any], BaseModel],
|
|
451
453
|
path: Optional[str] = None,
|
|
452
454
|
skip_stdout_reset: bool = False,
|
|
453
455
|
) -> None:
|
|
@@ -497,8 +499,12 @@ class LocalOutputWriter(OutputWriter):
|
|
|
497
499
|
output_format = output.output_format
|
|
498
500
|
elif isinstance(output, dict):
|
|
499
501
|
output_format = OutputFormat.JSON
|
|
502
|
+
elif isinstance(output, BaseModel):
|
|
503
|
+
output_format = OutputFormat.JSON
|
|
500
504
|
else:
|
|
501
|
-
raise TypeError(
|
|
505
|
+
raise TypeError(
|
|
506
|
+
f"unsupported output type: {type(output)}, supported types are `Output`, `dict`, `BaseModel`"
|
|
507
|
+
)
|
|
502
508
|
|
|
503
509
|
statistics = self._extract_statistics(output)
|
|
504
510
|
options = self._extract_options(output)
|
|
@@ -640,7 +646,7 @@ _LOCAL_OUTPUT_WRITER = LocalOutputWriter()
|
|
|
640
646
|
|
|
641
647
|
|
|
642
648
|
def write(
|
|
643
|
-
output: Union[Output, dict[str, Any]],
|
|
649
|
+
output: Union[Output, dict[str, Any], BaseModel],
|
|
644
650
|
path: Optional[str] = None,
|
|
645
651
|
skip_stdout_reset: bool = False,
|
|
646
652
|
writer: Optional[OutputWriter] = _LOCAL_OUTPUT_WRITER,
|
|
@@ -4,10 +4,11 @@ import os
|
|
|
4
4
|
import shutil
|
|
5
5
|
import unittest
|
|
6
6
|
from io import StringIO
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import Any, Optional
|
|
8
8
|
from unittest.mock import patch
|
|
9
9
|
|
|
10
10
|
import nextmv
|
|
11
|
+
from nextmv.base_model import BaseModel
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class TestOutput(unittest.TestCase):
|
|
@@ -294,6 +295,32 @@ class TestOutput(unittest.TestCase):
|
|
|
294
295
|
|
|
295
296
|
self.assertDictEqual(got, expected)
|
|
296
297
|
|
|
298
|
+
def test_local_write_base_model(self):
|
|
299
|
+
class myClass(BaseModel):
|
|
300
|
+
output: dict[str, Any]
|
|
301
|
+
|
|
302
|
+
output = {
|
|
303
|
+
"i_am": "a_crazy_object",
|
|
304
|
+
"with": [
|
|
305
|
+
{"nested": "values"},
|
|
306
|
+
{"and": "more_craziness"},
|
|
307
|
+
],
|
|
308
|
+
}
|
|
309
|
+
custom_class = myClass(output=output)
|
|
310
|
+
|
|
311
|
+
output_writer = nextmv.LocalOutputWriter()
|
|
312
|
+
|
|
313
|
+
with patch("sys.stdout", new=StringIO()) as mock_stdout:
|
|
314
|
+
output_writer.write(custom_class, skip_stdout_reset=True)
|
|
315
|
+
|
|
316
|
+
got = json.loads(mock_stdout.getvalue())
|
|
317
|
+
|
|
318
|
+
# We test that the `write` method calls the `.to_dict()` method if
|
|
319
|
+
# it detects the output type to be an instance of `BaseModel`.
|
|
320
|
+
expected = {"output": output}
|
|
321
|
+
|
|
322
|
+
self.assertDictEqual(got, expected)
|
|
323
|
+
|
|
297
324
|
def test_local_write_empty_output(self):
|
|
298
325
|
output = nextmv.Output()
|
|
299
326
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "v0.25.0"
|
|
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
|