nextmv 0.10.3.dev0__py3-none-any.whl → 0.35.0__py3-none-any.whl

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