nextmv 0.30.0__py3-none-any.whl → 0.32.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/local/runner.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ Runner module for executing local runs.
3
+
4
+ This module provides functionality to execute local runs.
5
+
6
+ Functions
7
+ ---------
8
+ run
9
+ Function to execute a local run.
10
+ new_run
11
+ Function to initialize a new run.
12
+ record_input
13
+ Function to write the input to the appropriate location.
14
+ """
15
+
16
+ import importlib.util
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ from datetime import datetime, timezone
23
+ from typing import Any, Optional, Union
24
+
25
+ from nextmv.input import INPUTS_KEY
26
+ from nextmv.local.local import DEFAULT_INPUT_JSON_FILE, NEXTMV_DIR, RUNS_KEY, calculate_files_size
27
+ from nextmv.manifest import Manifest
28
+ from nextmv.run import Format, FormatInput, Metadata, RunInformation, StatusV2
29
+ from nextmv.safe import safe_id
30
+
31
+
32
+ def run(
33
+ app_id: str,
34
+ src: str,
35
+ manifest: Manifest,
36
+ run_config: dict[str, Any],
37
+ name: Optional[str] = None,
38
+ description: Optional[str] = None,
39
+ input_data: Optional[Union[dict[str, Any], str]] = None,
40
+ inputs_dir_path: Optional[str] = None,
41
+ options: Optional[dict[str, Any]] = None,
42
+ ) -> str:
43
+ """
44
+ Execute a local run.
45
+
46
+ This method recreates, partially, what the Nextmv Cloud does in the backend
47
+ when running an application. A run ID is generated, a run directory is
48
+ created, and the input data is recorded. Then, a subprocess is started to
49
+ execute the application run in a detached manner. This means that the
50
+ application run is not waited upon.
51
+
52
+ Parameters
53
+ ----------
54
+ app_id : str
55
+ The ID of the application.
56
+ src : str
57
+ The path to the application source code.
58
+ manifest : Manifest
59
+ The application manifest.
60
+ run_config : dict[str, Any]
61
+ The run configuration.
62
+ name : Optional[str], optional
63
+ The name for the run, by default None.
64
+ description : Optional[str], optional
65
+ The description for the run, by default None.
66
+ input_data : Optional[Union[dict[str, Any], str]], optional
67
+ The input data for the run, by default None. If `inputs_dir_path` is
68
+ provided, this parameter is ignored.
69
+ inputs_dir_path : Optional[str], optional
70
+ The path to the directory containing input files, by default None. If
71
+ provided, this parameter takes precedence over `input_data`.
72
+ options : Optional[dict[str, Any]], optional
73
+ Additional options for the run, by default None.
74
+
75
+ Returns
76
+ -------
77
+ str
78
+ The ID of the created run.
79
+ """
80
+
81
+ # Check for required optional dependencies
82
+ missing_deps = []
83
+ if importlib.util.find_spec("folium") is None:
84
+ missing_deps.append("folium")
85
+ if importlib.util.find_spec("plotly") is None:
86
+ missing_deps.append("plotly")
87
+
88
+ if missing_deps:
89
+ raise ImportError(
90
+ f"{' and '.join(missing_deps)} {'is' if len(missing_deps) == 1 else 'are'} not installed. "
91
+ "Please install optional dependencies with `pip install nextmv[all]`"
92
+ )
93
+
94
+ # Initialize the run: create the ID, dir, and write the input.
95
+ run_id = safe_id("local")
96
+ run_dir = new_run(
97
+ app_id=app_id,
98
+ src=src,
99
+ run_id=run_id,
100
+ run_config=run_config,
101
+ name=name,
102
+ description=description,
103
+ )
104
+ record_input(
105
+ run_dir=run_dir,
106
+ run_id=run_id,
107
+ input_data=input_data,
108
+ inputs_dir_path=inputs_dir_path,
109
+ )
110
+
111
+ # Start the process as a daemon (detached) so we don't wait for it to
112
+ # finish. We send the input via stdin and close it immediately without
113
+ # waiting. We call the `executor.py` script to do the actual execution.
114
+ stdin_input = json.dumps(
115
+ {
116
+ "run_id": run_id,
117
+ "src": os.path.abspath(src),
118
+ "manifest_dict": manifest.to_dict(),
119
+ "run_dir": os.path.abspath(run_dir),
120
+ "run_config": run_config,
121
+ "input_data": input_data,
122
+ "inputs_dir_path": os.path.abspath(inputs_dir_path) if inputs_dir_path is not None else None,
123
+ "options": options,
124
+ }
125
+ )
126
+ args = [sys.executable, "executor.py"]
127
+ process = subprocess.Popen(
128
+ args,
129
+ env=os.environ,
130
+ text=True,
131
+ stdin=subprocess.PIPE,
132
+ stdout=subprocess.DEVNULL,
133
+ stderr=subprocess.DEVNULL,
134
+ cwd=os.path.dirname(__file__),
135
+ start_new_session=True, # Detach from parent process
136
+ )
137
+ process.stdin.write(stdin_input)
138
+ process.stdin.close()
139
+
140
+ return run_id
141
+
142
+
143
+ def new_run(
144
+ app_id: str,
145
+ src: str,
146
+ run_id: str,
147
+ run_config: dict[str, Any],
148
+ name: Optional[str] = None,
149
+ description: Optional[str] = None,
150
+ ) -> str:
151
+ """
152
+ Initializes a new run.
153
+
154
+ The run information is recorded in a JSON file within the run directory.
155
+
156
+ Parameters
157
+ ----------
158
+ app_id : str
159
+ The ID of the application.
160
+ src : str
161
+ The path to the application source code.
162
+ run_id : str
163
+ The ID of the run.
164
+ run_config : dict[str, Any]
165
+ The run configuration.
166
+ name : Optional[str], optional
167
+ The name for the run, by default None.
168
+ description : Optional[str], optional
169
+ The description for the run, by default None.
170
+
171
+ Returns
172
+ -------
173
+ str
174
+ The path to the new run directory.
175
+ """
176
+
177
+ # First, ensure the runs directory exists.
178
+ runs_dir = os.path.join(src, NEXTMV_DIR, RUNS_KEY)
179
+ os.makedirs(runs_dir, exist_ok=True)
180
+
181
+ # Create a new run directory.
182
+ run_dir = os.path.join(runs_dir, run_id)
183
+ os.makedirs(run_dir, exist_ok=True)
184
+
185
+ # Create the run information file.
186
+ created_at = datetime.now(timezone.utc)
187
+ metadata = Metadata(
188
+ application_id=app_id,
189
+ application_instance_id="",
190
+ application_version_id="",
191
+ created_at=created_at,
192
+ duration=0.0,
193
+ error="",
194
+ input_size=0.0,
195
+ output_size=0.0,
196
+ format=Format(
197
+ format_input=FormatInput(
198
+ input_type=run_config["format"]["input"]["type"],
199
+ ),
200
+ ),
201
+ status_v2=StatusV2.queued,
202
+ )
203
+
204
+ if description is None:
205
+ description = f"Local run created at {created_at.isoformat().replace('+00:00', 'Z')}"
206
+
207
+ if name is None:
208
+ name = f"local run {run_id}"
209
+
210
+ information = RunInformation(
211
+ description=description,
212
+ id=run_id,
213
+ metadata=metadata,
214
+ name=name,
215
+ user_email="",
216
+ )
217
+ with open(os.path.join(run_dir, f"{run_id}.json"), "w") as f:
218
+ json.dump(information.to_dict(), f, indent=2)
219
+
220
+ return run_dir
221
+
222
+
223
+ def record_input(
224
+ run_dir: str,
225
+ run_id: str,
226
+ input_data: Optional[Union[dict[str, Any], str]] = None,
227
+ inputs_dir_path: Optional[str] = None,
228
+ ) -> None:
229
+ """
230
+ Writes the input to the appropriate location.
231
+
232
+ The size of the input is calculated and recorded in the run information.
233
+
234
+ Parameters
235
+ ----------
236
+ run_dir : str
237
+ The path to the run directory.
238
+ run_id : str
239
+ The ID of the run.
240
+ input_data : Optional[Union[dict[str, Any], str]], optional
241
+ The input data for the run, by default None. If `inputs_dir_path` is
242
+ provided, this parameter is ignored.
243
+ inputs_dir_path : Optional[str], optional
244
+ The path to the directory containing input files, by default None. If
245
+ provided, this parameter takes precedence over `input_data`.
246
+ """
247
+
248
+ # Create the inputs directory.
249
+ run_inputs_dir = os.path.join(run_dir, INPUTS_KEY)
250
+ os.makedirs(run_inputs_dir, exist_ok=True)
251
+
252
+ if inputs_dir_path is not None and inputs_dir_path != "":
253
+ # If we specify an inputs directory, we ignore the input_data.
254
+ # Copy all files from inputs_dir_path to run_inputs_dir
255
+ if os.path.exists(inputs_dir_path) and os.path.isdir(inputs_dir_path):
256
+ shutil.copytree(inputs_dir_path, run_inputs_dir, dirs_exist_ok=True)
257
+
258
+ elif isinstance(input_data, dict):
259
+ # If no inputs_dir_path is provided, try a single JSON input.
260
+ with open(os.path.join(run_inputs_dir, DEFAULT_INPUT_JSON_FILE), "w") as f:
261
+ json.dump(input_data, f, indent=2)
262
+
263
+ elif isinstance(input_data, str):
264
+ # If no inputs_dir_path is provided, try a single TEXT input.
265
+ with open(os.path.join(run_inputs_dir, "input"), "w") as f:
266
+ f.write(input_data)
267
+
268
+ else:
269
+ raise ValueError(
270
+ "Invalid input data type: input_data must be a dict or str, or inputs_dir_path must be provided."
271
+ )
272
+
273
+ # Update the input size in the run information file.
274
+ calculate_files_size(run_dir, run_id, run_inputs_dir, metadata_key="input_size")