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
@@ -0,0 +1,474 @@
1
+ """Module with the logic for pushing an app to Nextmv Cloud."""
2
+
3
+ import glob
4
+ import os
5
+ import platform
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import tarfile
10
+ import tempfile
11
+
12
+ from nextmv.logger import log
13
+ from nextmv.manifest import MANIFEST_FILE_NAME, Manifest, ManifestBuild, ManifestType
14
+ from nextmv.model import Model, ModelConfiguration, _cleanup_python_model
15
+
16
+ _MANDATORY_FILES_PER_TYPE = {
17
+ ManifestType.PYTHON: ["main.py"],
18
+ ManifestType.GO: ["main"],
19
+ ManifestType.JAVA: ["main.jar"],
20
+ }
21
+
22
+
23
+ def _package(
24
+ app_dir: str,
25
+ manifest: Manifest,
26
+ model: Model | None = None,
27
+ model_configuration: ModelConfiguration | None = None,
28
+ verbose: bool = False,
29
+ ) -> tuple[str, str]:
30
+ """Package the app into a tarball."""
31
+
32
+ with tempfile.TemporaryDirectory(prefix="nextmv-temp-") as temp_dir:
33
+ if manifest.type == ManifestType.PYTHON:
34
+ __handle_python(app_dir, temp_dir, manifest, model, model_configuration, verbose)
35
+
36
+ found, missing, files = __find_files(app_dir, manifest.files)
37
+ __confirm_mandatory_files(manifest, found)
38
+
39
+ if len(missing) > 0:
40
+ raise Exception(f"could not find files listed in manifest: {', '.join(missing)}")
41
+
42
+ manifest.to_yaml(temp_dir)
43
+
44
+ for file in files:
45
+ target_dir = os.path.dirname(os.path.join(temp_dir, file["interior_path"]))
46
+ try:
47
+ os.makedirs(target_dir, exist_ok=True)
48
+ except OSError as e:
49
+ raise Exception(f"error creating directory for asset file {file['interior_path']}: {e}") from e
50
+
51
+ try:
52
+ shutil.copy2(file["absolute_path"], os.path.join(temp_dir, target_dir))
53
+ except subprocess.CalledProcessError as e:
54
+ raise Exception(f"error copying asset files {file['absolute_path']}: {e}") from e
55
+
56
+ if verbose:
57
+ log(f'📋 Copied files listed in "{MANIFEST_FILE_NAME}" manifest.')
58
+
59
+ if manifest.type == ManifestType.PYTHON:
60
+ _cleanup_python_model(app_dir, model_configuration, verbose)
61
+
62
+ output_dir = tempfile.mkdtemp(prefix="nextmv-build-out-")
63
+ tar_file, file_count = __compress_tar(temp_dir, output_dir)
64
+ file_count_msg = f"{file_count} file" if file_count == 1 else f"{file_count} files"
65
+ if verbose:
66
+ try:
67
+ size = __human_friendly_file_size(tar_file)
68
+ log(f"📦 Packaged application ({file_count_msg}, {size}).")
69
+ except Exception:
70
+ log(f"📦 Packaged application ({file_count_msg}).")
71
+
72
+ return tar_file, output_dir
73
+
74
+
75
+ def _run_build_command(
76
+ app_dir: str,
77
+ manifest_build: ManifestBuild | None = None,
78
+ verbose: bool = False,
79
+ ) -> None:
80
+ """Run the build command specified in the manifest."""
81
+
82
+ if manifest_build is None or manifest_build.command is None or manifest_build.command == "":
83
+ return
84
+
85
+ elements = manifest_build.command.split(" ")
86
+ command_str = " ".join(elements)
87
+ log(f'🚧 Running build command: "{command_str}"')
88
+ try:
89
+ result = subprocess.run(
90
+ elements,
91
+ env={**os.environ, **manifest_build.environment_to_dict()},
92
+ check=True,
93
+ text=True,
94
+ capture_output=True,
95
+ cwd=app_dir,
96
+ )
97
+
98
+ except subprocess.CalledProcessError as e:
99
+ raise Exception(f"error running build command: {e.stderr}") from e
100
+
101
+ if verbose:
102
+ log(result.stdout)
103
+
104
+
105
+ def _get_shell_command_elements(pre_push_command):
106
+ """Get the shell command elements based on the operating system."""
107
+ # Check if we're in a Unix-like shell (including MINGW on Windows)
108
+ if "SHELL" in os.environ and shutil.which("bash"):
109
+ return ["bash", "-c", pre_push_command]
110
+ # Default to cmd on Windows
111
+ elif platform.system() == "Windows":
112
+ return ["cmd", "/c", pre_push_command]
113
+ # Default to sh on Unix-like systems (Linux, macOS)
114
+ else:
115
+ return ["sh", "-c", pre_push_command]
116
+
117
+
118
+ def _run_pre_push_command(
119
+ app_dir: str,
120
+ pre_push_command: str | None = None,
121
+ verbose: bool = False,
122
+ ) -> None:
123
+ """Run the pre-push command specified in the manifest."""
124
+
125
+ if pre_push_command is None or pre_push_command == "":
126
+ return
127
+
128
+ elements = _get_shell_command_elements(pre_push_command)
129
+
130
+ command_str = " ".join(elements)
131
+ log(f'🔨 Running pre-push command: "{command_str}"')
132
+ try:
133
+ result = subprocess.run(
134
+ elements,
135
+ env=os.environ,
136
+ check=True,
137
+ text=True,
138
+ capture_output=True,
139
+ cwd=app_dir,
140
+ )
141
+
142
+ except subprocess.CalledProcessError as e:
143
+ raise Exception(f"error running pre-push command: {e.stderr}") from e
144
+
145
+ if verbose:
146
+ log(result.stdout)
147
+
148
+
149
+ def __find_files(
150
+ app_dir: str,
151
+ filters: list[str],
152
+ ) -> tuple[list[str], list[str], list[dict[str, str]]]:
153
+ """Find all files matching the given filters in the given directory."""
154
+
155
+ found = []
156
+ missing = []
157
+
158
+ # Temporarily switch to the directory to make the globbing work
159
+ cwd = os.getcwd()
160
+ try:
161
+ os.chdir(app_dir)
162
+ except OSError as e:
163
+ raise Exception(f"error changing to file root directory: {e}") from e
164
+
165
+ for filter in filters:
166
+ # We support "some/path/" ending with a "/". We consider it equivalent
167
+ # to "some/path/*".
168
+ pattern = filter
169
+ if filter.endswith("/"):
170
+ pattern = filter + "*"
171
+ # If the pattern starts with a '!': negate the pattern
172
+ negated = False
173
+ if pattern.startswith("!"):
174
+ pattern = pattern[1:]
175
+ negated = True
176
+ matches = glob.glob(pattern, recursive=True)
177
+ if not matches and not negated:
178
+ missing.append(filter)
179
+ else:
180
+ if negated:
181
+ found = [f for f in found if f not in matches]
182
+ else:
183
+ for match in matches:
184
+ if os.path.isdir(match):
185
+ continue
186
+
187
+ found.append(match)
188
+
189
+ # Switch back to the original directory
190
+ os.chdir(cwd)
191
+
192
+ files = []
193
+ for file in found:
194
+ files.append(
195
+ {
196
+ "interior_path": file,
197
+ "absolute_path": os.path.join(app_dir, file),
198
+ }
199
+ )
200
+
201
+ return found, missing, files
202
+
203
+
204
+ def __confirm_mandatory_files(manifest: Manifest, present_files: list[str]) -> None:
205
+ """Confirm that all mandatory files are present in the given list of files."""
206
+
207
+ found_files = {os.path.normpath(file): True for file in present_files}
208
+
209
+ # Check for mandatory files (if a custom execution config is provided we check the
210
+ # custom entrypoint instead)
211
+ mandatory_files = []
212
+ if manifest.execution is None or manifest.execution.entrypoint is None:
213
+ mandatory_files = _MANDATORY_FILES_PER_TYPE[manifest.type]
214
+ else:
215
+ mandatory_files.append(os.path.normpath(manifest.execution.entrypoint))
216
+ missing_files = [file for file in mandatory_files if file not in found_files]
217
+
218
+ if missing_files:
219
+ raise Exception(f"missing mandatory files: {', '.join(missing_files)}")
220
+
221
+
222
+ def __handle_python(
223
+ app_dir: str,
224
+ temp_dir: str,
225
+ manifest: Manifest,
226
+ model: Model | None = None,
227
+ model_configuration: ModelConfiguration | None = None,
228
+ verbose: bool = False,
229
+ ) -> None:
230
+ """Handles the Python-specific packaging logic."""
231
+
232
+ if model is not None and model_configuration is not None:
233
+ if verbose:
234
+ log("🔮 Encoding Python model.")
235
+ model.save(app_dir, model_configuration)
236
+
237
+ if verbose:
238
+ log("🐍 Bundling Python dependencies.")
239
+ __install_dependencies(manifest, app_dir, temp_dir)
240
+
241
+
242
+ def __install_dependencies( # noqa: C901 # complexity
243
+ manifest: Manifest,
244
+ app_dir: str,
245
+ temp_dir: str,
246
+ ) -> None:
247
+ """Install dependencies for the Python app."""
248
+
249
+ if manifest.python is None:
250
+ return
251
+
252
+ pip_requirements = manifest.python.pip_requirements
253
+
254
+ if pip_requirements is None or pip_requirements == "":
255
+ # If no pip requirements are specified, we do not install any dependencies.
256
+ return
257
+
258
+ if isinstance(pip_requirements, list):
259
+ # If pip_requirements is a list, we write it to a temporary file so that we can
260
+ # pass it to pip.
261
+ pip_requirements_file = os.path.join(temp_dir, "requirements.txt")
262
+ with open(pip_requirements_file, "w") as f:
263
+ for requirement in pip_requirements:
264
+ f.write(requirement + "\n")
265
+ pip_requirements = pip_requirements_file
266
+ elif isinstance(pip_requirements, str):
267
+ # If pip_requirements is a string, we expect it to be a file path to a
268
+ # requirements file.
269
+ pip_requirements = pip_requirements.strip()
270
+ if not os.path.isfile(os.path.join(app_dir, pip_requirements)):
271
+ raise FileNotFoundError(f"pip requirements file '{pip_requirements}' not found in '{app_dir}'")
272
+
273
+ platform_filter = []
274
+ if not manifest.python.arch or manifest.python.arch == "arm64":
275
+ platform_filter.extend(
276
+ [
277
+ "--platform=manylinux2014_aarch64",
278
+ "--platform=manylinux_2_17_aarch64",
279
+ "--platform=manylinux_2_24_aarch64",
280
+ "--platform=manylinux_2_28_aarch64",
281
+ "--platform=linux_aarch64",
282
+ ]
283
+ )
284
+ elif manifest.python.arch == "amd64":
285
+ platform_filter.extend(
286
+ [
287
+ "--platform=manylinux2014_x86_64",
288
+ "--platform=manylinux_2_17_x86_64",
289
+ "--platform=manylinux_2_24_x86_64",
290
+ "--platform=manylinux_2_28_x86_64",
291
+ "--platform=linux_x86_64",
292
+ ]
293
+ )
294
+ else:
295
+ raise Exception(f"unknown architecture '{manifest.python.arch}' specified in manifest")
296
+
297
+ version_filter = ["--python-version=3.11"]
298
+ if manifest.python.version:
299
+ __confirm_python_bundling_version(manifest.python.version)
300
+ version_filter = [f"--python-version={manifest.python.version}"]
301
+
302
+ py_cmd = __get_python_command()
303
+ dep_dir = os.path.join(".nextmv", "python", "deps")
304
+ command = (
305
+ [
306
+ py_cmd,
307
+ "-m",
308
+ "pip",
309
+ "install",
310
+ "-r",
311
+ pip_requirements,
312
+ "--only-binary=:all:",
313
+ "--implementation=cp",
314
+ "--upgrade",
315
+ "--no-warn-conflicts",
316
+ "--target",
317
+ os.path.join(temp_dir, dep_dir),
318
+ "--no-user", # We explicitly avoid user mode (mainly to fix issues with Windows store Python installations)
319
+ "--no-input",
320
+ "--quiet",
321
+ ]
322
+ + platform_filter
323
+ + version_filter
324
+ )
325
+ result = subprocess.run(
326
+ command,
327
+ cwd=app_dir,
328
+ text=True,
329
+ capture_output=True,
330
+ check=True,
331
+ )
332
+ if result.returncode != 0:
333
+ raise Exception(f"error installing dependencies: {result.stderr}")
334
+
335
+
336
+ def __run_command(binary: str, dir: str, redirect_out_err: bool, *arguments: str) -> str:
337
+ os_agnostic_cmd = binary
338
+ if arguments:
339
+ os_agnostic_cmd += " " + " ".join(arguments)
340
+
341
+ if platform.system() == "Windows":
342
+ bin = "cmd"
343
+ args = ["/c", os_agnostic_cmd]
344
+ else:
345
+ bin = "bash"
346
+ args = ["-c", os_agnostic_cmd]
347
+
348
+ cmd = subprocess.Popen(
349
+ [bin] + args,
350
+ cwd=dir if dir else None,
351
+ env=os.environ,
352
+ stdout=subprocess.PIPE if redirect_out_err else None,
353
+ stderr=subprocess.PIPE if redirect_out_err else None,
354
+ text=True,
355
+ )
356
+
357
+ if redirect_out_err:
358
+ out, err = cmd.communicate()
359
+ if cmd.returncode != 0:
360
+ raise Exception(f"Command failed with error: {err}")
361
+ return out
362
+ else:
363
+ cmd.wait()
364
+ if cmd.returncode != 0:
365
+ raise Exception("Command failed")
366
+ return ""
367
+
368
+
369
+ def __get_python_command() -> str:
370
+ py_cmds = ["python3", "python"]
371
+ py_cmd = ""
372
+ for cmd in py_cmds:
373
+ try:
374
+ output = __run_command(cmd, "", True, "--version")
375
+ __confirm_python_version(output)
376
+ py_cmd = cmd
377
+ break
378
+ except Exception:
379
+ continue
380
+
381
+ if not py_cmd:
382
+ raise Exception("Python not found in PATH")
383
+
384
+ output = __run_command(py_cmd, "", True, "-m", "pip", "--version")
385
+ __confirm_pip_version(output)
386
+
387
+ return py_cmd
388
+
389
+
390
+ def __confirm_pip_version(output: str) -> None:
391
+ elements = output.split()
392
+ if len(elements) < 2:
393
+ raise Exception("pip version not found")
394
+
395
+ version = elements[1].strip()
396
+ re_version = re.compile(r"\d+\.\d+")
397
+ if re_version.match(version):
398
+ try:
399
+ major, _, _ = map(int, version.split("."))
400
+ except ValueError:
401
+ major, _ = map(int, version.split("."))
402
+
403
+ if major >= 22:
404
+ return
405
+
406
+ raise Exception("pip version 22.0 or higher is required")
407
+
408
+
409
+ def __confirm_python_version(output: str) -> None:
410
+ elements = output.split()
411
+ if len(elements) < 2:
412
+ raise Exception("python version not found")
413
+
414
+ version = elements[1].strip()
415
+ re_version = re.compile(r"\d+\.\d+\.\d+")
416
+ if re_version.match(version):
417
+ try:
418
+ major, minor, _ = map(int, version.split("."))
419
+ except ValueError:
420
+ major, minor = map(int, version.split("."))
421
+
422
+ if major == 3 and minor >= 10:
423
+ return
424
+
425
+ raise Exception("python version 3.10 or higher is required")
426
+
427
+
428
+ def __confirm_python_bundling_version(version: str) -> None:
429
+ # Only accept versions in the form "major.minor" where both are integers
430
+ re_version = re.compile(r"^(\d+)\.(\d+)$")
431
+ match = re_version.fullmatch(version)
432
+ if match:
433
+ major, minor = int(match.group(1)), int(match.group(2))
434
+ if major == 3 and minor >= 10:
435
+ return
436
+ raise Exception(f"python version 3.10 or higher is required for bundling, got {version}")
437
+
438
+
439
+ def __compress_tar(source: str, target: str) -> tuple[str, int]:
440
+ """Compress the source directory into a tar.gz file in the target"""
441
+
442
+ return_file_name = "app.tar.gz"
443
+ target = os.path.join(target, return_file_name)
444
+ num_files = 0
445
+
446
+ with tarfile.open(target, "w:gz") as tar:
447
+ for root, _, files in os.walk(source):
448
+ for file in files:
449
+ if file == return_file_name:
450
+ continue
451
+ file_path = os.path.join(root, file)
452
+ arcname = os.path.relpath(file_path, start=source)
453
+ tar.add(file_path, arcname=arcname)
454
+ num_files += 1
455
+
456
+ return target, num_files
457
+
458
+
459
+ def __human_friendly_file_size(path: str) -> str:
460
+ """Return a human-friendly string representation of the file size."""
461
+
462
+ try:
463
+ size = os.path.getsize(path)
464
+ except OSError as e:
465
+ raise Exception(f"error getting file size: {e}") from e
466
+
467
+ if size < 1024:
468
+ return f"{size} B"
469
+ elif size < 1024 * 1024:
470
+ return f"{size / 1024:.2f} KiB"
471
+ elif size < 1024 * 1024 * 1024:
472
+ return f"{size / (1024 * 1024):.2f} MiB"
473
+ else:
474
+ return f"{size / (1024 * 1024 * 1024):.2f} GiB"