nextmv 0.18.0__py3-none-any.whl → 1.0.0.dev2__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.
Files changed (175) hide show
  1. nextmv/__about__.py +1 -1
  2. nextmv/__entrypoint__.py +8 -13
  3. nextmv/__init__.py +53 -0
  4. nextmv/_serialization.py +96 -0
  5. nextmv/base_model.py +54 -9
  6. nextmv/cli/CONTRIBUTING.md +511 -0
  7. nextmv/cli/__init__.py +0 -0
  8. nextmv/cli/cloud/__init__.py +47 -0
  9. nextmv/cli/cloud/acceptance/__init__.py +27 -0
  10. nextmv/cli/cloud/acceptance/create.py +393 -0
  11. nextmv/cli/cloud/acceptance/delete.py +68 -0
  12. nextmv/cli/cloud/acceptance/get.py +104 -0
  13. nextmv/cli/cloud/acceptance/list.py +62 -0
  14. nextmv/cli/cloud/acceptance/update.py +95 -0
  15. nextmv/cli/cloud/account/__init__.py +28 -0
  16. nextmv/cli/cloud/account/create.py +83 -0
  17. nextmv/cli/cloud/account/delete.py +60 -0
  18. nextmv/cli/cloud/account/get.py +66 -0
  19. nextmv/cli/cloud/account/update.py +70 -0
  20. nextmv/cli/cloud/app/__init__.py +35 -0
  21. nextmv/cli/cloud/app/create.py +141 -0
  22. nextmv/cli/cloud/app/delete.py +58 -0
  23. nextmv/cli/cloud/app/exists.py +44 -0
  24. nextmv/cli/cloud/app/get.py +66 -0
  25. nextmv/cli/cloud/app/list.py +61 -0
  26. nextmv/cli/cloud/app/push.py +137 -0
  27. nextmv/cli/cloud/app/update.py +124 -0
  28. nextmv/cli/cloud/batch/__init__.py +29 -0
  29. nextmv/cli/cloud/batch/create.py +454 -0
  30. nextmv/cli/cloud/batch/delete.py +68 -0
  31. nextmv/cli/cloud/batch/get.py +104 -0
  32. nextmv/cli/cloud/batch/list.py +63 -0
  33. nextmv/cli/cloud/batch/metadata.py +66 -0
  34. nextmv/cli/cloud/batch/update.py +95 -0
  35. nextmv/cli/cloud/data/__init__.py +26 -0
  36. nextmv/cli/cloud/data/upload.py +162 -0
  37. nextmv/cli/cloud/ensemble/__init__.py +31 -0
  38. nextmv/cli/cloud/ensemble/create.py +414 -0
  39. nextmv/cli/cloud/ensemble/delete.py +67 -0
  40. nextmv/cli/cloud/ensemble/get.py +65 -0
  41. nextmv/cli/cloud/ensemble/update.py +103 -0
  42. nextmv/cli/cloud/input_set/__init__.py +30 -0
  43. nextmv/cli/cloud/input_set/create.py +170 -0
  44. nextmv/cli/cloud/input_set/get.py +63 -0
  45. nextmv/cli/cloud/input_set/list.py +63 -0
  46. nextmv/cli/cloud/input_set/update.py +123 -0
  47. nextmv/cli/cloud/instance/__init__.py +35 -0
  48. nextmv/cli/cloud/instance/create.py +290 -0
  49. nextmv/cli/cloud/instance/delete.py +62 -0
  50. nextmv/cli/cloud/instance/exists.py +39 -0
  51. nextmv/cli/cloud/instance/get.py +62 -0
  52. nextmv/cli/cloud/instance/list.py +60 -0
  53. nextmv/cli/cloud/instance/update.py +216 -0
  54. nextmv/cli/cloud/managed_input/__init__.py +31 -0
  55. nextmv/cli/cloud/managed_input/create.py +146 -0
  56. nextmv/cli/cloud/managed_input/delete.py +65 -0
  57. nextmv/cli/cloud/managed_input/get.py +63 -0
  58. nextmv/cli/cloud/managed_input/list.py +60 -0
  59. nextmv/cli/cloud/managed_input/update.py +97 -0
  60. nextmv/cli/cloud/run/__init__.py +37 -0
  61. nextmv/cli/cloud/run/cancel.py +37 -0
  62. nextmv/cli/cloud/run/create.py +530 -0
  63. nextmv/cli/cloud/run/get.py +199 -0
  64. nextmv/cli/cloud/run/input.py +86 -0
  65. nextmv/cli/cloud/run/list.py +80 -0
  66. nextmv/cli/cloud/run/logs.py +167 -0
  67. nextmv/cli/cloud/run/metadata.py +67 -0
  68. nextmv/cli/cloud/run/track.py +501 -0
  69. nextmv/cli/cloud/scenario/__init__.py +29 -0
  70. nextmv/cli/cloud/scenario/create.py +451 -0
  71. nextmv/cli/cloud/scenario/delete.py +65 -0
  72. nextmv/cli/cloud/scenario/get.py +102 -0
  73. nextmv/cli/cloud/scenario/list.py +63 -0
  74. nextmv/cli/cloud/scenario/metadata.py +67 -0
  75. nextmv/cli/cloud/scenario/update.py +93 -0
  76. nextmv/cli/cloud/secrets/__init__.py +33 -0
  77. nextmv/cli/cloud/secrets/create.py +206 -0
  78. nextmv/cli/cloud/secrets/delete.py +67 -0
  79. nextmv/cli/cloud/secrets/get.py +66 -0
  80. nextmv/cli/cloud/secrets/list.py +60 -0
  81. nextmv/cli/cloud/secrets/update.py +147 -0
  82. nextmv/cli/cloud/shadow/__init__.py +33 -0
  83. nextmv/cli/cloud/shadow/create.py +184 -0
  84. nextmv/cli/cloud/shadow/delete.py +68 -0
  85. nextmv/cli/cloud/shadow/get.py +61 -0
  86. nextmv/cli/cloud/shadow/list.py +63 -0
  87. nextmv/cli/cloud/shadow/metadata.py +66 -0
  88. nextmv/cli/cloud/shadow/start.py +43 -0
  89. nextmv/cli/cloud/shadow/stop.py +43 -0
  90. nextmv/cli/cloud/shadow/update.py +95 -0
  91. nextmv/cli/cloud/upload/__init__.py +22 -0
  92. nextmv/cli/cloud/upload/create.py +39 -0
  93. nextmv/cli/cloud/version/__init__.py +33 -0
  94. nextmv/cli/cloud/version/create.py +97 -0
  95. nextmv/cli/cloud/version/delete.py +62 -0
  96. nextmv/cli/cloud/version/exists.py +39 -0
  97. nextmv/cli/cloud/version/get.py +62 -0
  98. nextmv/cli/cloud/version/list.py +60 -0
  99. nextmv/cli/cloud/version/update.py +92 -0
  100. nextmv/cli/community/__init__.py +24 -0
  101. nextmv/cli/community/clone.py +270 -0
  102. nextmv/cli/community/list.py +265 -0
  103. nextmv/cli/configuration/__init__.py +23 -0
  104. nextmv/cli/configuration/config.py +195 -0
  105. nextmv/cli/configuration/create.py +94 -0
  106. nextmv/cli/configuration/delete.py +67 -0
  107. nextmv/cli/configuration/list.py +77 -0
  108. nextmv/cli/main.py +188 -0
  109. nextmv/cli/message.py +153 -0
  110. nextmv/cli/options.py +206 -0
  111. nextmv/cli/version.py +38 -0
  112. nextmv/cloud/__init__.py +71 -17
  113. nextmv/cloud/acceptance_test.py +757 -51
  114. nextmv/cloud/account.py +406 -17
  115. nextmv/cloud/application/__init__.py +957 -0
  116. nextmv/cloud/application/_acceptance.py +419 -0
  117. nextmv/cloud/application/_batch_scenario.py +860 -0
  118. nextmv/cloud/application/_ensemble.py +251 -0
  119. nextmv/cloud/application/_input_set.py +227 -0
  120. nextmv/cloud/application/_instance.py +289 -0
  121. nextmv/cloud/application/_managed_input.py +227 -0
  122. nextmv/cloud/application/_run.py +1393 -0
  123. nextmv/cloud/application/_secrets.py +294 -0
  124. nextmv/cloud/application/_shadow.py +314 -0
  125. nextmv/cloud/application/_utils.py +54 -0
  126. nextmv/cloud/application/_version.py +303 -0
  127. nextmv/cloud/assets.py +48 -0
  128. nextmv/cloud/batch_experiment.py +294 -33
  129. nextmv/cloud/client.py +307 -66
  130. nextmv/cloud/ensemble.py +247 -0
  131. nextmv/cloud/input_set.py +120 -2
  132. nextmv/cloud/instance.py +133 -8
  133. nextmv/cloud/integration.py +533 -0
  134. nextmv/cloud/package.py +168 -53
  135. nextmv/cloud/scenario.py +410 -0
  136. nextmv/cloud/secrets.py +234 -0
  137. nextmv/cloud/shadow.py +190 -0
  138. nextmv/cloud/url.py +73 -0
  139. nextmv/cloud/version.py +132 -4
  140. nextmv/default_app/.gitignore +1 -0
  141. nextmv/default_app/README.md +32 -0
  142. nextmv/default_app/app.yaml +12 -0
  143. nextmv/default_app/input.json +5 -0
  144. nextmv/default_app/main.py +37 -0
  145. nextmv/default_app/requirements.txt +2 -0
  146. nextmv/default_app/src/__init__.py +0 -0
  147. nextmv/default_app/src/visuals.py +36 -0
  148. nextmv/deprecated.py +47 -0
  149. nextmv/input.py +861 -90
  150. nextmv/local/__init__.py +5 -0
  151. nextmv/local/application.py +1251 -0
  152. nextmv/local/executor.py +1042 -0
  153. nextmv/local/geojson_handler.py +323 -0
  154. nextmv/local/local.py +97 -0
  155. nextmv/local/plotly_handler.py +61 -0
  156. nextmv/local/runner.py +274 -0
  157. nextmv/logger.py +80 -9
  158. nextmv/manifest.py +1466 -0
  159. nextmv/model.py +241 -66
  160. nextmv/options.py +708 -115
  161. nextmv/output.py +1301 -274
  162. nextmv/polling.py +325 -0
  163. nextmv/run.py +1702 -0
  164. nextmv/safe.py +145 -0
  165. nextmv/status.py +122 -0
  166. nextmv-1.0.0.dev2.dist-info/METADATA +311 -0
  167. nextmv-1.0.0.dev2.dist-info/RECORD +170 -0
  168. {nextmv-0.18.0.dist-info → nextmv-1.0.0.dev2.dist-info}/WHEEL +1 -1
  169. nextmv-1.0.0.dev2.dist-info/entry_points.txt +2 -0
  170. nextmv/cloud/application.py +0 -1405
  171. nextmv/cloud/manifest.py +0 -234
  172. nextmv/cloud/status.py +0 -29
  173. nextmv-0.18.0.dist-info/METADATA +0 -770
  174. nextmv-0.18.0.dist-info/RECORD +0 -25
  175. {nextmv-0.18.0.dist-info → nextmv-1.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
nextmv/output.py CHANGED
@@ -1,26 +1,103 @@
1
- """Module for handling output destinations and data."""
1
+ """
2
+ Module for handling output destinations and data.
3
+
4
+ This module provides classes and functions for handling the output of decision
5
+ problems, including formatting, serialization, and writing to various
6
+ destinations.
7
+
8
+ Classes
9
+ -------
10
+ RunStatistics
11
+ Statistics about a general run.
12
+ ResultStatistics
13
+ Statistics about a specific result.
14
+ DataPoint
15
+ A data point representing a 2D coordinate.
16
+ Series
17
+ A series of data points for visualization or analysis.
18
+ SeriesData
19
+ Data container for multiple series of data points.
20
+ Statistics
21
+ Complete statistics container for a solution, including run metrics and result data.
22
+ OutputFormat
23
+ Enumeration of supported output formats.
24
+ SolutionFile
25
+ Represents a solution to be written as a file.
26
+ VisualSchema
27
+ Enumeration of supported visualization schemas.
28
+ Visual
29
+ Visual schema definition for an asset.
30
+ Asset
31
+ Represents downloadable information that is part of the `Output`.
32
+ Output
33
+ A class for representing the output of a decision problem.
34
+ OutputWriter
35
+ Base class for writing outputs to different destinations.
36
+ LocalOutputWriter
37
+ Class for writing outputs to local files or stdout.
38
+
39
+ Functions
40
+ ---------
41
+ write
42
+ Write the output to the specified destination.
43
+
44
+ Attributes
45
+ ----------
46
+ ASSETS_KEY : str
47
+ Assets key constant used for identifying assets in the run output.
48
+ STATISTICS_KEY : str
49
+ Statistics key constant used for identifying statistics in the run output.
50
+ SOLUTIONS_KEY : str
51
+ Solutions key constant used for identifying solutions in the run output.
52
+ OUTPUTS_KEY : str
53
+ Outputs key constant used for identifying outputs in the run output.
54
+ """
2
55
 
3
56
  import copy
4
57
  import csv
5
- import datetime
6
- import json
7
58
  import os
8
59
  import sys
60
+ from collections.abc import Callable
9
61
  from dataclasses import dataclass
10
62
  from enum import Enum
11
- from typing import Any, Optional, Union
63
+ from typing import Any
12
64
 
13
65
  from pydantic import AliasChoices, Field
14
66
 
67
+ from nextmv._serialization import serialize_json
15
68
  from nextmv.base_model import BaseModel
69
+ from nextmv.deprecated import deprecated
16
70
  from nextmv.logger import reset_stdout
17
71
  from nextmv.options import Options
18
72
 
73
+ ASSETS_KEY = "assets"
74
+ """
75
+ Assets key constant used for identifying assets in the run output.
76
+ """
77
+ STATISTICS_KEY = "statistics"
78
+ """
79
+ Statistics key constant used for identifying statistics in the run output.
80
+ """
81
+ SOLUTIONS_KEY = "solutions"
82
+ """
83
+ Solutions key constant used for identifying solutions in the run output.
84
+ """
85
+ OUTPUTS_KEY = "outputs"
86
+ """
87
+ Outputs key constant used for identifying outputs in the run output.
88
+ """
89
+
19
90
 
20
91
  class RunStatistics(BaseModel):
21
92
  """
22
93
  Statistics about a general run.
23
94
 
95
+ You can import the `RunStatistics` class directly from `nextmv`:
96
+
97
+ ```python
98
+ from nextmv import RunStatistics
99
+ ```
100
+
24
101
  Parameters
25
102
  ----------
26
103
  duration : float, optional
@@ -30,18 +107,23 @@ class RunStatistics(BaseModel):
30
107
  custom : Union[Any, dict[str, Any]], optional
31
108
  Custom statistics created by the user. Can normally expect a `dict[str,
32
109
  Any]`.
110
+
111
+ Examples
112
+ --------
113
+ >>> from nextmv.output import RunStatistics
114
+ >>> stats = RunStatistics(duration=10.5, iterations=100)
115
+ >>> stats.duration
116
+ 10.5
117
+ >>> stats.custom = {"convergence": 0.001}
118
+ >>> stats.to_dict()
119
+ {'duration': 10.5, 'iterations': 100, 'custom': {'convergence': 0.001}}
33
120
  """
34
121
 
35
- duration: Optional[float] = None
122
+ duration: float | None = None
36
123
  """Duration of the run in seconds."""
37
- iterations: Optional[int] = None
124
+ iterations: int | None = None
38
125
  """Number of iterations."""
39
- custom: Optional[
40
- Union[
41
- Any,
42
- dict[str, Any],
43
- ]
44
- ] = None
126
+ custom: Any | dict[str, Any] | None = None
45
127
  """Custom statistics created by the user. Can normally expect a `dict[str,
46
128
  Any]`."""
47
129
 
@@ -50,6 +132,12 @@ class ResultStatistics(BaseModel):
50
132
  """
51
133
  Statistics about a specific result.
52
134
 
135
+ You can import the `ResultStatistics` class directly from `nextmv`:
136
+
137
+ ```python
138
+ from nextmv import ResultStatistics
139
+ ```
140
+
53
141
  Parameters
54
142
  ----------
55
143
  duration : float, optional
@@ -59,25 +147,36 @@ class ResultStatistics(BaseModel):
59
147
  custom : Union[Any, dict[str, Any]], optional
60
148
  Custom statistics created by the user. Can normally expect a `dict[str,
61
149
  Any]`.
150
+
151
+ Examples
152
+ --------
153
+ >>> from nextmv.output import ResultStatistics
154
+ >>> result_stats = ResultStatistics(duration=5.2, value=42.0)
155
+ >>> result_stats.value
156
+ 42.0
157
+ >>> result_stats.custom = {"gap": 0.05}
158
+ >>> result_stats.to_dict()
159
+ {'duration': 5.2, 'value': 42.0, 'custom': {'gap': 0.05}}
62
160
  """
63
161
 
64
- duration: Optional[float] = None
162
+ duration: float | None = None
65
163
  """Duration of the run in seconds."""
66
- value: Optional[float] = None
164
+ value: float | None = None
67
165
  """Value of the result."""
68
- custom: Optional[
69
- Union[
70
- Any,
71
- dict[str, Any],
72
- ]
73
- ] = None
166
+ custom: Any | dict[str, Any] | None = None
74
167
  """Custom statistics created by the user. Can normally expect a `dict[str,
75
168
  Any]`."""
76
169
 
77
170
 
78
171
  class DataPoint(BaseModel):
79
172
  """
80
- A data point.
173
+ A data point representing a 2D coordinate.
174
+
175
+ You can import the `DataPoint` class directly from `nextmv`:
176
+
177
+ ```python
178
+ from nextmv import DataPoint
179
+ ```
81
180
 
82
181
  Parameters
83
182
  ----------
@@ -85,6 +184,15 @@ class DataPoint(BaseModel):
85
184
  X coordinate of the data point.
86
185
  y : float
87
186
  Y coordinate of the data point.
187
+
188
+ Examples
189
+ --------
190
+ >>> from nextmv.output import DataPoint
191
+ >>> point = DataPoint(x=3.5, y=4.2)
192
+ >>> point.x
193
+ 3.5
194
+ >>> point.to_dict()
195
+ {'x': 3.5, 'y': 4.2}
88
196
  """
89
197
 
90
198
  x: float
@@ -95,25 +203,47 @@ class DataPoint(BaseModel):
95
203
 
96
204
  class Series(BaseModel):
97
205
  """
98
- A series of data points.
206
+ A series of data points for visualization or analysis.
207
+
208
+ You can import the `Series` class directly from `nextmv`:
209
+
210
+ ```python
211
+ from nextmv import Series
212
+ ```
99
213
 
100
214
  Parameters
101
215
  ----------
102
216
  name : str, optional
103
217
  Name of the series.
104
218
  data_points : list[DataPoint], optional
105
- Data of the series.
219
+ Data points of the series.
220
+
221
+ Examples
222
+ --------
223
+ >>> from nextmv.output import Series, DataPoint
224
+ >>> points = [DataPoint(x=1.0, y=2.0), DataPoint(x=2.0, y=3.0)]
225
+ >>> series = Series(name="Example Series", data_points=points)
226
+ >>> series.name
227
+ 'Example Series'
228
+ >>> len(series.data_points)
229
+ 2
106
230
  """
107
231
 
108
- name: Optional[str] = None
232
+ name: str | None = None
109
233
  """Name of the series."""
110
- data_points: Optional[list[DataPoint]] = None
234
+ data_points: list[DataPoint] | None = None
111
235
  """Data of the series."""
112
236
 
113
237
 
114
238
  class SeriesData(BaseModel):
115
239
  """
116
- Data of a series.
240
+ Data container for multiple series of data points.
241
+
242
+ You can import the `SeriesData` class directly from `nextmv`:
243
+
244
+ ```python
245
+ from nextmv import SeriesData
246
+ ```
117
247
 
118
248
  Parameters
119
249
  ----------
@@ -121,17 +251,35 @@ class SeriesData(BaseModel):
121
251
  A series for the value of the solution.
122
252
  custom : list[Series], optional
123
253
  A list of series for custom statistics.
254
+
255
+ Examples
256
+ --------
257
+ >>> from nextmv.output import SeriesData, Series, DataPoint
258
+ >>> value_series = Series(name="Solution Value", data_points=[DataPoint(x=0, y=10), DataPoint(x=1, y=5)])
259
+ >>> custom_series = [Series(name="Gap", data_points=[DataPoint(x=0, y=0.5), DataPoint(x=1, y=0.1)])]
260
+ >>> series_data = SeriesData(value=value_series, custom=custom_series)
261
+ >>> series_data.value.name
262
+ 'Solution Value'
263
+ >>> len(series_data.custom)
264
+ 1
124
265
  """
125
266
 
126
- value: Optional[Series] = None
267
+ value: Series | None = None
127
268
  """A series for the value of the solution."""
128
- custom: Optional[list[Series]] = None
269
+ custom: list[Series] | None = None
129
270
  """A list of series for custom statistics."""
130
271
 
131
272
 
132
273
  class Statistics(BaseModel):
133
274
  """
134
- Statistics of a solution.
275
+ Complete statistics container for a solution, including run metrics and
276
+ result data.
277
+
278
+ You can import the `Statistics` class directly from `nextmv`:
279
+
280
+ ```python
281
+ from nextmv import Statistics
282
+ ```
135
283
 
136
284
  Parameters
137
285
  ----------
@@ -143,15 +291,26 @@ class Statistics(BaseModel):
143
291
  Series data about some metric.
144
292
  statistics_schema : str, optional
145
293
  Schema (version). This class only supports `v1`.
294
+
295
+ Examples
296
+ --------
297
+ >>> from nextmv.output import Statistics, RunStatistics, ResultStatistics
298
+ >>> run_stats = RunStatistics(duration=10.0, iterations=50)
299
+ >>> result_stats = ResultStatistics(value=100.0)
300
+ >>> stats = Statistics(run=run_stats, result=result_stats, statistics_schema="v1")
301
+ >>> stats.run.duration
302
+ 10.0
303
+ >>> stats.result.value
304
+ 100.0
146
305
  """
147
306
 
148
- run: Optional[RunStatistics] = None
307
+ run: RunStatistics | None = None
149
308
  """Statistics about the run."""
150
- result: Optional[ResultStatistics] = None
309
+ result: ResultStatistics | None = None
151
310
  """Statistics about the last result."""
152
- series_data: Optional[SeriesData] = None
311
+ series_data: SeriesData | None = None
153
312
  """Data of the series."""
154
- statistics_schema: Optional[str] = Field(
313
+ statistics_schema: str | None = Field(
155
314
  serialization_alias="schema",
156
315
  validation_alias=AliasChoices("schema", "statistics_schema"),
157
316
  default="v1",
@@ -159,17 +318,28 @@ class Statistics(BaseModel):
159
318
  """Schema (version). This class only supports `v1`."""
160
319
 
161
320
 
162
- class OutputFormat(str, Enum):
163
- """Format of an `Input`."""
321
+ class VisualSchema(str, Enum):
322
+ """
323
+ Enumeration of supported visualization schemas.
164
324
 
165
- JSON = "JSON"
166
- """JSON format, utf-8 encoded."""
167
- CSV_ARCHIVE = "CSV_ARCHIVE"
168
- """CSV archive format: multiple CSV files."""
325
+ You can import the `VisualSchema` class directly from `nextmv`:
169
326
 
327
+ ```python
328
+ from nextmv import VisualSchema
329
+ ```
170
330
 
171
- class VisualSchema(str, Enum):
172
- """Schema of a visual asset."""
331
+ This enum defines the different visualization libraries or rendering methods
332
+ that can be used to display custom asset data in the Nextmv Console.
333
+
334
+ Attributes
335
+ ----------
336
+ CHARTJS : str
337
+ Tells Nextmv Console to render the custom asset data with the Chart.js library.
338
+ GEOJSON : str
339
+ Tells Nextmv Console to render the custom asset data as GeoJSON on a map.
340
+ PLOTLY : str
341
+ Tells Nextmv Console to render the custom asset data with the Plotly library.
342
+ """
173
343
 
174
344
  CHARTJS = "chartjs"
175
345
  """Tells Nextmv Console to render the custom asset data with the Chart.js
@@ -184,8 +354,39 @@ class VisualSchema(str, Enum):
184
354
 
185
355
  class Visual(BaseModel):
186
356
  """
187
- Visual schema of an asset that defines how it is plotted in the Nextmv
188
- Console.
357
+ Visual schema definition for an asset.
358
+
359
+ You can import the `Visual` class directly from `nextmv`:
360
+
361
+ ```python
362
+ from nextmv import Visual
363
+ ```
364
+
365
+ This class defines how an asset is plotted in the Nextmv Console,
366
+ including the schema type, label, and display type.
367
+
368
+ Parameters
369
+ ----------
370
+ visual_schema : VisualSchema
371
+ Schema of the visual asset.
372
+ label : str
373
+ Label for the custom tab of the visual asset in the Nextmv Console.
374
+ visual_type : str, optional
375
+ Defines the type of custom visual. Default is "custom-tab".
376
+
377
+ Raises
378
+ ------
379
+ ValueError
380
+ If an unsupported schema or visual_type is provided.
381
+
382
+ Examples
383
+ --------
384
+ >>> from nextmv.output import Visual, VisualSchema
385
+ >>> visual = Visual(visual_schema=VisualSchema.CHARTJS, label="Performance Chart")
386
+ >>> visual.visual_schema
387
+ <VisualSchema.CHARTJS: 'chartjs'>
388
+ >>> visual.label
389
+ 'Performance Chart'
189
390
  """
190
391
 
191
392
  visual_schema: VisualSchema = Field(
@@ -196,7 +397,7 @@ class Visual(BaseModel):
196
397
  label: str
197
398
  """Label for the custom tab of the visual asset in the Nextmv Console."""
198
399
 
199
- visual_type: Optional[str] = Field(
400
+ visual_type: str | None = Field(
200
401
  serialization_alias="type",
201
402
  validation_alias=AliasChoices("type", "visual_type"),
202
403
  default="custom-tab",
@@ -206,6 +407,14 @@ class Visual(BaseModel):
206
407
  details."""
207
408
 
208
409
  def __post_init__(self):
410
+ """
411
+ Validate the visual schema and type.
412
+
413
+ Raises
414
+ ------
415
+ ValueError
416
+ If the visual_schema is not in VisualSchema or if visual_type is not 'custom-tab'.
417
+ """
209
418
  if self.visual_schema not in VisualSchema:
210
419
  raise ValueError(f"unsupported schema: {self.visual_schema}, supported schemas are {VisualSchema}")
211
420
 
@@ -215,153 +424,810 @@ class Visual(BaseModel):
215
424
 
216
425
  class Asset(BaseModel):
217
426
  """
218
- An asset represents downloadable information that is part of the `Output`.
427
+ Represents downloadable information that is part of the `Output`.
428
+
429
+ You can import the `Asset` class directly from `nextmv`:
430
+
431
+ ```python
432
+ from nextmv import Asset
433
+ ```
434
+
435
+ An asset contains content that can be serialized to JSON and optionally
436
+ includes visual information for rendering in the Nextmv Console.
437
+
438
+ Parameters
439
+ ----------
440
+ name : str
441
+ Name of the asset.
442
+ content : Any
443
+ Content of the asset. The type must be serializable to JSON.
444
+ content_type : str, optional
445
+ Content type of the asset. Only "json" is currently supported. Default is "json".
446
+ description : str, optional
447
+ Description of the asset. Default is None.
448
+ visual : Visual, optional
449
+ Visual schema of the asset. Default is None.
450
+
451
+ Raises
452
+ ------
453
+ ValueError
454
+ If the content_type is not "json".
455
+
456
+ Examples
457
+ --------
458
+ >>> from nextmv.output import Asset, Visual, VisualSchema
459
+ >>> visual = Visual(visual_schema=VisualSchema.CHARTJS, label="Solution Progress")
460
+ >>> asset = Asset(
461
+ ... name="optimization_progress",
462
+ ... content={"iterations": [1, 2, 3], "values": [10, 8, 7]},
463
+ ... description="Optimization progress over iterations",
464
+ ... visual=visual
465
+ ... )
466
+ >>> asset.name
467
+ 'optimization_progress'
219
468
  """
220
469
 
221
470
  name: str
222
471
  """Name of the asset."""
223
- content: Any
224
- """Content of the asset. The type must be serializable to JSON."""
225
472
 
226
- content_type: Optional[str] = "json"
473
+ id: str | None = None
474
+ """
475
+ The ID of the asset. This ID will be populated by the Nextmv platform and can be used
476
+ to download the asset later.
477
+ """
478
+ content: Any | None = None
479
+ """
480
+ Content of the asset. The type must be serializable to JSON. Can be empty when
481
+ fetching the asset metadata only (e.g.: via the asset list endpoint).
482
+ """
483
+ content_type: str | None = "json"
227
484
  """Content type of the asset. Only `json` is allowed"""
228
- description: Optional[str] = None
485
+ description: str | None = None
229
486
  """Description of the asset."""
230
- visual: Optional[Visual] = None
487
+ visual: Visual | None = None
231
488
  """Visual schema of the asset."""
232
489
 
233
490
  def __post_init__(self):
491
+ """
492
+ Validate the content type.
493
+
494
+ Raises
495
+ ------
496
+ ValueError
497
+ If the content_type is not "json".
498
+ """
234
499
  if self.content_type != "json":
235
500
  raise ValueError(f"unsupported content_type: {self.content_type}, supported types are `json`")
236
501
 
237
502
 
503
+ class OutputFormat(str, Enum):
504
+ """
505
+ Enumeration of supported output formats.
506
+
507
+ You can import the `OutputFormat` class directly from `nextmv`:
508
+
509
+ ```python
510
+ from nextmv import OutputFormat
511
+ ```
512
+
513
+ This enum defines the different formats that can be used for outputting data.
514
+ Each format has specific requirements and behaviors when writing.
515
+
516
+ Attributes
517
+ ----------
518
+ JSON : str
519
+ JSON format, utf-8 encoded.
520
+ CSV_ARCHIVE : str
521
+ CSV archive format: multiple CSV files.
522
+ MULTI_FILE : str
523
+ Multi-file format: multiple files in a directory.
524
+ TEXT : str
525
+ Text format, utf-8 encoded.
526
+ """
527
+
528
+ JSON = "json"
529
+ """JSON format, utf-8 encoded."""
530
+ CSV_ARCHIVE = "csv-archive"
531
+ """CSV archive format: multiple CSV files."""
532
+ MULTI_FILE = "multi-file"
533
+ """Multi-file format: multiple files in a directory."""
534
+ TEXT = "text"
535
+ """Text format, utf-8 encoded."""
536
+
537
+
538
+ @dataclass
539
+ class SolutionFile:
540
+ """
541
+ Represents a solution to be written as a file.
542
+
543
+ You can import the `SolutionFile` class directly from `nextmv`:
544
+
545
+ ```python
546
+ from nextmv import SolutionFile
547
+ ```
548
+
549
+ This class is used to define a solution that will be written to a file in
550
+ the filesystem. It includes the name of the file, the data to be written,
551
+ and the writer function that will handle the serialization of the data.
552
+ This `SolutionFile` class is typically used in the `Output`, when the
553
+ `Output.output_format` is set to `OutputFormat.MULTI_FILE`. Given that it
554
+ is difficult to handle every edge case of how a solution is serialized, and
555
+ written to a file, this class exists so that the user can implement the
556
+ `writer` callable of their choice and provide it with any `writer_args`
557
+ and `writer_kwargs` they might need.
558
+
559
+ Parameters
560
+ ----------
561
+ name : str
562
+ Name of the output file. The file extension should be included in the
563
+ name.
564
+ data : Any
565
+ The actual data that will be written to the file. This can be any type
566
+ that can be given to the `writer` function. For example, if the `writer`
567
+ is a `csv.DictWriter`, then the data should be a list of dictionaries,
568
+ where each dictionary represents a row in the CSV file.
569
+ writer : Callable
570
+ Callable that writes the solution data to the file. This should be a
571
+ function implemented by the user. There are convenience functions that you
572
+ can use as a writer as well. The `writer` must receive, at the very
573
+ minimum, the following arguments:
574
+
575
+ - `file_path`: a `str` argument which is the location where this solution
576
+ will be written to. This includes the dir and the name of the file. As
577
+ such, the `name` parameter of this class is going to be passed to this
578
+ function joined with the directory where the file will be written.
579
+ - `data`: the actual data that will be written to the file. This can be any
580
+ type that can be given to the `writer` function. The `data` parameter of
581
+ this class is going to be passed to the `writer` function.
582
+
583
+ The `writer` can also receive additional arguments, and keyword arguments.
584
+ The `writer_args` and `writer_kwargs` parameters of this class can be used
585
+ to provide those additional arguments.
586
+ writer_args : Optional[list[Any]], optional
587
+ Positional arguments to pass to the writer function.
588
+ writer_kwargs : Optional[dict[str, Any]], optional
589
+ Keyword arguments to pass to the writer function.
590
+
591
+ Examples
592
+ --------
593
+ >>> from nextmv import SolutionFile
594
+ >>> solution_file = SolutionFile(
595
+ ... name="solution.csv",
596
+ ... data=[{"id": 1, "value": 100}, {"id": 2, "value": 200}],
597
+ ... writer=csv.DictWriter,
598
+ ... writer_kwargs={"fieldnames": ["id", "value"]},
599
+ ... writer_args=[open("solution.csv", "w", newline="")],
600
+ ... )
601
+ """
602
+
603
+ name: str
604
+ """
605
+ Name of the solution (output) file. The file extension should be included in the
606
+ name.
607
+ """
608
+ data: Any
609
+ """
610
+ The actual data that will be written to the file. This can be any type that
611
+ can be given to the `writer` function. For example, if the `writer` is a
612
+ `csv.DictWriter`, then the data should be a list of dictionaries, where
613
+ each dictionary represents a row in the CSV file.
614
+ """
615
+ writer: Callable[[str, Any], None]
616
+ """
617
+ Callable that writes the solution data to the file. This should be a
618
+ function implemented by the user. There are convenience functions that you
619
+ can use as a writer as well. The `writer` must receive, at the very
620
+ minimum, the following arguments:
621
+
622
+ - `file_path`: a `str` argument which is the location where this solution
623
+ will be written to. This includes the dir and the name of the file. As
624
+ such, the `name` parameter of this class is going to be passed to this
625
+ function joined with the directory where the file will be written.
626
+ - `data`: the actual data that will be written to the file. This can be any
627
+ type that can be given to the `writer` function. The `data` parameter of
628
+ this class is going to be passed to the `writer` function.
629
+
630
+ The `writer` can also receive additional arguments, and keyword arguments.
631
+ The `writer_args` and `writer_kwargs` parameters of this class can be used
632
+ to provide those additional arguments.
633
+ """
634
+ writer_args: list[Any] | None = None
635
+ """
636
+ Optional positional arguments to pass to the writer function. This can be
637
+ used to customize the behavior of the writer.
638
+ """
639
+ writer_kwargs: dict[str, Any] | None = None
640
+ """
641
+ Optional keyword arguments to pass to the writer function. This can be used
642
+ to customize the behavior of the writer.
643
+ """
644
+
645
+
646
+ def json_solution_file(
647
+ name: str,
648
+ data: dict[str, Any],
649
+ json_configurations: dict[str, Any] | None = None,
650
+ ) -> SolutionFile:
651
+ """
652
+ This is a convenience function to build a `SolutionFile`. It writes the
653
+ given `data` to a `.json` file with the provided `name`.
654
+
655
+ You can import this function directly from `nextmv`:
656
+
657
+ ```python
658
+ from nextmv import json_solution_file
659
+ ```
660
+
661
+ Parameters
662
+ ----------
663
+ name : str
664
+ Name of the output file. You don't need to include the `.json`
665
+ extension.
666
+ data : dict[str, Any]
667
+ The actual data that will be written to the file. This should be a
668
+ dictionary that can be serialized to JSON.
669
+ json_configurations : Optional[dict[str, Any]], optional
670
+ Optional configuration options for the JSON serialization process. You
671
+ can use these options to configure parameters such as indentation.
672
+
673
+ Returns
674
+ -------
675
+ SolutionFile
676
+ The constructed `SolutionFile` object.
677
+
678
+ Examples
679
+ --------
680
+ >>> from nextmv import json_solution_file
681
+ >>> solution_file = json_solution_file(
682
+ ... name="solution",
683
+ ... data={"id": 1, "value": 100}
684
+ ... )
685
+ >>> solution_file.name
686
+ 'solution.json'
687
+ >>> solution_file.data
688
+ {'id': 1, 'value': 100}
689
+ """
690
+
691
+ if not name.endswith(".json"):
692
+ name += ".json"
693
+
694
+ json_configurations = json_configurations or {}
695
+
696
+ def writer(file_path: str, write_data: dict[str, Any]) -> None:
697
+ serialized = serialize_json(write_data, json_configurations=json_configurations)
698
+
699
+ with open(file_path, "w", encoding="utf-8") as file:
700
+ file.write(serialized + "\n")
701
+
702
+ return SolutionFile(
703
+ name=name,
704
+ data=data,
705
+ writer=writer,
706
+ )
707
+
708
+
709
+ def csv_solution_file(
710
+ name: str,
711
+ data: list[dict[str, Any]],
712
+ csv_configurations: dict[str, Any] | None = None,
713
+ ) -> SolutionFile:
714
+ """
715
+ This is a convenience function to build a `SolutionFile`. It writes the
716
+ given `data` to a `.csv` file with the provided `name`.
717
+
718
+ You can import this function directly from `nextmv`:
719
+
720
+ ```python
721
+ from nextmv import csv_solution_file
722
+ ```
723
+
724
+ Parameters
725
+ ----------
726
+ name : str
727
+ Name of the output file. You don't need to include the `.csv`
728
+ extension.
729
+ data : list[dict[str, Any]]
730
+ The actual data that will be written to the file. This should be a list
731
+ of dictionaries, where each dictionary represents a row in the CSV file.
732
+ The keys of the dictionaries will be used as the column headers in the
733
+ CSV file.
734
+ csv_configurations : Optional[dict[str, Any]], optional
735
+ Optional configuration options for the CSV serialization process.
736
+
737
+ Returns
738
+ -------
739
+ SolutionFile
740
+ The constructed `SolutionFile` object.
741
+
742
+ Examples
743
+ --------
744
+ >>> from nextmv import csv_solution_file
745
+ >>> solution_file = csv_solution_file(
746
+ ... name="solution",
747
+ ... data=[{"id": 1, "value": 100}, {"id": 2, "value": 200}]
748
+ ... )
749
+ >>> solution_file.name
750
+ 'solution.csv'
751
+ >>> solution_file.data
752
+ [{'id': 1, 'value': 100}, {'id': 2, 'value': 200}]
753
+ """
754
+
755
+ if not name.endswith(".csv"):
756
+ name += ".csv"
757
+
758
+ csv_configurations = csv_configurations or {}
759
+
760
+ def writer(file_path: str, write_data: list[dict[str, Any]]) -> None:
761
+ with open(file_path, "w", encoding="utf-8", newline="") as file:
762
+ writer = csv.DictWriter(
763
+ file,
764
+ fieldnames=write_data[0].keys(),
765
+ **csv_configurations,
766
+ )
767
+ writer.writeheader()
768
+ writer.writerows(write_data)
769
+
770
+ return SolutionFile(
771
+ name=name,
772
+ data=data,
773
+ writer=writer,
774
+ )
775
+
776
+
777
+ def text_solution_file(name: str, data: str) -> SolutionFile:
778
+ """
779
+ This is a convenience function to build a `SolutionFile`. It writes the
780
+ given `data` to a utf-8 encoded file with the provided `name`.
781
+
782
+ You can import this function directly from `nextmv`:
783
+
784
+ ```python
785
+ from nextmv import text_solution_file
786
+ ```
787
+
788
+ You must provide the extension as part of the `name` parameter.
789
+
790
+ Parameters
791
+ ----------
792
+ name : str
793
+ Name of the output file. The file extension must be provided in the
794
+ name.
795
+ data : str
796
+ The actual data that will be written to the file.
797
+
798
+ Returns
799
+ -------
800
+ SolutionFile
801
+ The constructed `SolutionFile` object.
802
+
803
+ Examples
804
+ --------
805
+ >>> from nextmv import text_solution_file
806
+ >>> solution_file = text_solution_file(
807
+ ... name="solution.txt",
808
+ ... data="This is a sample text solution."
809
+ ... )
810
+ >>> solution_file.name
811
+ 'solution.txt'
812
+ >>> solution_file.data
813
+ 'This is a sample text solution.'
814
+ """
815
+
816
+ def writer(file_path: str, write_data: str) -> None:
817
+ with open(file_path, "w", encoding="utf-8") as file:
818
+ file.write(write_data + "\n")
819
+
820
+ return SolutionFile(
821
+ name=name,
822
+ data=data,
823
+ writer=writer,
824
+ )
825
+
826
+
238
827
  @dataclass
239
828
  class Output:
240
829
  """
241
- Output of a decision problem. This class is used to be later be written to
242
- some location.
830
+ Output of a decision problem.
243
831
 
244
- The output can be in different formats, such as JSON (default) or
245
- CSV_ARCHIVE.
832
+ You can import the `Output` class directly from `nextmv`:
246
833
 
247
- If you used options, you can also include them in the output, to be
248
- serialized to the write location.
834
+ ```python
835
+ from nextmv import Output
836
+ ```
249
837
 
250
- The most important part of the output is the solution, which represents the
251
- result of the decision problem. The solution’s type must match the
252
- `output_format`:
838
+ This class is used to structure the output of a decision problem that
839
+ can later be written to various destinations. It supports different output
840
+ formats and allows for customization of the serialization process.
253
841
 
254
- - `OutputFormat.JSON`: the data must be `dict[str, Any]`.
255
- - `OutputFormat.CSV_ARCHIVE`: the data must be `dict[str, list[dict[str,
256
- Any]]]`. The keys represent the file names where the data should be
257
- written. The values are lists of dictionaries, where each dictionary
258
- represents a row in the CSV file.
842
+ The `solution`'s type must match the `output_format`:
259
843
 
260
- The statistics are used to keep track of different metrics that were
261
- obtained after the run was completed. Although it can be a simple
262
- dictionary, we recommend using the `Statistics` class to ensure that the
263
- data is correctly formatted.
844
+ - `OutputFormat.JSON`: the data must be `dict[str, Any]` or `Any`.
845
+ - `OutputFormat.CSV_ARCHIVE`: the data must be `dict[str, list[dict[str,
846
+ Any]]]`. The keys represent the file names where the data should be
847
+ written. The values are lists of dictionaries, where each dictionary
848
+ represents a row in the CSV file.
849
+
850
+ If you are working with `OutputFormat.MULTI_FILE`, you should use
851
+ `solution_files` instead of `solution`. When `solution_files` is not
852
+ `None`, then the `output_format` _must_ be `OutputFormat.MULTI_FILE`.
853
+ `solution_files` is a list of `SolutionFile` objects, which allows you to
854
+ define the name of the file, the data to be written, and the writer
855
+ function that will handle the serialization of the data. This is useful when
856
+ you need to write the solution to multiple files with different formats or
857
+ configurations.
858
+
859
+ There are convenience functions to create `SolutionFile` objects for
860
+ common use cases, such as:
861
+
862
+ - `json_solution_file`: for writing JSON data to a file.
863
+ - `csv_solution_file`: for writing CSV data to a file.
864
+ - `text_solution_file`: for writing utf-8 encoded data to a file.
865
+
866
+ For other data types, such as Excel, you can create your own `SolutionFile`
867
+ objects by providing a `name`, `data`, and a `writer` function that will
868
+ handle the serialization of the data.
264
869
 
265
870
  Parameters
266
871
  ----------
267
- options : Options, optional
268
- Options that the `Input` were created with.
269
- output_format : OutputFormat, optional
872
+ options : Optional[Union[Options, dict[str, Any]]], optional
873
+ Options that the `Output` was created with. These options can be of type
874
+ `Options` or a simple dictionary. Default is None.
875
+ output_format : Optional[OutputFormat], optional
270
876
  Format of the output data. Default is `OutputFormat.JSON`.
271
- solution : Union[dict[str, Any], dict[str, list[dict[str, Any]]], optional
272
- The solution to the decision problem.
273
- statistics : Union[Statistics, dict[str, Any], optional
274
- Statistics of the solution.
275
- """
276
-
277
- options: Optional[Options] = None
278
- """Options that the `Input` were created with."""
279
- output_format: Optional[OutputFormat] = OutputFormat.JSON
280
- """Format of the output data. Default is `OutputFormat.JSON`."""
281
- solution: Optional[
282
- Union[
283
- Union[dict[str, Any], Any], # JSON
284
- dict[str, list[dict[str, Any]]], # CSV_ARCHIVE
285
- ]
286
- ] = None
287
- """The solution to the decision problem."""
288
- statistics: Optional[Union[Statistics, dict[str, Any]]] = None
289
- """Statistics of the solution."""
290
- csv_configurations: Optional[dict[str, Any]] = None
291
- """Optional configuration for writing CSV files, to be used when the
292
- `output_format` is OutputFormat.CSV_ARCHIVE. These configurations are
293
- passed as kwargs to the `DictWriter` class from the `csv` module."""
294
- assets: Optional[list[Asset]] = None
295
- """Optional list of assets to be included in the output."""
877
+ solution : Optional[Union[dict[str, Any], Any, dict[str, list[dict[str, Any]]]]], optional
878
+ The solution to the decision problem. The type must match the
879
+ `output_format`. Default is None.
880
+ statistics : Optional[Union[Statistics, dict[str, Any]]], optional
881
+ Statistics of the solution. Default is None.
882
+ csv_configurations : Optional[dict[str, Any]], optional
883
+ Configuration for writing CSV files. Default is None.
884
+ json_configurations : Optional[dict[str, Any]], optional
885
+ Configuration for writing JSON files. Default is None.
886
+ assets : Optional[list[Union[Asset, dict[str, Any]]]], optional
887
+ List of assets to be included in the output. Default is None.
888
+ solution_files: Optional[list[SolutionFile]], default = None
889
+ Optional list of solution files to be included in the output. These
890
+ files are of type `SolutionFile`, which allows for custom serialization
891
+ and writing of the solution data to files. When this field is
892
+ specified, then the `output_format` must be set to
893
+ `OutputFormat.MULTI_FILE`, otherwise an exception will be raised. The
894
+ `SolutionFile` class allows you to define the name of the file, the
895
+ data to be written, and the writer function that will handle the
896
+ serialization of the data. This is useful when you need to write the
897
+ solution to multiple files with different formats or configurations.
898
+
899
+ There are convenience functions to create `SolutionFile` objects for
900
+ common use cases, such as:
901
+
902
+ - `json_solution_file`: for writing JSON data to a file.
903
+ - `csv_solution_file`: for writing CSV data to a file.
904
+ - `text_solution_file`: for writing utf-8 encoded data to a file.
905
+
906
+ For other data types, such as Excel, you can create your own
907
+ `SolutionFile` objects by providing a `name`, `data`, and a `writer`
908
+ function that will handle the serialization of the data.
909
+
910
+ Raises
911
+ ------
912
+ ValueError
913
+ If the solution is not compatible with the specified output_format.
914
+ TypeError
915
+ If options, statistics, or assets have unsupported types.
916
+
917
+ Examples
918
+ --------
919
+ >>> from nextmv.output import Output, OutputFormat, Statistics, RunStatistics
920
+ >>> run_stats = RunStatistics(duration=30.0, iterations=100)
921
+ >>> stats = Statistics(run=run_stats)
922
+ >>> solution = {"routes": [{"vehicle": 1, "stops": [1, 2, 3]}, {"vehicle": 2, "stops": [4, 5]}]}
923
+ >>> output = Output(
924
+ ... output_format=OutputFormat.JSON,
925
+ ... solution=solution,
926
+ ... statistics=stats,
927
+ ... json_configurations={"indent": 4}
928
+ ... )
929
+ >>> output_dict = output.to_dict()
930
+ >>> "solution" in output_dict and "statistics" in output_dict
931
+ True
932
+ """
933
+
934
+ options: Options | dict[str, Any] | None = None
935
+ """
936
+ Options that the `Output` was created with. These options can be of type
937
+ `Options` or a simple dictionary. If the options are of type `Options`,
938
+ they will be serialized to a dictionary using the `to_dict` method. If
939
+ they are a dictionary, they will be used as is. If the options are not
940
+ provided, an empty dictionary will be used. If the options are of type
941
+ `dict`, then the dictionary should have the following structure:
942
+
943
+ ```python
944
+ {
945
+ "duration": "30",
946
+ "threads": 4,
947
+ }
948
+ ```
949
+ """
950
+ output_format: OutputFormat | None = OutputFormat.JSON
951
+ """
952
+ Format of the output data. Default is `OutputFormat.JSON`. When set to
953
+ `OutputFormat.MULTI_FILE`, the `solution_files` field must be specified and
954
+ cannot be `None`.
955
+ """
956
+ solution: dict[str, Any] | Any | dict[str, list[dict[str, Any]]] | None = None
957
+ """
958
+ The solution to the decision problem. Use this filed when working with
959
+ `output_format` of types:
960
+
961
+ - `OutputFormat.JSON`: the data must be `dict[str, Any]` or `Any`.
962
+ - `OutputFormat.CSV_ARCHIVE`: the data must be `dict[str, list[dict[str,
963
+ Any]]]`. The keys represent the file names where the data will be written
964
+ to. The values are lists of dictionaries, where each dictionary represents
965
+ a row in the CSV file.
966
+
967
+ Note that when the `output_format` is set to `OutputFormat.MULTI_FILE`,
968
+ this `solution` field is ignored, as you should use the `solution_files`
969
+ field instead.
970
+ """
971
+ statistics: Statistics | dict[str, Any] | None = None
972
+ """
973
+ Statistics of the solution. These statistics can be of type `Statistics` or a
974
+ simple dictionary. If the statistics are of type `Statistics`, they will be
975
+ serialized to a dictionary using the `to_dict` method. If they are a
976
+ dictionary, they will be used as is. If the statistics are not provided, an
977
+ empty dictionary will be used.
978
+ """
979
+ csv_configurations: dict[str, Any] | None = None
980
+ """
981
+ Optional configuration for writing CSV files, to be used when the
982
+ `output_format` is `OutputFormat.CSV_ARCHIVE`. These configurations are
983
+ passed as kwargs to the `DictWriter` class from the `csv` module.
984
+ """
985
+ json_configurations: dict[str, Any] | None = None
986
+ """
987
+ Optional configuration for writing JSON files, to be used when the
988
+ `output_format` is `OutputFormat.JSON`. These configurations are passed as
989
+ kwargs to the `json.dumps` function.
990
+ """
991
+ assets: list[Asset | dict[str, Any]] | None = None
992
+ """
993
+ Optional list of assets to be included in the output. These assets can be of
994
+ type `Asset` or a simple dictionary. If the assets are of type `Asset`, they
995
+ will be serialized to a dictionary using the `to_dict` method. If they are a
996
+ dictionary, they will be used as is. If the assets are not provided, an
997
+ empty list will be used.
998
+ """
999
+ solution_files: list[SolutionFile] | None = None
1000
+ """
1001
+ Optional list of solution files to be included in the output. These files
1002
+ are of type `SolutionFile`, which allows for custom serialization and
1003
+ writing of the solution data to files. When this field is specified, then
1004
+ the `output_format` must be set to `OutputFormat.MULTI_FILE`, otherwise an
1005
+ exception will be raised. The `SolutionFile` class allows you to define the
1006
+ name of the file, the data to be written, and the writer function that will
1007
+ handle the serialization of the data. This is useful when you need to write
1008
+ the solution to multiple files with different formats or configurations.
1009
+
1010
+ There are convenience functions to create `SolutionFile` objects for
1011
+ common use cases, such as:
1012
+
1013
+ - `json_solution_file`: for writing JSON data to a file.
1014
+ - `csv_solution_file`: for writing CSV data to a file.
1015
+ - `text_solution_file`: for writing utf-8 encoded data to a file.
1016
+
1017
+ For other data types, such as Excel, you can create your own `SolutionFile`
1018
+ objects by providing a `name`, `data`, and a `writer` function that will
1019
+ handle the serialization of the data.
1020
+ """
296
1021
 
297
1022
  def __post_init__(self):
298
- """Check that the solution matches the format given to initialize the
299
- class."""
1023
+ """
1024
+ Initialize and validate the Output instance.
1025
+
1026
+ This method performs two main tasks:
1027
+ 1. Creates a deep copy of the options to preserve the original values
1028
+ 2. Validates that the solution matches the specified output_format
300
1029
 
1030
+ Raises
1031
+ ------
1032
+ ValueError
1033
+ If the solution is not compatible with the specified output_format.
1034
+ """
301
1035
  # Capture a snapshot of the options that were used to create the class
302
1036
  # so even if they are changed later, we have a record of the original.
303
1037
  init_options = self.options
304
1038
  new_options = copy.deepcopy(init_options)
305
1039
  self.options = new_options
306
1040
 
307
- if self.solution is None:
308
- return
309
-
310
- if self.output_format == OutputFormat.JSON:
311
- try:
312
- _ = json.dumps(self.solution, default=_custom_serial)
313
- except (TypeError, OverflowError) as e:
1041
+ if self.solution is not None:
1042
+ if self.output_format == OutputFormat.JSON:
1043
+ try:
1044
+ _ = serialize_json(self.solution)
1045
+ except (TypeError, OverflowError) as e:
1046
+ raise ValueError(
1047
+ f"Output has output_format OutputFormat.JSON and "
1048
+ f"Output.solution is of type {type(self.solution)}, which is not JSON serializable"
1049
+ ) from e
1050
+
1051
+ elif self.output_format == OutputFormat.CSV_ARCHIVE and not isinstance(self.solution, dict):
314
1052
  raise ValueError(
315
- f"Output has output_format OutputFormat.JSON and "
316
- f"Output.solution is of type {type(self.solution)}, which is not JSON serializable"
317
- ) from e
1053
+ f"unsupported Output.solution type: {type(self.solution)} with "
1054
+ "output_format OutputFormat.CSV_ARCHIVE, supported type is `dict`"
1055
+ )
318
1056
 
319
- elif self.output_format == OutputFormat.CSV_ARCHIVE and not isinstance(self.solution, dict):
1057
+ if self.solution_files is not None and self.output_format != OutputFormat.MULTI_FILE:
320
1058
  raise ValueError(
321
- f"unsupported Output.solution type: {type(self.solution)} with "
322
- "output_format OutputFormat.CSV_ARCHIVE, supported type is `dict`"
1059
+ f"`solution_files` are not `None`, but `output_format` is different from `OutputFormat.MULTI_FILE`: "
1060
+ f"{self.output_format}. If you want to use `solution_files`, set `output_format` "
1061
+ "to `OutputFormat.MULTI_FILE`."
1062
+ )
1063
+ elif self.solution_files is not None and not isinstance(self.solution_files, list):
1064
+ raise TypeError(
1065
+ f"unsupported Output.solution_files type: {type(self.solution_files)}, supported type is `list`"
1066
+ )
1067
+
1068
+ def to_dict(self) -> dict[str, Any]: # noqa: C901
1069
+ """
1070
+ Convert the `Output` object to a dictionary.
1071
+
1072
+ Returns
1073
+ -------
1074
+ dict[str, Any]
1075
+ The dictionary representation of the `Output` object.
1076
+ """
1077
+
1078
+ # Options need to end up as a dict, so we achieve that based on the
1079
+ # type of options that were used to create the class.
1080
+ if self.options is None:
1081
+ options = {}
1082
+ elif isinstance(self.options, Options):
1083
+ options = self.options.to_dict()
1084
+ elif isinstance(self.options, dict):
1085
+ options = self.options
1086
+ else:
1087
+ raise TypeError(f"unsupported options type: {type(self.options)}, supported types are `Options` or `dict`")
1088
+
1089
+ # Statistics need to end up as a dict, so we achieve that based on the
1090
+ # type of statistics that were used to create the class.
1091
+ if self.statistics is None:
1092
+ statistics = {}
1093
+ elif isinstance(self.statistics, Statistics):
1094
+ statistics = self.statistics.to_dict()
1095
+ elif isinstance(self.statistics, dict):
1096
+ statistics = self.statistics
1097
+ else:
1098
+ raise TypeError(
1099
+ f"unsupported statistics type: {type(self.statistics)}, supported types are `Statistics` or `dict`"
323
1100
  )
324
1101
 
1102
+ # Assets need to end up as a list of dicts, so we achieve that based on
1103
+ # the type of each asset in the list.
1104
+ assets = []
1105
+ if isinstance(self.assets, list):
1106
+ for ix, asset in enumerate(self.assets):
1107
+ if isinstance(asset, Asset):
1108
+ assets.append(asset.to_dict())
1109
+ elif isinstance(asset, dict):
1110
+ assets.append(asset)
1111
+ else:
1112
+ raise TypeError(
1113
+ f"unsupported asset {ix}, type: {type(asset)}; supported types are `Asset` or `dict`"
1114
+ )
1115
+ elif self.assets is not None:
1116
+ raise TypeError(f"unsupported assets type: {type(self.assets)}, supported types are `list`")
1117
+
1118
+ output_dict = {
1119
+ "options": options,
1120
+ "solution": self.solution if self.solution is not None else {},
1121
+ STATISTICS_KEY: statistics,
1122
+ ASSETS_KEY: assets,
1123
+ }
1124
+
1125
+ # Add the auxiliary configurations to the output dictionary if they are
1126
+ # defined and not empty.
1127
+ if (
1128
+ self.output_format == OutputFormat.CSV_ARCHIVE
1129
+ and self.csv_configurations is not None
1130
+ and self.csv_configurations != {}
1131
+ ):
1132
+ output_dict["csv_configurations"] = self.csv_configurations
1133
+
1134
+ return output_dict
1135
+
325
1136
 
326
1137
  class OutputWriter:
327
- """Base class for writing outputs."""
1138
+ """
1139
+ Base class for writing outputs.
328
1140
 
329
- def write(self, output: Output, *args, **kwargs) -> None:
330
- """
331
- Write the output data. This method should be implemented by subclasses.
1141
+ You can import the `OutputWriter` class directly from `nextmv`:
1142
+
1143
+ ```python
1144
+ from nextmv import OutputWriter
1145
+ ```
1146
+
1147
+ This is an abstract base class that defines the interface for writing outputs
1148
+ to different destinations. Subclasses should implement the `write` method.
1149
+
1150
+ Examples
1151
+ --------
1152
+ >>> class CustomOutputWriter(OutputWriter):
1153
+ ... def write(self, output, path=None, **kwargs):
1154
+ ... # Custom implementation for writing output
1155
+ ... print(f"Writing output to {path}")
1156
+ """
1157
+
1158
+ def write(self, output: Output | dict[str, Any] | BaseModel, *args, **kwargs) -> None:
332
1159
  """
1160
+ Write the output data.
1161
+
1162
+ This is an abstract method that should be implemented by subclasses.
333
1163
 
1164
+ Parameters
1165
+ ----------
1166
+ output : Union[Output, dict[str, Any], BaseModel]
1167
+ The output data to write.
1168
+ *args
1169
+ Variable length argument list.
1170
+ **kwargs
1171
+ Arbitrary keyword arguments.
1172
+
1173
+ Raises
1174
+ ------
1175
+ NotImplementedError
1176
+ This method must be implemented by subclasses.
1177
+ """
334
1178
  raise NotImplementedError
335
1179
 
336
1180
 
337
1181
  class LocalOutputWriter(OutputWriter):
338
1182
  """
339
- Class for write outputs to local files or stdout. Call the `write` method
340
- to write the output data.
1183
+ Class for writing outputs to local files or stdout.
1184
+
1185
+ You can import the `LocalOutputWriter` class directly from `nextmv`:
1186
+
1187
+ ```python
1188
+ from nextmv import LocalOutputWriter
1189
+ ```
1190
+
1191
+ This class implements the OutputWriter interface to write output data to
1192
+ local files or stdout. The destination and format depend on the output
1193
+ format and the provided path.
1194
+
1195
+ Examples
1196
+ --------
1197
+ >>> from nextmv.output import LocalOutputWriter, Output, Statistics
1198
+ >>> writer = LocalOutputWriter()
1199
+ >>> output = Output(solution={"result": 42}, statistics=Statistics())
1200
+ >>> # Write to stdout
1201
+ >>> writer.write(output, path=None)
1202
+ >>> # Write to a file
1203
+ >>> writer.write(output, path="results.json")
341
1204
  """
342
1205
 
343
1206
  def _write_json(
344
- output: Union[Output, dict[str, Any]],
345
- options: dict[str, Any],
346
- statistics: dict[str, Any],
347
- assets: list[dict[str, Any]],
348
- path: Optional[str] = None,
1207
+ self,
1208
+ output: Output | dict[str, Any] | BaseModel,
1209
+ output_dict: dict[str, Any],
1210
+ path: str | None = None,
349
1211
  ) -> None:
350
- if isinstance(output, dict):
351
- final_output = output
352
- else:
353
- solution = output.solution if output.solution is not None else {}
354
- final_output = {
355
- "options": options,
356
- "solution": solution,
357
- "statistics": statistics,
358
- "assets": assets,
359
- }
360
-
361
- serialized = json.dumps(
362
- final_output,
363
- indent=2,
364
- default=_custom_serial,
1212
+ """
1213
+ Write output in JSON format.
1214
+
1215
+ Parameters
1216
+ ----------
1217
+ output : Union[Output, dict[str, Any], BaseModel]
1218
+ The output object containing configuration.
1219
+ output_dict : dict[str, Any]
1220
+ Dictionary representation of the output to write.
1221
+ path : str, optional
1222
+ Path to write the output. If None or empty, writes to stdout.
1223
+ """
1224
+ json_configurations = {}
1225
+ if hasattr(output, "json_configurations") and output.json_configurations is not None:
1226
+ json_configurations = output.json_configurations
1227
+
1228
+ serialized = serialize_json(
1229
+ output_dict,
1230
+ json_configurations=json_configurations,
365
1231
  )
366
1232
 
367
1233
  if path is None or path == "":
@@ -372,12 +1238,29 @@ class LocalOutputWriter(OutputWriter):
372
1238
  file.write(serialized + "\n")
373
1239
 
374
1240
  def _write_archive(
375
- output: Output,
376
- options: dict[str, Any],
377
- statistics: dict[str, Any],
378
- assets: list[dict[str, Any]],
379
- path: Optional[str] = None,
1241
+ self,
1242
+ output: Output | dict[str, Any] | BaseModel,
1243
+ output_dict: dict[str, Any],
1244
+ path: str | None = None,
380
1245
  ) -> None:
1246
+ """
1247
+ Write output in CSV archive format.
1248
+
1249
+ Parameters
1250
+ ----------
1251
+ output : Union[Output, dict[str, Any], BaseModel]
1252
+ The output object containing configuration and solution data.
1253
+ output_dict : dict[str, Any]
1254
+ Dictionary representation of the output to write.
1255
+ path : str, optional
1256
+ Directory path to write the CSV files. If None or empty,
1257
+ writes to a directory named "output" in the current working directory.
1258
+
1259
+ Raises
1260
+ ------
1261
+ ValueError
1262
+ If the path is an existing file instead of a directory.
1263
+ """
381
1264
  dir_path = "output"
382
1265
  if path is not None and path != "":
383
1266
  if os.path.isfile(path):
@@ -388,13 +1271,17 @@ class LocalOutputWriter(OutputWriter):
388
1271
  if not os.path.exists(dir_path):
389
1272
  os.makedirs(dir_path)
390
1273
 
391
- serialized = json.dumps(
1274
+ json_configurations = {}
1275
+ if hasattr(output, "json_configurations") and output.json_configurations is not None:
1276
+ json_configurations = output.json_configurations
1277
+
1278
+ serialized = serialize_json(
392
1279
  {
393
- "options": options,
394
- "statistics": statistics,
395
- "assets": assets,
1280
+ "options": output_dict.get("options", {}),
1281
+ STATISTICS_KEY: output_dict.get(STATISTICS_KEY, {}),
1282
+ ASSETS_KEY: output_dict.get(ASSETS_KEY, []),
396
1283
  },
397
- indent=2,
1284
+ json_configurations=json_configurations,
398
1285
  )
399
1286
  print(serialized, file=sys.stdout)
400
1287
 
@@ -416,53 +1303,183 @@ class LocalOutputWriter(OutputWriter):
416
1303
  writer.writeheader()
417
1304
  writer.writerows(data)
418
1305
 
1306
+ def _write_multi_file(
1307
+ self,
1308
+ output: Output | dict[str, Any] | BaseModel,
1309
+ output_dict: dict[str, Any],
1310
+ path: str | None = None,
1311
+ ) -> None:
1312
+ """
1313
+ Write output to multiple files.
1314
+
1315
+ Parameters
1316
+ ----------
1317
+ output : Union[Output, dict[str, Any], BaseModel]
1318
+ The output object containing configuration and solution data.
1319
+ output_dict : dict[str, Any]
1320
+ Dictionary representation of the output to write.
1321
+ path : str, optional
1322
+ Directory path to write the CSV files. If None or empty,
1323
+ writes to a directory named "output" in the current working directory.
1324
+
1325
+ Raises
1326
+ ------
1327
+ ValueError
1328
+ If the path is an existing file instead of a directory.
1329
+ """
1330
+ dir_path = OUTPUTS_KEY
1331
+ if path is not None and path != "":
1332
+ if os.path.isfile(path):
1333
+ raise ValueError(f"The path refers to an existing file: {path}")
1334
+
1335
+ dir_path = path
1336
+
1337
+ if not os.path.exists(dir_path):
1338
+ os.makedirs(dir_path)
1339
+
1340
+ json_configurations = {}
1341
+ if hasattr(output, "json_configurations") and output.json_configurations is not None:
1342
+ json_configurations = output.json_configurations
1343
+
1344
+ self._write_multi_file_element(
1345
+ parent_dir=dir_path,
1346
+ json_configurations=json_configurations,
1347
+ output_dict=output_dict,
1348
+ element_key=STATISTICS_KEY,
1349
+ )
1350
+ self._write_multi_file_element(
1351
+ parent_dir=dir_path,
1352
+ json_configurations=json_configurations,
1353
+ output_dict=output_dict,
1354
+ element_key=ASSETS_KEY,
1355
+ )
1356
+ self._write_multi_file_solution(dir_path=dir_path, output=output)
1357
+
1358
+ def _write_multi_file_element(
1359
+ self,
1360
+ parent_dir: str,
1361
+ output_dict: dict[str, Any],
1362
+ element_key: str,
1363
+ json_configurations: dict[str, Any] | None = None,
1364
+ ):
1365
+ """
1366
+ Auxiliary function to write a specific element of the output
1367
+ dictionary to a file in the specified parent directory.
1368
+ """
1369
+
1370
+ element = output_dict.get(element_key)
1371
+ if element is None or not element:
1372
+ return
1373
+
1374
+ final_dir = os.path.join(parent_dir, element_key)
1375
+
1376
+ if not os.path.exists(final_dir):
1377
+ os.makedirs(final_dir)
1378
+
1379
+ keyed_element = {element_key: element} # The element is expected behind its key.
1380
+
1381
+ serialized = serialize_json(keyed_element, json_configurations=json_configurations)
1382
+
1383
+ with open(os.path.join(final_dir, f"{element_key}.json"), "w", encoding="utf-8") as file:
1384
+ file.write(serialized + "\n")
1385
+
1386
+ def _write_multi_file_solution(
1387
+ self,
1388
+ dir_path: str,
1389
+ output: Output,
1390
+ ):
1391
+ """
1392
+ Auxiliary function to write the solution files to the specified
1393
+ directory.
1394
+ """
1395
+
1396
+ if output.solution_files is None:
1397
+ return
1398
+
1399
+ solutions_dir = os.path.join(dir_path, SOLUTIONS_KEY)
1400
+
1401
+ if not os.path.exists(solutions_dir):
1402
+ os.makedirs(solutions_dir)
1403
+
1404
+ for solution_file in output.solution_files:
1405
+ if not isinstance(solution_file, SolutionFile):
1406
+ raise TypeError(
1407
+ f"unsupported solution_file type: {type(solution_file)}, supported type is `SolutionFile`"
1408
+ )
1409
+
1410
+ file_path = os.path.join(solutions_dir, solution_file.name)
1411
+ if solution_file.writer_args is None:
1412
+ solution_file.writer_args = []
1413
+ if solution_file.writer_kwargs is None:
1414
+ solution_file.writer_kwargs = {}
1415
+
1416
+ # Call the writer function with the final path, and user provided
1417
+ # arguments and keyword arguments.
1418
+ solution_file.writer(
1419
+ file_path,
1420
+ solution_file.data,
1421
+ *solution_file.writer_args,
1422
+ **solution_file.writer_kwargs,
1423
+ )
1424
+
419
1425
  # Callback functions for writing the output data.
420
1426
  FILE_WRITERS = {
421
1427
  OutputFormat.JSON: _write_json,
422
1428
  OutputFormat.CSV_ARCHIVE: _write_archive,
1429
+ OutputFormat.MULTI_FILE: _write_multi_file,
1430
+ OutputFormat.TEXT: _write_json,
423
1431
  }
1432
+ """Dictionary mapping output formats to writer functions."""
424
1433
 
425
1434
  def write(
426
1435
  self,
427
- output: Union[Output, dict[str, Any]],
428
- path: Optional[str] = None,
1436
+ output: Output | dict[str, Any] | BaseModel,
1437
+ path: str | None = None,
429
1438
  skip_stdout_reset: bool = False,
430
1439
  ) -> None:
431
1440
  """
432
- Write the `output` to the local filesystem. Consider the following for
433
- the `path` parameter, depending on the `Output.output_format`:
434
-
435
- - `OutputFormat.JSON`: the `path` is the file where the JSON data will
436
- be written. If empty or `None`, the data will be written to stdout.
437
- - `OutputFormat.CSV_ARCHIVE`: the `path` is the directory where the CSV
438
- files will be written. If empty or `None`, the data will be written
439
- to a directory named `output` under the current working directory.
440
- The `Output.options` and `Output.statistics` will be written to
441
- stdout.
1441
+ Write the output to the local filesystem or stdout.
442
1442
 
443
- This function detects if stdout was redirected and resets it to avoid
444
- unexpected behavior. If you want to skip this behavior, set the
445
- `skip_stdout_reset` parameter to `True`.
446
-
447
- If the `output` is a `dict`, it will be simply written to the specified
448
- `path`, as a passthrough. On the other hand, if the `output` is of type
449
- `Output`, a more structured object will be written, which adheres to
450
- the schema specified by the corresponding `Output` class.
1443
+ This method writes the provided output to the specified path or to stdout,
1444
+ depending on the output format and the path parameter.
451
1445
 
452
1446
  Parameters
453
1447
  ----------
454
- output: Output, dict[str, Any]
455
- Output data to write.
456
- path : str
457
- Path to write the output data to.
1448
+ output : Union[Output, dict[str, Any], BaseModel]
1449
+ Output data to write. Can be an Output object, a dictionary, or a BaseModel.
1450
+ path : str, optional
1451
+ Path to write the output data to. The interpretation depends on the output format:
1452
+ - For OutputFormat.JSON: File path for the JSON output. If None or empty, writes to stdout.
1453
+ - For OutputFormat.CSV_ARCHIVE: Directory path for CSV files. If None or empty,
1454
+ writes to a directory named "output" in the current working directory.
458
1455
  skip_stdout_reset : bool, optional
459
- Skip resetting stdout before writing the output data. Default is
460
- `False`.
1456
+ Skip resetting stdout before writing the output data. Default is False.
461
1457
 
462
1458
  Raises
463
1459
  ------
464
1460
  ValueError
465
- If the `Output.output_format` is not supported.
1461
+ If the Output.output_format is not supported.
1462
+ TypeError
1463
+ If the output is of an unsupported type.
1464
+
1465
+ Notes
1466
+ -----
1467
+ This function detects if stdout was redirected and resets it to avoid
1468
+ unexpected behavior. If you want to skip this behavior, set the
1469
+ skip_stdout_reset parameter to True.
1470
+
1471
+ If the output is a dict or a BaseModel, it will be written as JSON. If
1472
+ the output is an Output object, it will be written according to its
1473
+ output_format.
1474
+
1475
+ Examples
1476
+ --------
1477
+ >>> from nextmv.output import LocalOutputWriter, Output
1478
+ >>> writer = LocalOutputWriter()
1479
+ >>> # Write JSON to a file
1480
+ >>> writer.write(Output(solution={"result": 42}), path="result.json")
1481
+ >>> # Write JSON to stdout
1482
+ >>> writer.write({"simple": "data"})
466
1483
  """
467
1484
 
468
1485
  # If the user forgot to reset stdout after redirecting it, we need to
@@ -474,140 +1491,150 @@ class LocalOutputWriter(OutputWriter):
474
1491
  output_format = output.output_format
475
1492
  elif isinstance(output, dict):
476
1493
  output_format = OutputFormat.JSON
1494
+ elif isinstance(output, BaseModel):
1495
+ output_format = OutputFormat.JSON
477
1496
  else:
478
- raise TypeError(f"unsupported output type: {type(output)}, supported types are `Output` or `dict`")
1497
+ raise TypeError(
1498
+ f"unsupported output type: {type(output)}, supported types are `Output`, `dict`, `BaseModel`"
1499
+ )
479
1500
 
480
- statistics = self._extract_statistics(output)
481
- options = self._extract_options(output)
482
- assets = self._extract_assets(output)
1501
+ output_dict = {}
1502
+ if isinstance(output, Output):
1503
+ output_dict = output.to_dict()
1504
+ elif isinstance(output, BaseModel):
1505
+ output_dict = output.to_dict()
1506
+ elif isinstance(output, dict):
1507
+ output_dict = output
1508
+ else:
1509
+ raise TypeError(
1510
+ f"unsupported output type: {type(output)}, supported types are `Output`, `dict`, `BaseModel`"
1511
+ )
483
1512
 
484
1513
  self.FILE_WRITERS[output_format](
1514
+ self,
485
1515
  output=output,
486
- options=options,
487
- statistics=statistics,
488
- assets=assets,
1516
+ output_dict=output_dict,
489
1517
  path=path,
490
1518
  )
491
1519
 
492
- @staticmethod
493
- def _extract_statistics(output: Union[Output, dict[str, Any]]) -> dict[str, Any]:
494
- """Extract JSON-serializable statistics."""
495
1520
 
496
- statistics = {}
497
-
498
- if not isinstance(output, Output):
499
- return statistics
500
-
501
- stats = output.statistics
502
-
503
- if stats is None:
504
- return statistics
505
-
506
- if isinstance(stats, Statistics):
507
- statistics = stats.to_dict()
508
- elif isinstance(stats, dict):
509
- statistics = stats
510
- else:
511
- raise TypeError(f"unsupported statistics type: {type(stats)}, supported types are `Statistics` or `dict`")
512
-
513
- return statistics
514
-
515
- @staticmethod
516
- def _extract_options(output: Union[Output, dict[str, Any]]) -> dict[str, Any]:
517
- """Extract JSON-serializable options."""
518
-
519
- options = {}
520
-
521
- if not isinstance(output, Output):
522
- return options
523
-
524
- opt = output.options
525
-
526
- if opt is None:
527
- return options
1521
+ def write_local(
1522
+ output: Output | dict[str, Any],
1523
+ path: str | None = None,
1524
+ skip_stdout_reset: bool = False,
1525
+ ) -> None:
1526
+ """
1527
+ !!! warning
1528
+ `write_local` is deprecated, use `write` instead.
528
1529
 
529
- if isinstance(opt, Options):
530
- options = opt.to_dict()
531
- elif isinstance(opt, dict):
532
- options = opt
533
- else:
534
- raise TypeError(f"unsupported options type: {type(opt)}, supported types are `Options` or `dict`")
1530
+ Write the output to the local filesystem or stdout.
535
1531
 
536
- return options
1532
+ This is a convenience function for instantiating a `LocalOutputWriter` and
1533
+ calling its `write` method.
537
1534
 
538
- @staticmethod
539
- def _extract_assets(output: Union[Output, dict[str, Any]]) -> list[dict[str, Any]]:
540
- """Extract JSON-serializable assets."""
1535
+ Parameters
1536
+ ----------
1537
+ output : Union[Output, dict[str, Any]]
1538
+ Output data to write. Can be an Output object or a dictionary.
1539
+ path : str, optional
1540
+ Path to write the output data to. The interpretation depends on the
1541
+ output format:
1542
+
1543
+ - For `OutputFormat.JSON`: File path for the JSON output. If None or
1544
+ empty, writes to stdout.
1545
+ - For `OutputFormat.CSV_ARCHIVE`: Directory path for CSV files. If None
1546
+ or empty, writes to a directory named "output" in the current working
1547
+ directory.
1548
+ skip_stdout_reset : bool, optional
1549
+ Skip resetting stdout before writing the output data. Default is False.
541
1550
 
542
- assets = []
1551
+ Raises
1552
+ ------
1553
+ ValueError
1554
+ If the Output.output_format is not supported.
1555
+ TypeError
1556
+ If the output is of an unsupported type.
543
1557
 
544
- if not isinstance(output, Output):
545
- return assets
1558
+ Notes
1559
+ -----
1560
+ This function detects if stdout was redirected and resets it to avoid
1561
+ unexpected behavior. If you want to skip this behavior, set the
1562
+ skip_stdout_reset parameter to True.
1563
+
1564
+ Examples
1565
+ --------
1566
+ >>> from nextmv.output import write_local, Output
1567
+ >>> # Write JSON to a file
1568
+ >>> write_local(Output(solution={"result": 42}), path="result.json")
1569
+ >>> # Write JSON to stdout
1570
+ >>> write_local({"simple": "data"})
1571
+ """
546
1572
 
547
- assts = output.assets
1573
+ deprecated(
1574
+ name="write_local",
1575
+ reason="`write_local` is deprecated, use `write` instead",
1576
+ )
548
1577
 
549
- if assts is None:
550
- return assets
1578
+ writer = LocalOutputWriter()
1579
+ writer.write(output, path, skip_stdout_reset)
551
1580
 
552
- for ix, asset in enumerate(assts):
553
- if isinstance(asset, Asset):
554
- assets.append(asset.to_dict())
555
- elif isinstance(asset, dict):
556
- assets.append(asset)
557
- else:
558
- raise TypeError(f"unsupported asset {ix}, type: {type(asset)}; supported types are `Asset` or `dict`")
559
1581
 
560
- return assets
1582
+ _LOCAL_OUTPUT_WRITER = LocalOutputWriter()
1583
+ """Default LocalOutputWriter instance used by the write function."""
561
1584
 
562
1585
 
563
- def write_local(
564
- output: Union[Output, dict[str, Any]],
565
- path: Optional[str] = None,
1586
+ def write(
1587
+ output: Output | dict[str, Any] | BaseModel,
1588
+ path: str | None = None,
566
1589
  skip_stdout_reset: bool = False,
1590
+ writer: OutputWriter | None = _LOCAL_OUTPUT_WRITER,
567
1591
  ) -> None:
568
1592
  """
569
- This is a convenience function for instantiating a `LocalOutputWriter` and
570
- calling its `write` method.
1593
+ Write the output to the specified destination.
571
1594
 
572
- Write the `output` to the local filesystem. Consider the following for the
573
- `path` parameter, depending on the `Output.output_format`:
1595
+ You can import the `write` function directly from `nextmv`:
574
1596
 
575
- - `OutputFormat.JSON`: the `path` is the file where the JSON data will
576
- be written. If empty or `None`, the data will be written to stdout.
577
- - `OutputFormat.CSV_ARCHIVE`: the `path` is the directory where the CSV
578
- files will be written. If empty or `None`, the data will be written
579
- to a directory named `output` under the current working directory.
580
- The `Output.options` and `Output.statistics` will be written to
581
- stdout.
1597
+ ```python
1598
+ from nextmv import write
1599
+ ```
582
1600
 
583
- This function detects if stdout was redirected and resets it to avoid
584
- unexpected behavior. If you want to skip this behavior, set the
585
- `skip_stdout_reset` parameter to `True`.
1601
+ This is a convenience function for writing output data using a provided writer.
1602
+ By default, it uses the `LocalOutputWriter` to write to files or stdout.
586
1603
 
587
1604
  Parameters
588
1605
  ----------
589
- output : Output, dict[str, Any]
590
- Output data to write.
591
- path : str
592
- Path to write the output data to.
1606
+ output : Union[Output, dict[str, Any], BaseModel]
1607
+ Output data to write. Can be an Output object, a dictionary, or a BaseModel.
1608
+ path : str, optional
1609
+ Path to write the output data to. The interpretation depends on the
1610
+ output format:
1611
+
1612
+ - For `OutputFormat.JSON`: File path for the JSON output. If None or
1613
+ empty, writes to stdout.
1614
+ - For `OutputFormat.CSV_ARCHIVE`: Directory path for CSV files. If None
1615
+ or empty, writes to a directory named "output" in the current working
1616
+ directory.
593
1617
  skip_stdout_reset : bool, optional
594
- Skip resetting stdout before writing the output data. Default is
595
- `False`.
1618
+ Skip resetting stdout before writing the output data. Default is False.
1619
+ writer : OutputWriter, optional
1620
+ The writer to use for writing the output. Default is a
1621
+ `LocalOutputWriter` instance.
596
1622
 
597
1623
  Raises
598
1624
  ------
599
1625
  ValueError
600
- If the `Output.output_format` is not supported.
1626
+ If the Output.output_format is not supported.
1627
+ TypeError
1628
+ If the output is of an unsupported type.
1629
+
1630
+ Examples
1631
+ --------
1632
+ >>> from nextmv.output import write, Output, OutputFormat
1633
+ >>> # Write JSON to a file
1634
+ >>> write(Output(solution={"result": 42}), path="result.json")
1635
+ >>> # Write CSV archive
1636
+ >>> data = {"vehicles": [{"id": 1, "capacity": 100}, {"id": 2, "capacity": 150}]}
1637
+ >>> write(Output(output_format=OutputFormat.CSV_ARCHIVE, solution=data), path="output_dir")
601
1638
  """
602
1639
 
603
- writer = LocalOutputWriter()
604
1640
  writer.write(output, path, skip_stdout_reset)
605
-
606
-
607
- def _custom_serial(obj: Any):
608
- """JSON serializer for objects not serializable by default one."""
609
-
610
- if isinstance(obj, (datetime.datetime | datetime.date)):
611
- return obj.isoformat()
612
-
613
- raise TypeError(f"Type {type(obj)} not serializable")