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.
Files changed (60) hide show
  1. {nextmv-0.25.0 → nextmv-0.26.1}/PKG-INFO +1 -1
  2. nextmv-0.26.1/nextmv/__about__.py +1 -0
  3. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/__init__.py +1 -0
  4. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/application.py +169 -13
  5. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/batch_experiment.py +3 -3
  6. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/run.py +34 -2
  7. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/logger.py +9 -2
  8. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/output.py +11 -5
  9. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_output.py +28 -1
  10. nextmv-0.25.0/nextmv/__about__.py +0 -1
  11. {nextmv-0.25.0 → nextmv-0.26.1}/.gitignore +0 -0
  12. {nextmv-0.25.0 → nextmv-0.26.1}/LICENSE +0 -0
  13. {nextmv-0.25.0 → nextmv-0.26.1}/README.md +0 -0
  14. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/__entrypoint__.py +0 -0
  15. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/__init__.py +0 -0
  16. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/base_model.py +0 -0
  17. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/acceptance_test.py +0 -0
  18. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/account.py +0 -0
  19. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/client.py +0 -0
  20. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/input_set.py +0 -0
  21. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/instance.py +0 -0
  22. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/manifest.py +0 -0
  23. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/package.py +0 -0
  24. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/safe.py +0 -0
  25. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/scenario.py +0 -0
  26. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/secrets.py +0 -0
  27. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/status.py +0 -0
  28. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/cloud/version.py +0 -0
  29. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/deprecated.py +0 -0
  30. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/input.py +0 -0
  31. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/model.py +0 -0
  32. {nextmv-0.25.0 → nextmv-0.26.1}/nextmv/options.py +0 -0
  33. {nextmv-0.25.0 → nextmv-0.26.1}/pyproject.toml +0 -0
  34. {nextmv-0.25.0 → nextmv-0.26.1}/requirements.txt +0 -0
  35. {nextmv-0.25.0 → nextmv-0.26.1}/tests/__init__.py +0 -0
  36. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/__init__.py +0 -0
  37. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/app.yaml +0 -0
  38. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_application.py +0 -0
  39. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_client.py +0 -0
  40. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_manifest.py +0 -0
  41. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_package.py +0 -0
  42. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_safe_name_id.py +0 -0
  43. {nextmv-0.25.0 → nextmv-0.26.1}/tests/cloud/test_scenario.py +0 -0
  44. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/__init__.py +0 -0
  45. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options1.py +0 -0
  46. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options2.py +0 -0
  47. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options3.py +0 -0
  48. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options4.py +0 -0
  49. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options5.py +0 -0
  50. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options6.py +0 -0
  51. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options7.py +0 -0
  52. {nextmv-0.25.0 → nextmv-0.26.1}/tests/scripts/options_deprecated.py +0 -0
  53. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_base_model.py +0 -0
  54. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_entrypoint/__init__.py +0 -0
  55. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_entrypoint/test_entrypoint.py +0 -0
  56. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_input.py +0 -0
  57. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_logger.py +0 -0
  58. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_model.py +0 -0
  59. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_options.py +0 -0
  60. {nextmv-0.25.0 → nextmv-0.26.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextmv
3
- Version: 0.25.0
3
+ Version: 0.26.1
4
4
  Summary: The all-purpose Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://www.nextmv.io/docs/python-sdks/nextmv/installation
@@ -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 BatchExperiment, BatchExperimentMetadata, BatchExperimentRun
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 seconds."""
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 seconds."""
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(f"unsupported output type: {type(output)}, supported types are `Output` or `dict`")
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