nextmv 0.28.4__py3-none-any.whl → 0.29.0.dev0__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.
nextmv/input.py CHANGED
@@ -27,6 +27,7 @@ import csv
27
27
  import json
28
28
  import os
29
29
  import sys
30
+ from collections.abc import Callable
30
31
  from dataclasses import dataclass
31
32
  from enum import Enum
32
33
  from typing import Any, Optional, Union
@@ -58,6 +59,8 @@ class InputFormat(str, Enum):
58
59
  CSV format, utf-8 encoded.
59
60
  CSV_ARCHIVE : str
60
61
  CSV archive format: multiple CSV files.
62
+ MULTI_FILE : str
63
+ Multi-file format, used for loading multiple files in a single input.
61
64
  """
62
65
 
63
66
  JSON = "json"
@@ -68,6 +71,233 @@ class InputFormat(str, Enum):
68
71
  """CSV format, utf-8 encoded."""
69
72
  CSV_ARCHIVE = "csv-archive"
70
73
  """CSV archive format: multiple CSV files."""
74
+ MULTI_FILE = "multi-file"
75
+ """Multi-file format, used for loading multiple files in a single input."""
76
+
77
+
78
+ @dataclass
79
+ class DataFile:
80
+ """
81
+ Represents data to be read from a file.
82
+
83
+ You can import the `DataFile` class directly from `nextmv`:
84
+
85
+ ```python
86
+ from nextmv import DataFile
87
+ ```
88
+
89
+ This class is used to define data that will be read from a file in the
90
+ filesystem. It includes the name of the file, and the reader function that
91
+ will handle the loading, and deserialization of the data from the file.
92
+ This `DataFile` class is typically used in the `Input`, when the
93
+ `Input.input_format` is set to `InputFormat.MULTI_FILE`. Given that it is
94
+ difficul to handle every edge case of how data is deserialized, and read
95
+ from a file, this class exists so that the user can implement the `reader`
96
+ callable of their choice and provide it with any `reader_args` and
97
+ `reader_kwargs` they might need.
98
+
99
+ Parameters
100
+ ----------
101
+ name : str
102
+ Name of the data (input) file. The file extension should be included in
103
+ the name.
104
+ reader : Callable[[str], Any]
105
+ Callable that reads the data from the file. This should be a function
106
+ implemented by the user. There are convenience functions that you can
107
+ use as a reader as well. The `reader` must receive, at the very minimum,
108
+ the following arguments:
109
+
110
+ - `file_path`: a `str` argument which is the location where this
111
+ solution will be read from. This includes the dir and name of the
112
+ file. As such, the `name` parameter of this class is going to be
113
+ passed to the `reader` function, joined with the directory where the
114
+ file will be read from.
115
+
116
+ The `reader` can also receive additional arguments, and keyword
117
+ arguments. The `reader_args` and `reader_kwargs` parameters of this
118
+ class can be used to provide those additional arguments.
119
+
120
+ The `reader` function should return the data that will be used in the
121
+ model.
122
+ """
123
+
124
+ name: str
125
+ """
126
+ Name of the data (input) file. The file extension should be included in the
127
+ name.
128
+ """
129
+ loader: Callable[[str], Any]
130
+ """
131
+ Callable that reads (loads) the data from the file. This should be a function
132
+ implemented by the user. There are convenience functions that you can use
133
+ as a `loader` as well. The `loader` must receive, at the very minimum, the
134
+ following arguments:
135
+
136
+ - `file_path`: a `str` argument which is the location where this
137
+ solution will be read from. This includes the dir and name of the
138
+ file. As such, the `name` parameter of this class is going to be
139
+ passed to the `loader` function, joined with the directory where the
140
+ file will be read from.
141
+
142
+ The `loader` can also receive additional arguments, and keyword arguments.
143
+ The `loader_args` and `loader_kwargs` parameters of this class can be used
144
+ to provide those additional arguments.
145
+
146
+ The `loader` function should return the data that will be used in the model.
147
+ """
148
+ loader_kwargs: Optional[dict[str, Any]] = None
149
+ """
150
+ Optional keyword arguments to pass to the loader function. This can be used
151
+ to customize the behavior of the loader.
152
+ """
153
+ loader_args: Optional[list[Any]] = None
154
+ """
155
+ Optional positional arguments to pass to the loader function. This can be
156
+ used to customize the behavior of the loader.
157
+ """
158
+
159
+
160
+ def json_data_file(name: str, json_configurations: Optional[dict[str, Any]] = None) -> DataFile:
161
+ """
162
+ This is a convenience function to create a `DataFile` that reads JSON data.
163
+
164
+ You can import the `json_data_file` function directly from `nextmv`:
165
+
166
+ ```python
167
+ from nextmv import json_data_file
168
+ ```
169
+
170
+ Parameters
171
+ ----------
172
+ name : str
173
+ Name of the data file. You don't need to include the `.json` extension.
174
+ json_configurations : dict[str, Any], optional
175
+ JSON-specific configurations for reading the data.
176
+
177
+ Returns
178
+ -------
179
+ DataFile
180
+ A `DataFile` instance that reads JSON data from a file with the given
181
+ name.
182
+
183
+ Examples
184
+ --------
185
+ >>> from nextmv import json_data_file
186
+ >>> data_file = json_data_file("my_data")
187
+ >>> data = data_file.read()
188
+ >>> print(data)
189
+ {
190
+ "key": "value",
191
+ "another_key": [1, 2, 3]
192
+ }
193
+ """
194
+
195
+ if not name.endswith(".json"):
196
+ name += ".json"
197
+
198
+ json_configurations = json_configurations or {}
199
+
200
+ def loader(file_path: str) -> Union[dict[str, Any], Any]:
201
+ with open(file_path, encoding="utf-8") as f:
202
+ return json.load(f, **json_configurations)
203
+
204
+ return DataFile(
205
+ name=name,
206
+ loader=loader,
207
+ )
208
+
209
+
210
+ def csv_data_file(name: str, csv_configurations: Optional[dict[str, Any]] = None) -> DataFile:
211
+ """
212
+ This is a convenience function to create a `DataFile` that reads CSV data.
213
+
214
+ You can import the `csv_data_file` function directly from `nextmv`:
215
+
216
+ ```python
217
+ from nextmv import csv_data_file
218
+ ```
219
+
220
+ Parameters
221
+ ----------
222
+ name : str
223
+ Name of the data file. You don't need to include the `.csv` extension.
224
+ csv_configurations : dict[str, Any], optional
225
+ CSV-specific configurations for reading the data.
226
+
227
+ Returns
228
+ -------
229
+ DataFile
230
+ A `DataFile` instance that reads CSV data from a file with the given
231
+ name.
232
+
233
+ Examples
234
+ --------
235
+ >>> from nextmv import csv_data_file
236
+ >>> data_file = csv_data_file("my_data")
237
+ >>> data = data_file.read()
238
+ >>> print(data)
239
+ [
240
+ {"column1": "value1", "column2": "value2"},
241
+ {"column1": "value3", "column2": "value4"}
242
+ ]
243
+ """
244
+
245
+ if not name.endswith(".csv"):
246
+ name += ".csv"
247
+
248
+ csv_configurations = csv_configurations or {}
249
+
250
+ def loader(file_path: str) -> list[dict[str, Any]]:
251
+ with open(file_path, encoding="utf-8") as f:
252
+ return list(csv.DictReader(f, **csv_configurations))
253
+
254
+ return DataFile(
255
+ name=name,
256
+ loader=loader,
257
+ )
258
+
259
+
260
+ def text_data_file(name: str) -> DataFile:
261
+ """
262
+ This is a convenience function to create a `DataFile` that reads utf-8
263
+ encoded text data.
264
+
265
+ You can import the `text_data_file` function directly from `nextmv`:
266
+
267
+ ```python
268
+ from nextmv import text_data_file
269
+ ```
270
+
271
+ You must provide the extension as part of the `name` parameter.
272
+
273
+ Parameters
274
+ ----------
275
+ name : str
276
+ Name of the data file. The file extension must be provided in the name.
277
+
278
+ Returns
279
+ -------
280
+ DataFile
281
+ A `DataFile` instance that reads text data from a file with the given
282
+ name.
283
+
284
+ Examples
285
+ --------
286
+ >>> from nextmv import text_data_file
287
+ >>> data_file = text_data_file("my_data")
288
+ >>> data = data_file.read()
289
+ >>> print(data)
290
+ This is some text data.
291
+ """
292
+
293
+ def loader(file_path: str) -> str:
294
+ with open(file_path, encoding="utf-8") as f:
295
+ return f.read().rstrip("\n")
296
+
297
+ return DataFile(
298
+ name=name,
299
+ loader=loader,
300
+ )
71
301
 
72
302
 
73
303
  @dataclass
@@ -83,12 +313,40 @@ class Input:
83
313
 
84
314
  Parameters
85
315
  ----------
86
- data : Union[dict[str, Any], str, list[dict[str, Any]], dict[str, list[dict[str, Any]]]]
316
+ data : Union[Union[dict[str, Any], Any], str, list[dict[str, Any]],
317
+ dict[str, list[dict[str, Any]]], dict[str, Any]]
87
318
  The actual data.
88
319
  input_format : InputFormat, optional
89
320
  Format of the input data. Default is `InputFormat.JSON`.
90
321
  options : Options, optional
91
322
  Options that the input was created with.
323
+
324
+ Notes
325
+ -----
326
+ The `data`'s type must match the `input_format`:
327
+
328
+ - `InputFormat.JSON`: the data is `Union[dict[str, Any], Any]`. This just
329
+ means that the data must be JSON-deserializable, which includes dicts and
330
+ lists.
331
+ - `InputFormat.TEXT`: the data is `str`, and it must be utf-8 encoded.
332
+ - `InputFormat.CSV`: the data is `list[dict[str, Any]]`, where each dict
333
+ represents a row in the CSV.
334
+ - `InputFormat.CSV_ARCHIVE`: the data is `dict[str, list[dict[str, Any]]]`,
335
+ where each key is the name of a CSV file and the value is a list of dicts
336
+ representing the rows in that CSV file.
337
+ - `InputFormat.MULTI_FILE`: the data is `dict[str, Any]`, where for each
338
+ item, the key is the file name (with the extension) and the actual data
339
+ from the file is the value. When working with multi-file, data is loaded
340
+ from one or more files in a specific directory. Given that each file can
341
+ be of different types (JSON, CSV, Excel, etc...), the data captured from
342
+ each might vary. To reflect this, the data is loaded as a dict of items.
343
+
344
+ Raises
345
+ ------
346
+ ValueError
347
+ If the data type doesn't match the expected type for the given format.
348
+ ValueError
349
+ If the `input_format` is not one of the supported formats.
92
350
  """
93
351
 
94
352
  data: Union[
@@ -96,6 +354,7 @@ class Input:
96
354
  str, # TEXT
97
355
  list[dict[str, Any]], # CSV
98
356
  dict[str, list[dict[str, Any]]], # CSV_ARCHIVE
357
+ dict[str, Any], # MULTI_FILE
99
358
  ]
100
359
  """
101
360
  The actual data.
@@ -106,6 +365,7 @@ class Input:
106
365
  - For `TEXT`: `str`
107
366
  - For `CSV`: `list[dict[str, Any]]`
108
367
  - For `CSV_ARCHIVE`: `dict[str, list[dict[str, Any]]]`
368
+ - For `MULTI_FILE`: `dict[str, Any]`
109
369
  """
110
370
 
111
371
  input_format: Optional[InputFormat] = InputFormat.JSON
@@ -165,6 +425,12 @@ class Input:
165
425
  "input_format InputFormat.CSV_ARCHIVE, supported type is `dict`"
166
426
  )
167
427
 
428
+ elif self.input_format == InputFormat.MULTI_FILE and not isinstance(self.data, dict):
429
+ raise ValueError(
430
+ f"unsupported Input.data type: {type(self.data)} with "
431
+ "input_format InputFormat.MULTI_FILE, supported type is `dict`"
432
+ )
433
+
168
434
  # Capture a snapshot of the options that were used to create the class
169
435
  # so even if they are changed later, we have a record of the original.
170
436
  init_options = self.options
@@ -175,8 +441,10 @@ class Input:
175
441
  """
176
442
  Convert the input to a dictionary.
177
443
 
178
- This method serializes the Input object to a dictionary format that can be
179
- easily converted to JSON or other serialization formats.
444
+ This method serializes the Input object to a dictionary format that can
445
+ be easily converted to JSON or other serialization formats. When the
446
+ `input_type` is set to `InputFormat.MULTI_FILE`, it will not include
447
+ the `data` field, as it is uncertain how data is deserialized from the file.
180
448
 
181
449
  Returns
182
450
  -------
@@ -201,12 +469,18 @@ class Input:
201
469
  {'data': {'key': 'value'}, 'input_format': 'json', 'options': None}
202
470
  """
203
471
 
204
- return {
205
- "data": self.data,
472
+ input_dict = {
206
473
  "input_format": self.input_format.value,
207
474
  "options": self.options.to_dict() if self.options is not None else None,
208
475
  }
209
476
 
477
+ if self.input_format == InputFormat.MULTI_FILE:
478
+ return input_dict
479
+
480
+ input_dict["data"] = self.data
481
+
482
+ return input_dict
483
+
210
484
 
211
485
  class InputLoader:
212
486
  """
@@ -375,6 +649,7 @@ class LocalInputLoader(InputLoader):
375
649
  options: Optional[Options] = None,
376
650
  path: Optional[str] = None,
377
651
  csv_configurations: Optional[dict[str, Any]] = None,
652
+ data_files: Optional[list[DataFile]] = None,
378
653
  ) -> Input:
379
654
  """
380
655
  Load the input data. The input data can be in various formats. For
@@ -395,6 +670,10 @@ class LocalInputLoader(InputLoader):
395
670
  - `InputFormat.CSV`: the data is a `list[dict[str, Any]]`.
396
671
  - `InputFormat.CSV_ARCHIVE`: the data is a `dict[str, list[dict[str, Any]]]`.
397
672
  Each key is the name of the CSV file, minus the `.csv` extension.
673
+ - `InputFormat.MULTI_FILE`: the data is a `dict[str, Any]`, where each
674
+ key is the file name (with extension) and the value is the data read
675
+ from the file. The data can be of any type, depending on the file
676
+ type and the reader function provided in the `DataFile` instances.
398
677
 
399
678
  Parameters
400
679
  ----------
@@ -408,6 +687,16 @@ class LocalInputLoader(InputLoader):
408
687
  Configurations for loading CSV files. The default `DictReader` is
409
688
  used when loading a CSV file, so you have the option to pass in a
410
689
  dictionary with custom kwargs for the `DictReader`.
690
+ data_files : list[DataFile], optional
691
+ List of `DataFile` instances to read from. This is used when the
692
+ `input_format` is set to `InputFormat.MULTI_FILE`. Each `DataFile`
693
+ instance should have a `name` (the file name with extension) and a
694
+ `loader` function that reads the data from the file. The `loader`
695
+ function should accept the file path as its first argument and return
696
+ the data read from the file. The `loader` can also accept additional
697
+ positional and keyword arguments, which can be provided through the
698
+ `loader_args` and `loader_kwargs` attributes of the `DataFile`
699
+ instance.
411
700
 
412
701
  Returns
413
702
  -------
@@ -428,6 +717,14 @@ class LocalInputLoader(InputLoader):
428
717
  data = self._load_utf8_encoded(path=path, input_format=input_format, csv_configurations=csv_configurations)
429
718
  elif input_format == InputFormat.CSV_ARCHIVE:
430
719
  data = self._load_archive(path=path, csv_configurations=csv_configurations)
720
+ elif input_format == InputFormat.MULTI_FILE:
721
+ if data_files is None:
722
+ raise ValueError("data_files must be provided when input_format is InputFormat.MULTI_FILE")
723
+
724
+ if not isinstance(data_files, list):
725
+ raise ValueError("data_files must be a list of DataFile instances")
726
+
727
+ data = self._load_multi_file(data_files=data_files, path=path)
431
728
 
432
729
  return Input(data=data, input_format=input_format, options=options)
433
730
 
@@ -528,6 +825,73 @@ class LocalInputLoader(InputLoader):
528
825
 
529
826
  return data
530
827
 
828
+ def _load_multi_file(
829
+ self,
830
+ data_files: list[DataFile],
831
+ path: Optional[str] = None,
832
+ ) -> dict[str, Any]:
833
+ """
834
+ Load multiple files from a directory.
835
+
836
+ This internal method loads all supported files from a specified
837
+ directory, organizing them into a dictionary where each key is the
838
+ filename and each value is the parsed file content. Supports CSV files
839
+ (parsed as list of dictionaries), JSON files (parsed as JSON objects),
840
+ and any other utf-8 encoded text files (loaded as plain text strings).
841
+ It also supports Excel files, loading them as DataFrames.
842
+
843
+ Parameters
844
+ ----------
845
+ data_files : list[DataFile]
846
+ List of `DataFile` instances to read from.
847
+ path : str, optional
848
+ Path to the directory containing files. If None or empty,
849
+ uses "./inputs" as the default directory.
850
+
851
+ Returns
852
+ -------
853
+ dict[str, Any]
854
+ Dictionary mapping filenames to file contents. CSV files are loaded
855
+ as lists of dictionaries, JSON files as parsed JSON objects, and
856
+ other utf-8 text files as strings. Excel files are loaded as
857
+ DataFrames.
858
+
859
+ Raises
860
+ ------
861
+ ValueError
862
+ If the path is not a directory or the default directory doesn't exist.
863
+ """
864
+
865
+ dir_path = "inputs"
866
+ if path is not None and path != "":
867
+ if not os.path.isdir(path):
868
+ raise ValueError(f"path {path} is not a directory")
869
+
870
+ dir_path = path
871
+
872
+ if not os.path.isdir(dir_path):
873
+ raise ValueError(f'expected input directoy "{dir_path}" to exist as a default location')
874
+
875
+ data = {}
876
+
877
+ for data_file in data_files:
878
+ name = data_file.name
879
+ file_path = os.path.join(dir_path, name)
880
+
881
+ if data_file.loader_args is None:
882
+ data_file.loader_args = []
883
+ if data_file.loader_kwargs is None:
884
+ data_file.loader_kwargs = {}
885
+
886
+ d = data_file.loader(
887
+ file_path,
888
+ *data_file.loader_args,
889
+ **data_file.loader_kwargs,
890
+ )
891
+ data[name] = d
892
+
893
+ return data
894
+
531
895
 
532
896
  def load_local(
533
897
  input_format: Optional[InputFormat] = InputFormat.JSON,
@@ -590,6 +954,7 @@ def load(
590
954
  path: Optional[str] = None,
591
955
  csv_configurations: Optional[dict[str, Any]] = None,
592
956
  loader: Optional[InputLoader] = _LOCAL_INPUT_LOADER,
957
+ data_files: Optional[list[DataFile]] = None,
593
958
  ) -> Input:
594
959
  """
595
960
  Load input data using the specified loader.
@@ -611,6 +976,36 @@ def load(
611
976
  - `InputFormat.CSV`: the data is a `list[dict[str, Any]]`
612
977
  - `InputFormat.CSV_ARCHIVE`: the data is a `dict[str, list[dict[str, Any]]]`
613
978
  Each key is the name of the CSV file, minus the `.csv` extension.
979
+ - `InputFormat.MULTI_FILE`: the data is a `dict[str, Any]`
980
+ where each key is the file name (with extension) and the value is the
981
+ data read from the file. This is used for loading multiple files in a
982
+ single input, where each file can be of different types (JSON, CSV,
983
+ Excel, etc.). The data is loaded as a dict of items, where each item
984
+ corresponds to a file and its content.
985
+
986
+ When specifying `input_format` as `InputFormat.MULTI_FILE`, the
987
+ `data_files` argument must be provided. This argument is a list of
988
+ `DataFile` instances, each representing a file to be read. Each `DataFile`
989
+ instance should have a `name` (the file name with extension) and a `loader`
990
+ function that reads the data from the file. The `loader` function should
991
+ accept the file path as its first argument and return the data read from
992
+ the file. The `loader` can also accept additional positional and keyword
993
+ arguments, which can be provided through the `loader_args` and
994
+ `loader_kwargs` attributes of the `DataFile` instance.
995
+
996
+ There are convenience functions that can be used to create `DataFile`
997
+ classes, such as:
998
+
999
+ - `json_data_file`: Creates a `DataFile` that reads JSON data.
1000
+ - `csv_data_file`: Creates a `DataFile` that reads CSV data.
1001
+ - `text_data_file`: Creates a `DataFile` that reads utf-8 encoded text
1002
+ data.
1003
+
1004
+ When workiing with data in other formats, such as Excel files, you are
1005
+ encouraged to create your own `DataFile` objects with your own
1006
+ implementation of the `loader` function. This allows you to read data
1007
+ from files in a way that suits your needs, while still adhering to the
1008
+ `DataFile` interface.
614
1009
 
615
1010
  Parameters
616
1011
  ----------
@@ -629,6 +1024,24 @@ def load(
629
1024
  loader : InputLoader, optional
630
1025
  The loader to use for loading the input data.
631
1026
  Default is an instance of `LocalInputLoader`.
1027
+ data_files : list[DataFile], optional
1028
+ List of `DataFile` instances to read from. This is used when the
1029
+ `input_format` is set to `InputFormat.MULTI_FILE`. Each `DataFile`
1030
+ instance should have a `name` (the file name with extension) and a
1031
+ `loader` function that reads the data from the file. The `loader`
1032
+ function should accept the file path as its first argument and return
1033
+ the data read from the file. The `loader` can also accept additional
1034
+ positional and keyword arguments, which can be provided through the
1035
+ `loader_args` and `loader_kwargs` attributes of the `DataFile`
1036
+ instance.
1037
+
1038
+ There are convenience functions that can be used to create `DataFile`
1039
+ classes, such as `json_data_file`, `csv_data_file`, and
1040
+ `text_data_file`. When working with data in other formats, such as
1041
+ Excel files, you are encouraged to create your own `DataFile` objects
1042
+ with your own implementation of the `loader` function. This allows you
1043
+ to read data from files in a way that suits your needs, while still
1044
+ adhering to the `DataFile` interface.
632
1045
 
633
1046
  Returns
634
1047
  -------
@@ -651,4 +1064,4 @@ def load(
651
1064
  >>> input_obj = load(input_format=InputFormat.CSV_ARCHIVE, path="input_dir")
652
1065
  """
653
1066
 
654
- return loader.load(input_format, options, path, csv_configurations)
1067
+ return loader.load(input_format, options, path, csv_configurations, data_files)
nextmv/model.py CHANGED
@@ -24,7 +24,7 @@ from typing import Any, Optional
24
24
 
25
25
  from nextmv.input import Input
26
26
  from nextmv.logger import log
27
- from nextmv.options import Options
27
+ from nextmv.options import Options, OptionsEnforcement
28
28
  from nextmv.output import Output
29
29
 
30
30
  # The following block of code is used to suppress warnings from mlflow. We
@@ -132,6 +132,9 @@ class ModelConfiguration:
132
132
  formatted as they would appear in a requirements.txt file.
133
133
  options : Options, optional
134
134
  Options that the decision model requires.
135
+ options_enforcement:
136
+ Enforcement of options for the model. This controls how options
137
+ are handled when the model is run.
135
138
 
136
139
  Examples
137
140
  --------
@@ -139,17 +142,23 @@ class ModelConfiguration:
139
142
  >>> config = ModelConfiguration(
140
143
  ... name="my_routing_model",
141
144
  ... requirements=["nextroute>=1.0.0"],
142
- ... options=Options({"max_time": 60})
145
+ ... options=Options({"max_time": 60}),
146
+ ... options_enforcement=OptionsEnforcement(
147
+ strict=True,
148
+ validation_enforce=True
149
+ )
143
150
  ... )
144
151
  """
145
152
 
146
153
  name: str
147
154
  """The name of the decision model."""
148
-
149
155
  requirements: Optional[list[str]] = None
150
156
  """A list of Python dependencies that the decision model requires."""
151
157
  options: Optional[Options] = None
152
158
  """Options that the decision model requires."""
159
+ options_enforcement: Optional[OptionsEnforcement] = None
160
+ """Enforcement of options for the model."""
161
+
153
162
 
154
163
 
155
164
  class Model: