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.
- nextmv/__about__.py +1 -1
- nextmv/__entrypoint__.py +39 -0
- nextmv/__init__.py +57 -0
- nextmv/_serialization.py +96 -0
- nextmv/base_model.py +79 -9
- nextmv/cloud/__init__.py +71 -10
- nextmv/cloud/acceptance_test.py +888 -17
- nextmv/cloud/account.py +154 -10
- nextmv/cloud/application.py +3644 -437
- nextmv/cloud/batch_experiment.py +292 -33
- nextmv/cloud/client.py +354 -53
- nextmv/cloud/ensemble.py +247 -0
- nextmv/cloud/input_set.py +121 -4
- nextmv/cloud/instance.py +125 -0
- nextmv/cloud/package.py +474 -0
- nextmv/cloud/scenario.py +410 -0
- nextmv/cloud/secrets.py +234 -0
- nextmv/cloud/url.py +73 -0
- nextmv/cloud/version.py +174 -0
- nextmv/default_app/.gitignore +1 -0
- nextmv/default_app/README.md +32 -0
- nextmv/default_app/app.yaml +12 -0
- nextmv/default_app/input.json +5 -0
- nextmv/default_app/main.py +37 -0
- nextmv/default_app/requirements.txt +2 -0
- nextmv/default_app/src/__init__.py +0 -0
- nextmv/default_app/src/main.py +37 -0
- nextmv/default_app/src/visuals.py +36 -0
- nextmv/deprecated.py +47 -0
- nextmv/input.py +883 -78
- nextmv/local/__init__.py +5 -0
- nextmv/local/application.py +1263 -0
- nextmv/local/executor.py +1040 -0
- nextmv/local/geojson_handler.py +323 -0
- nextmv/local/local.py +97 -0
- nextmv/local/plotly_handler.py +61 -0
- nextmv/local/runner.py +274 -0
- nextmv/logger.py +80 -9
- nextmv/manifest.py +1472 -0
- nextmv/model.py +431 -0
- nextmv/options.py +968 -78
- nextmv/output.py +1363 -231
- nextmv/polling.py +287 -0
- nextmv/run.py +1623 -0
- nextmv/safe.py +145 -0
- nextmv/status.py +122 -0
- {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/METADATA +51 -288
- nextmv-0.35.0.dist-info/RECORD +50 -0
- {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/WHEEL +1 -1
- nextmv/cloud/status.py +0 -29
- nextmv/nextroute/__init__.py +0 -2
- nextmv/nextroute/check/__init__.py +0 -26
- nextmv/nextroute/check/schema.py +0 -141
- nextmv/nextroute/schema/__init__.py +0 -19
- nextmv/nextroute/schema/input.py +0 -52
- nextmv/nextroute/schema/location.py +0 -13
- nextmv/nextroute/schema/output.py +0 -136
- nextmv/nextroute/schema/stop.py +0 -61
- nextmv/nextroute/schema/vehicle.py +0 -68
- nextmv-0.10.3.dev0.dist-info/RECORD +0 -28
- {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/licenses/LICENSE +0 -0
nextmv/model.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model module for creating and saving decision models in Nextmv Cloud.
|
|
3
|
+
|
|
4
|
+
This module provides the base classes and functionality for creating decision models
|
|
5
|
+
that can be deployed and run in Nextmv Cloud. The main components are:
|
|
6
|
+
|
|
7
|
+
Classes
|
|
8
|
+
-------
|
|
9
|
+
Model
|
|
10
|
+
Base class for defining decision models.
|
|
11
|
+
ModelConfiguration
|
|
12
|
+
Configuration for packaging and deploying models.
|
|
13
|
+
|
|
14
|
+
Models defined using this module can be packaged with their dependencies and
|
|
15
|
+
deployed to Nextmv Cloud for execution.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import warnings
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from nextmv.input import Input
|
|
26
|
+
from nextmv.logger import log
|
|
27
|
+
from nextmv.options import Options, OptionsEnforcement
|
|
28
|
+
from nextmv.output import Output
|
|
29
|
+
|
|
30
|
+
# The following block of code is used to suppress warnings from mlflow. We
|
|
31
|
+
# suppress these warnings because they are not relevant to the user, and they
|
|
32
|
+
# are not actionable.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
Module-level function and variable to suppress warnings from mlflow.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_original_showwarning = warnings.showwarning
|
|
39
|
+
"""Original showwarning function from the warnings module."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _custom_showwarning(message, category, filename, lineno, file=None, line=None):
|
|
43
|
+
"""
|
|
44
|
+
Custom warning handler that suppresses specific mlflow warnings.
|
|
45
|
+
|
|
46
|
+
This function filters out non-actionable warnings from the mlflow library
|
|
47
|
+
to keep the console output clean and relevant for the user.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
message : str
|
|
52
|
+
The warning message.
|
|
53
|
+
category : Warning
|
|
54
|
+
The warning category.
|
|
55
|
+
filename : str
|
|
56
|
+
The filename where the warning was raised.
|
|
57
|
+
lineno : int
|
|
58
|
+
The line number where the warning was raised.
|
|
59
|
+
file : file, optional
|
|
60
|
+
The file to write the warning to.
|
|
61
|
+
line : str, optional
|
|
62
|
+
The line of source code to be included in the warning message.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
None
|
|
67
|
+
If the warning matches certain patterns, the function returns early
|
|
68
|
+
without showing the warning. Otherwise, it delegates to the original
|
|
69
|
+
warning handler.
|
|
70
|
+
"""
|
|
71
|
+
# .../site-packages/mlflow/pyfunc/utils/data_validation.py:134: UserWarning:Add
|
|
72
|
+
# type hints to the `predict` method to enable data validation and automatic
|
|
73
|
+
# signature inference during model logging. Check
|
|
74
|
+
# https://mlflow.org/docs/latest/model/python_model.html#type-hint-usage-in-pythonmodel
|
|
75
|
+
# for more details.
|
|
76
|
+
if "mlflow/pyfunc/utils/data_validation.py" in filename:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# .../site-packages/mlflow/pyfunc/__init__.py:3212: UserWarning: An input
|
|
80
|
+
# example was not provided when logging the model. To ensure the model
|
|
81
|
+
# signature functions correctly, specify the `input_example` parameter. See
|
|
82
|
+
# https://mlflow.org/docs/latest/model/signatures.html#model-input-example
|
|
83
|
+
# for more details about the benefits of using input_example.
|
|
84
|
+
if "mlflow/pyfunc/__init__.py" in filename:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
_original_showwarning(message, category, filename, lineno, file, line)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
warnings.showwarning = _custom_showwarning
|
|
91
|
+
|
|
92
|
+
# When working with the `Model`, we expect to be working in a notebook
|
|
93
|
+
# environment, and not interact with the local filesystem a lot. We use the
|
|
94
|
+
# `ModelConfiguration` to specify the dependencies that the `Model` requires.
|
|
95
|
+
# To work with the "push" logic of uploading an app to Nextmv Cloud, we need a
|
|
96
|
+
# requirement file that we use to gather dependencies, install them, and bundle
|
|
97
|
+
# them in the app. This file is used as a placeholder for the dependencies that
|
|
98
|
+
# the model requires and that we install and bundle with the app.
|
|
99
|
+
_REQUIREMENTS_FILE = "model_requirements.txt"
|
|
100
|
+
|
|
101
|
+
# When working in a notebook environment, we don't really create a `main.py`
|
|
102
|
+
# file with the main entrypoint of the program. Because the logic is mostly
|
|
103
|
+
# encoded inside the `Model` class, we need to create a `main.py` file that we
|
|
104
|
+
# can run in Nextmv Cloud. This file is used as that entrypoint.
|
|
105
|
+
_ENTRYPOINT_FILE = "__entrypoint__.py"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Required mlflow dependency version for model packaging.
|
|
109
|
+
_MLFLOW_DEPENDENCY = "mlflow>=2.18.0"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ModelConfiguration:
|
|
114
|
+
"""
|
|
115
|
+
Configuration class for Nextmv models.
|
|
116
|
+
|
|
117
|
+
You can import the `ModelConfiguration` class directly from `nextmv`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from nextmv import ModelConfiguration
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This class holds the configuration for a model, defining how a Python model
|
|
124
|
+
is encoded and loaded for use in Nextmv Cloud.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
name : str
|
|
129
|
+
A personalized name for the model. This is required.
|
|
130
|
+
requirements : list[str], optional
|
|
131
|
+
A list of Python dependencies that the decision model requires,
|
|
132
|
+
formatted as they would appear in a requirements.txt file.
|
|
133
|
+
options : Options, optional
|
|
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.
|
|
138
|
+
|
|
139
|
+
Examples
|
|
140
|
+
--------
|
|
141
|
+
>>> from nextmv import ModelConfiguration, Options
|
|
142
|
+
>>> config = ModelConfiguration(
|
|
143
|
+
... name="my_routing_model",
|
|
144
|
+
... requirements=["nextroute>=1.0.0"],
|
|
145
|
+
... options=Options({"max_time": 60}),
|
|
146
|
+
... options_enforcement=OptionsEnforcement(
|
|
147
|
+
strict=True,
|
|
148
|
+
validation_enforce=True
|
|
149
|
+
)
|
|
150
|
+
... )
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
name: str
|
|
154
|
+
"""The name of the decision model."""
|
|
155
|
+
requirements: list[str] | None = None
|
|
156
|
+
"""A list of Python dependencies that the decision model requires."""
|
|
157
|
+
options: Options | None = None
|
|
158
|
+
"""Options that the decision model requires."""
|
|
159
|
+
options_enforcement: OptionsEnforcement | None = None
|
|
160
|
+
"""Enforcement of options for the model."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Model:
|
|
164
|
+
"""
|
|
165
|
+
Base class for defining decision models that run in Nextmv Cloud.
|
|
166
|
+
|
|
167
|
+
You can import the `Model` class directly from `nextmv`:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from nextmv import Model
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
This class serves as a foundation for creating decision models that can be
|
|
174
|
+
deployed to Nextmv Cloud. Subclasses must implement the `solve` method,
|
|
175
|
+
which is the main entry point for processing inputs and producing decisions.
|
|
176
|
+
|
|
177
|
+
Methods
|
|
178
|
+
-------
|
|
179
|
+
solve(input)
|
|
180
|
+
Process input data and produce a decision output.
|
|
181
|
+
save(model_dir, configuration)
|
|
182
|
+
Save the model to the filesystem for deployment.
|
|
183
|
+
|
|
184
|
+
Examples
|
|
185
|
+
--------
|
|
186
|
+
>>> import nextroute
|
|
187
|
+
>>> import nextmv
|
|
188
|
+
>>>
|
|
189
|
+
>>> class DecisionModel(nextmv.Model):
|
|
190
|
+
... def solve(self, input: nextmv.Input) -> nextmv.Output:
|
|
191
|
+
... nextroute_input = nextroute.schema.Input.from_dict(input.data)
|
|
192
|
+
... nextroute_options = nextroute.Options.extract_from_dict(input.options.to_dict())
|
|
193
|
+
... nextroute_output = nextroute.solve(nextroute_input, nextroute_options)
|
|
194
|
+
...
|
|
195
|
+
... return nextmv.Output(
|
|
196
|
+
... options=input.options,
|
|
197
|
+
... solution=nextroute_output.solutions[0].to_dict(),
|
|
198
|
+
... statistics=nextroute_output.statistics.to_dict(),
|
|
199
|
+
... )
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def solve(self, input: Input) -> Output:
|
|
203
|
+
"""
|
|
204
|
+
Process input data and produce a decision output.
|
|
205
|
+
|
|
206
|
+
This is the main entry point of your model that you must implement in
|
|
207
|
+
subclasses. It receives input data and should process it to produce an
|
|
208
|
+
output containing the solution to the decision problem.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
input : Input
|
|
213
|
+
The input data that the model will use to make a decision.
|
|
214
|
+
|
|
215
|
+
Returns
|
|
216
|
+
-------
|
|
217
|
+
Output
|
|
218
|
+
The output of the model, which is the solution to the decision
|
|
219
|
+
model/problem.
|
|
220
|
+
|
|
221
|
+
Raises
|
|
222
|
+
------
|
|
223
|
+
NotImplementedError
|
|
224
|
+
When called on the base Model class, as this method must be
|
|
225
|
+
implemented by subclasses.
|
|
226
|
+
|
|
227
|
+
Examples
|
|
228
|
+
--------
|
|
229
|
+
>>> def solve(self, input: Input) -> Output:
|
|
230
|
+
... # Process input data
|
|
231
|
+
... result = self._process_data(input.data)
|
|
232
|
+
...
|
|
233
|
+
... # Return formatted output
|
|
234
|
+
... return Output(
|
|
235
|
+
... options=input.options,
|
|
236
|
+
... solution=result,
|
|
237
|
+
... statistics={"processing_time": 0.5}
|
|
238
|
+
... )
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
raise NotImplementedError
|
|
242
|
+
|
|
243
|
+
def save(model_self, model_dir: str, configuration: ModelConfiguration) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Save the model to the local filesystem for deployment.
|
|
246
|
+
|
|
247
|
+
This method packages the model according to the provided configuration,
|
|
248
|
+
creating all necessary files and dependencies for deployment to Nextmv
|
|
249
|
+
Cloud.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
model_dir : str
|
|
254
|
+
The directory where the model will be saved.
|
|
255
|
+
configuration : ModelConfiguration
|
|
256
|
+
The configuration of the model, which defines how the model is
|
|
257
|
+
saved and loaded.
|
|
258
|
+
|
|
259
|
+
Raises
|
|
260
|
+
------
|
|
261
|
+
ImportError
|
|
262
|
+
If mlflow is not installed, which is required for model packaging.
|
|
263
|
+
|
|
264
|
+
Notes
|
|
265
|
+
-----
|
|
266
|
+
This method uses mlflow for model packaging, creating the necessary
|
|
267
|
+
files and directory structure for deployment.
|
|
268
|
+
|
|
269
|
+
Examples
|
|
270
|
+
--------
|
|
271
|
+
>>> model = MyDecisionModel()
|
|
272
|
+
>>> config = ModelConfiguration(
|
|
273
|
+
... name="routing_model",
|
|
274
|
+
... requirements=["pandas", "numpy"]
|
|
275
|
+
... )
|
|
276
|
+
>>> model.save("/tmp/my_model", config)
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
# mlflow is a big package. We don't want to make it a dependency of
|
|
280
|
+
# `nextmv` because it is not always needed. We only need it if we are
|
|
281
|
+
# working with the "app from model" logic, which involves working with
|
|
282
|
+
# this `Model` class.
|
|
283
|
+
try:
|
|
284
|
+
import mlflow as mlflow
|
|
285
|
+
except ImportError as e:
|
|
286
|
+
raise ImportError(
|
|
287
|
+
"mlflow is not installed. Please install optional dependencies with `pip install nextmv[all]`"
|
|
288
|
+
) from e
|
|
289
|
+
|
|
290
|
+
finally:
|
|
291
|
+
from mlflow.models import infer_signature
|
|
292
|
+
from mlflow.pyfunc import PythonModel, save_model
|
|
293
|
+
|
|
294
|
+
class MLFlowModel(PythonModel):
|
|
295
|
+
"""
|
|
296
|
+
Transient class to translate a Nextmv Decision Model into an MLflow PythonModel.
|
|
297
|
+
|
|
298
|
+
This class complies with the MLflow inference API, implementing a `predict`
|
|
299
|
+
method that calls the user-defined `solve` method of the Nextmv Decision Model.
|
|
300
|
+
|
|
301
|
+
Methods
|
|
302
|
+
-------
|
|
303
|
+
predict(context, model_input, params)
|
|
304
|
+
MLflow-compliant predict method that delegates to the Nextmv model's solve method.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
def predict(
|
|
308
|
+
self,
|
|
309
|
+
context,
|
|
310
|
+
model_input,
|
|
311
|
+
params: dict[str, Any] | None = None,
|
|
312
|
+
) -> Any:
|
|
313
|
+
"""
|
|
314
|
+
MLflow-compliant prediction method that calls the Nextmv model's solve method.
|
|
315
|
+
|
|
316
|
+
This method enables compatibility with MLflow's python_function model flavor.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
context : mlflow.pyfunc.PythonModelContext
|
|
321
|
+
The MLflow model context.
|
|
322
|
+
model_input : Any
|
|
323
|
+
The input data for prediction, passed to the solve method.
|
|
324
|
+
params : Optional[dict[str, Any]], optional
|
|
325
|
+
Additional parameters for prediction.
|
|
326
|
+
|
|
327
|
+
Returns
|
|
328
|
+
-------
|
|
329
|
+
Any
|
|
330
|
+
The result from the Nextmv model's solve method.
|
|
331
|
+
|
|
332
|
+
Notes
|
|
333
|
+
-----
|
|
334
|
+
This method should not be used or overridden directly. Instead,
|
|
335
|
+
implement the `solve` method in your Nextmv Model subclass.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
return model_self.solve(model_input)
|
|
339
|
+
|
|
340
|
+
# Some annoying logging from mlflow must be disabled.
|
|
341
|
+
logging.disable(logging.CRITICAL)
|
|
342
|
+
|
|
343
|
+
_cleanup_python_model(model_dir, configuration, verbose=False)
|
|
344
|
+
|
|
345
|
+
signature = None
|
|
346
|
+
if configuration.options is not None:
|
|
347
|
+
options_dict = configuration.options.to_dict()
|
|
348
|
+
signature = infer_signature(
|
|
349
|
+
params=options_dict,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# We use mlflow to save the model to the local filesystem, to be able to
|
|
353
|
+
# load it later on.
|
|
354
|
+
model_path = os.path.join(model_dir, configuration.name)
|
|
355
|
+
save_model(
|
|
356
|
+
path=model_path, # Customize the name of the model location.
|
|
357
|
+
infer_code_paths=True, # Makes the imports portable.
|
|
358
|
+
python_model=MLFlowModel(),
|
|
359
|
+
signature=signature, # Allows us to work with our own `Options` class.
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Create an auxiliary requirements file with the model dependencies.
|
|
363
|
+
requirements_file = os.path.join(model_dir, _REQUIREMENTS_FILE)
|
|
364
|
+
with open(requirements_file, "w") as file:
|
|
365
|
+
file.write(f"{_MLFLOW_DEPENDENCY}\n")
|
|
366
|
+
reqs = configuration.requirements
|
|
367
|
+
if reqs is not None:
|
|
368
|
+
for req in reqs:
|
|
369
|
+
file.write(f"{req}\n")
|
|
370
|
+
|
|
371
|
+
# Adds the main.py file to the app_dir by coping the `entrypoint.py` file
|
|
372
|
+
# which is one level up from this file.
|
|
373
|
+
entrypoint_file = os.path.join(os.path.dirname(__file__), _ENTRYPOINT_FILE)
|
|
374
|
+
shutil.copy2(entrypoint_file, os.path.join(model_dir, "main.py"))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _cleanup_python_model(
|
|
378
|
+
model_dir: str,
|
|
379
|
+
model_configuration: ModelConfiguration | None = None,
|
|
380
|
+
verbose: bool = False,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""
|
|
383
|
+
Clean up Python-specific model packaging artifacts.
|
|
384
|
+
|
|
385
|
+
This function removes temporary files and directories created during the
|
|
386
|
+
model packaging process.
|
|
387
|
+
|
|
388
|
+
Parameters
|
|
389
|
+
----------
|
|
390
|
+
model_dir : str
|
|
391
|
+
The directory where the model was saved.
|
|
392
|
+
model_configuration : Optional[ModelConfiguration], optional
|
|
393
|
+
The configuration of the model. If None, the function returns early.
|
|
394
|
+
verbose : bool, default=False
|
|
395
|
+
If True, log a message when cleanup is complete.
|
|
396
|
+
|
|
397
|
+
Returns
|
|
398
|
+
-------
|
|
399
|
+
None
|
|
400
|
+
This function does not return anything.
|
|
401
|
+
|
|
402
|
+
Notes
|
|
403
|
+
-----
|
|
404
|
+
Files and directories removed include:
|
|
405
|
+
- The model directory itself
|
|
406
|
+
- The mlruns directory created by MLflow
|
|
407
|
+
- The requirements file
|
|
408
|
+
- The main.py file
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
if model_configuration is None:
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
model_path = os.path.join(model_dir, model_configuration.name)
|
|
415
|
+
if os.path.exists(model_path):
|
|
416
|
+
shutil.rmtree(model_path)
|
|
417
|
+
|
|
418
|
+
mlruns_path = os.path.join(model_dir, "mlruns")
|
|
419
|
+
if os.path.exists(mlruns_path):
|
|
420
|
+
shutil.rmtree(mlruns_path)
|
|
421
|
+
|
|
422
|
+
requirements_file = os.path.join(model_dir, _REQUIREMENTS_FILE)
|
|
423
|
+
if os.path.exists(requirements_file):
|
|
424
|
+
os.remove(requirements_file)
|
|
425
|
+
|
|
426
|
+
main_file = os.path.join(model_dir, "main.py")
|
|
427
|
+
if os.path.exists(main_file):
|
|
428
|
+
os.remove(main_file)
|
|
429
|
+
|
|
430
|
+
if verbose:
|
|
431
|
+
log("🧹 Cleaned up Python model artifacts.")
|