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/cloud/package.py
ADDED
|
@@ -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"
|