pyship 0.1.7__py3-none-any.whl → 0.3.1__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.
- pyship/__init__.py +2 -1
- pyship/__main__.py +0 -1
- pyship/_version_.py +1 -1
- pyship/app_info.py +12 -27
- pyship/clip.py +18 -104
- pyship/cloud.py +2 -1
- pyship/constants.py +7 -0
- pyship/create_launcher.py +47 -82
- pyship/launcher/__init__.py +1 -1
- pyship/launcher/launcher_standalone.py +309 -0
- pyship/launcher/metadata.py +5 -0
- pyship/launcher_stub.py +198 -0
- pyship/nsis.py +12 -5
- pyship/pyship.py +8 -8
- pyship/pyship_custom_print.py +0 -1
- pyship/pyship_get_icon.py +3 -1
- pyship/uv_util.py +157 -0
- pyship-0.3.1.dist-info/METADATA +81 -0
- pyship-0.3.1.dist-info/RECORD +34 -0
- {pyship-0.1.7.dist-info → pyship-0.3.1.dist-info}/WHEEL +1 -1
- pyship/atomic_zip.py +0 -41
- pyship/get-pip.py +0 -22713
- pyship/launcher/launcher.py +0 -225
- pyship/patch/__init__.py +0 -0
- pyship/patch/pyship_patch.py +0 -33
- pyship-0.1.7.dist-info/METADATA +0 -47
- pyship-0.1.7.dist-info/RECORD +0 -36
- {pyship-0.1.7.dist-info → pyship-0.3.1.dist-info/licenses}/LICENSE +0 -0
- {pyship-0.1.7.dist-info → pyship-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone launcher script for pyship applications.
|
|
3
|
+
|
|
4
|
+
This script is designed to be self-contained (stdlib only, no third-party imports)
|
|
5
|
+
so it can be run by any Python interpreter without additional dependencies.
|
|
6
|
+
|
|
7
|
+
It is invoked by the C# launcher stub:
|
|
8
|
+
python.exe {app_name}_launcher.py --app-dir <app_dir> [app args...]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
import logging
|
|
17
|
+
import subprocess
|
|
18
|
+
import argparse
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Return codes (matching pyshipupdate constants)
|
|
22
|
+
OK_RETURN_CODE = 0
|
|
23
|
+
ERROR_RETURN_CODE = 1
|
|
24
|
+
CAN_NOT_FIND_FILE_RETURN_CODE = 2
|
|
25
|
+
RESTART_RETURN_CODE = 13
|
|
26
|
+
|
|
27
|
+
# Python interpreter executables by GUI mode
|
|
28
|
+
PYTHON_INTERPRETER_EXES = {True: "pythonw.exe", False: "python.exe"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RestartMonitor:
|
|
32
|
+
"""
|
|
33
|
+
Monitor application restarts and detect excessive restart frequency.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.restarts = []
|
|
38
|
+
self.max_samples = 4
|
|
39
|
+
self.quick = 60.0 # this time or less (in seconds) is considered a quick restart
|
|
40
|
+
|
|
41
|
+
def add(self):
|
|
42
|
+
self.restarts.append(time.time())
|
|
43
|
+
if len(self.restarts) > self.max_samples:
|
|
44
|
+
self.restarts.pop(0)
|
|
45
|
+
|
|
46
|
+
def excessive(self):
|
|
47
|
+
"""
|
|
48
|
+
Determine if there has been excessive frequency of restarts.
|
|
49
|
+
:return: True if restarts have been excessive
|
|
50
|
+
"""
|
|
51
|
+
if len(self.restarts) < self.max_samples:
|
|
52
|
+
return False
|
|
53
|
+
return all(j - i <= self.quick for i, j in zip(self.restarts[:-1], self.restarts[1:]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _compare_versions(version_str):
|
|
57
|
+
"""
|
|
58
|
+
Convert a version string like "1.2.3" to a tuple of ints for comparison.
|
|
59
|
+
:param version_str: version string
|
|
60
|
+
:return: tuple of ints
|
|
61
|
+
"""
|
|
62
|
+
parts = []
|
|
63
|
+
for part in version_str.split("."):
|
|
64
|
+
try:
|
|
65
|
+
parts.append(int(part))
|
|
66
|
+
except ValueError:
|
|
67
|
+
parts.append(0)
|
|
68
|
+
return tuple(parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _setup_logging(app_name, is_gui):
|
|
72
|
+
"""
|
|
73
|
+
Set up stdlib logging for the launcher.
|
|
74
|
+
:param app_name: application name for the log
|
|
75
|
+
:param is_gui: True for GUI app
|
|
76
|
+
"""
|
|
77
|
+
log_dir = None
|
|
78
|
+
local_app_data = os.environ.get("LOCALAPPDATA")
|
|
79
|
+
if local_app_data:
|
|
80
|
+
log_dir = Path(local_app_data, app_name, "log")
|
|
81
|
+
try:
|
|
82
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
except OSError:
|
|
84
|
+
log_dir = None
|
|
85
|
+
|
|
86
|
+
logger = logging.getLogger(f"{app_name}_launcher")
|
|
87
|
+
logger.setLevel(logging.DEBUG)
|
|
88
|
+
|
|
89
|
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
90
|
+
|
|
91
|
+
if log_dir is not None:
|
|
92
|
+
try:
|
|
93
|
+
fh = logging.FileHandler(str(Path(log_dir, f"{app_name}_launcher.log")))
|
|
94
|
+
fh.setLevel(logging.DEBUG)
|
|
95
|
+
fh.setFormatter(formatter)
|
|
96
|
+
logger.addHandler(fh)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
if not is_gui:
|
|
101
|
+
ch = logging.StreamHandler()
|
|
102
|
+
ch.setLevel(logging.ERROR)
|
|
103
|
+
ch.setFormatter(formatter)
|
|
104
|
+
logger.addHandler(ch)
|
|
105
|
+
|
|
106
|
+
return logger
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _init_sentry():
|
|
110
|
+
"""
|
|
111
|
+
Optionally initialize Sentry if sentry_sdk is available.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
import urllib.request
|
|
115
|
+
import sentry_sdk
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
response = urllib.request.urlopen("https://api.pyship.org/resources/pyship/sentry", timeout=5)
|
|
119
|
+
if response.status == 200:
|
|
120
|
+
data = json.loads(response.read().decode())
|
|
121
|
+
sentry_dsn = data.get("dsn")
|
|
122
|
+
if sentry_dsn:
|
|
123
|
+
sentry_sdk.init(sentry_dsn, default_integrations=False)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
except ImportError:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def launch(app_dir=None, additional_path=None):
|
|
131
|
+
"""
|
|
132
|
+
Launch the pyship application.
|
|
133
|
+
:param app_dir: override app dir (mainly for testing)
|
|
134
|
+
:param additional_path: additional search path for app (mainly for testing)
|
|
135
|
+
:return: exit code (0 if no error)
|
|
136
|
+
"""
|
|
137
|
+
return_code = None
|
|
138
|
+
|
|
139
|
+
clip_regex = re.compile(r"([_a-z0-9]*)_([.0-9]+)", flags=re.IGNORECASE)
|
|
140
|
+
|
|
141
|
+
# Default values in case metadata file is not found
|
|
142
|
+
is_gui = False
|
|
143
|
+
report_exceptions = True
|
|
144
|
+
target_app_name = None
|
|
145
|
+
target_app_author = "unknown"
|
|
146
|
+
|
|
147
|
+
if app_dir is not None:
|
|
148
|
+
app_dir = Path(app_dir).resolve()
|
|
149
|
+
|
|
150
|
+
# Read metadata
|
|
151
|
+
if app_dir is not None:
|
|
152
|
+
for metadata_file_path in app_dir.glob("*_metadata.json"):
|
|
153
|
+
try:
|
|
154
|
+
with metadata_file_path.open() as metadata_file:
|
|
155
|
+
metadata = json.load(metadata_file)
|
|
156
|
+
target_app_name = metadata.get("app")
|
|
157
|
+
target_app_author = metadata.get("author", target_app_author)
|
|
158
|
+
is_gui = metadata.get("is_gui", is_gui)
|
|
159
|
+
report_exceptions = metadata.get("report_exceptions", report_exceptions)
|
|
160
|
+
except (json.JSONDecodeError, OSError):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
if target_app_name is None:
|
|
164
|
+
# Try to derive app name from CLIP directories
|
|
165
|
+
if app_dir is not None:
|
|
166
|
+
for d in app_dir.iterdir():
|
|
167
|
+
if d.is_dir():
|
|
168
|
+
m = clip_regex.match(d.name)
|
|
169
|
+
if m and Path(d, "Scripts", "python.exe").exists():
|
|
170
|
+
target_app_name = m.group(1)
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
log = _setup_logging(target_app_name or "pyship", is_gui)
|
|
174
|
+
|
|
175
|
+
if report_exceptions:
|
|
176
|
+
_init_sentry()
|
|
177
|
+
|
|
178
|
+
log.info(f"app_dir={app_dir}")
|
|
179
|
+
|
|
180
|
+
if target_app_name is None:
|
|
181
|
+
log.error(f"could not derive target app name in {app_dir}")
|
|
182
|
+
return ERROR_RETURN_CODE
|
|
183
|
+
|
|
184
|
+
log.info(f"target_app_name={target_app_name}")
|
|
185
|
+
|
|
186
|
+
glob_string = f"{target_app_name}_*"
|
|
187
|
+
|
|
188
|
+
restart_monitor = RestartMonitor()
|
|
189
|
+
|
|
190
|
+
while (return_code is None or return_code == RESTART_RETURN_CODE) and not restart_monitor.excessive():
|
|
191
|
+
restart_monitor.add()
|
|
192
|
+
|
|
193
|
+
search_dirs = []
|
|
194
|
+
if app_dir is not None:
|
|
195
|
+
search_dirs.append(app_dir)
|
|
196
|
+
|
|
197
|
+
# Also search user data dir (matches platformdirs.user_data_dir layout)
|
|
198
|
+
local_app_data = os.environ.get("LOCALAPPDATA")
|
|
199
|
+
if local_app_data:
|
|
200
|
+
user_data_dir = Path(local_app_data, target_app_author, target_app_name)
|
|
201
|
+
if user_data_dir.exists():
|
|
202
|
+
search_dirs.append(user_data_dir)
|
|
203
|
+
|
|
204
|
+
if additional_path is not None:
|
|
205
|
+
search_dirs.append(Path(additional_path))
|
|
206
|
+
|
|
207
|
+
candidate_dirs = []
|
|
208
|
+
for search_dir in search_dirs:
|
|
209
|
+
for d in Path(search_dir).glob(glob_string):
|
|
210
|
+
if d.is_dir():
|
|
211
|
+
candidate_dirs.append(d)
|
|
212
|
+
|
|
213
|
+
versions = {}
|
|
214
|
+
for candidate_dir in candidate_dirs:
|
|
215
|
+
matches = clip_regex.match(candidate_dir.name)
|
|
216
|
+
if matches is not None:
|
|
217
|
+
version_str = matches.group(2)
|
|
218
|
+
version_tuple = _compare_versions(version_str)
|
|
219
|
+
if any(v > 0 for v in version_tuple):
|
|
220
|
+
versions[version_tuple] = candidate_dir
|
|
221
|
+
else:
|
|
222
|
+
log.error(f"could not get version out of {candidate_dir}")
|
|
223
|
+
|
|
224
|
+
if len(versions) > 0:
|
|
225
|
+
latest_version = sorted(versions.keys())[-1]
|
|
226
|
+
log.info(f"latest_version={'.'.join(str(v) for v in latest_version)}")
|
|
227
|
+
|
|
228
|
+
python_exe_path = Path(versions[latest_version], "Scripts", PYTHON_INTERPRETER_EXES[is_gui])
|
|
229
|
+
|
|
230
|
+
if python_exe_path.exists():
|
|
231
|
+
cmd = [str(python_exe_path), "-m", target_app_name]
|
|
232
|
+
# Forward any extra arguments (skip --app-dir and its value)
|
|
233
|
+
forwarded_args = _get_forwarded_args()
|
|
234
|
+
cmd.extend(forwarded_args)
|
|
235
|
+
|
|
236
|
+
log.info(f"cmd={cmd}")
|
|
237
|
+
try:
|
|
238
|
+
target_process = subprocess.run(cmd, cwd=str(python_exe_path.parent), capture_output=True, text=True)
|
|
239
|
+
return_code = target_process.returncode
|
|
240
|
+
|
|
241
|
+
std_out = target_process.stdout
|
|
242
|
+
std_err = target_process.stderr
|
|
243
|
+
|
|
244
|
+
if (std_err and std_err.strip()) or (return_code != OK_RETURN_CODE and return_code != RESTART_RETURN_CODE):
|
|
245
|
+
if std_out and std_out.strip():
|
|
246
|
+
log.warning(std_out)
|
|
247
|
+
if std_err and std_err.strip():
|
|
248
|
+
log.error(std_err)
|
|
249
|
+
|
|
250
|
+
for name, std_x, sys_f in [("stdout", std_out, sys.stdout), ("stderr", std_err, sys.stderr)]:
|
|
251
|
+
if std_x and std_x.strip():
|
|
252
|
+
for so_line in std_x.splitlines():
|
|
253
|
+
so_line_strip = so_line.strip()
|
|
254
|
+
if so_line_strip:
|
|
255
|
+
log.info(f"{name}:{so_line_strip}")
|
|
256
|
+
print(std_x, file=sys_f)
|
|
257
|
+
|
|
258
|
+
log.info(f"return_code={return_code}")
|
|
259
|
+
|
|
260
|
+
except FileNotFoundError as e:
|
|
261
|
+
log.error(f"{e} {cmd}")
|
|
262
|
+
return_code = ERROR_RETURN_CODE
|
|
263
|
+
else:
|
|
264
|
+
log.error(f"python exe not found at {python_exe_path}")
|
|
265
|
+
return_code = CAN_NOT_FIND_FILE_RETURN_CODE
|
|
266
|
+
else:
|
|
267
|
+
log.error(f"could not find any expected application version in {search_dirs} ({glob_string=})")
|
|
268
|
+
return_code = ERROR_RETURN_CODE
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
if restart_monitor.excessive():
|
|
272
|
+
log.error(f"excessive restarts restarts={restart_monitor.restarts}")
|
|
273
|
+
|
|
274
|
+
if return_code is None:
|
|
275
|
+
return_code = ERROR_RETURN_CODE
|
|
276
|
+
|
|
277
|
+
log.info(f"returning : return_code={return_code}")
|
|
278
|
+
|
|
279
|
+
return return_code
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _get_forwarded_args():
|
|
283
|
+
"""
|
|
284
|
+
Parse sys.argv to extract arguments that should be forwarded to the target app.
|
|
285
|
+
Strips out --app-dir and its value.
|
|
286
|
+
"""
|
|
287
|
+
args = []
|
|
288
|
+
skip_next = False
|
|
289
|
+
for i, arg in enumerate(sys.argv[1:], 1):
|
|
290
|
+
if skip_next:
|
|
291
|
+
skip_next = False
|
|
292
|
+
continue
|
|
293
|
+
if arg == "--app-dir":
|
|
294
|
+
skip_next = True
|
|
295
|
+
continue
|
|
296
|
+
args.append(arg)
|
|
297
|
+
return args
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
parser = argparse.ArgumentParser(description="pyship standalone launcher")
|
|
302
|
+
parser.add_argument("--app-dir", type=str, default=None, help="application directory")
|
|
303
|
+
known_args, _ = parser.parse_known_args()
|
|
304
|
+
|
|
305
|
+
exit_app_dir = None
|
|
306
|
+
if known_args.app_dir:
|
|
307
|
+
exit_app_dir = Path(known_args.app_dir)
|
|
308
|
+
|
|
309
|
+
sys.exit(launch(app_dir=exit_app_dir))
|
pyship/launcher/metadata.py
CHANGED
|
@@ -20,8 +20,13 @@ def calculate_metadata(target_app_name: str, target_app_author: str, target_app_
|
|
|
20
20
|
"icon_sha256": get_file_sha256(icon_path),
|
|
21
21
|
"is_gui": is_gui,
|
|
22
22
|
}
|
|
23
|
+
# Hash launcher Python sources
|
|
23
24
|
for p in launcher_source_dir.glob("*.py"):
|
|
24
25
|
launcher_metadata[f"{p.name}_sha256"] = get_file_sha256(p)
|
|
26
|
+
# Hash the C# stub template (in the parent pyship package)
|
|
27
|
+
launcher_stub_path = launcher_source_dir.parent / "launcher_stub.py"
|
|
28
|
+
if launcher_stub_path.exists():
|
|
29
|
+
launcher_metadata["launcher_stub.py_sha256"] = get_file_sha256(launcher_stub_path)
|
|
25
30
|
return launcher_metadata
|
|
26
31
|
|
|
27
32
|
|
pyship/launcher_stub.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
from typeguard import typechecked
|
|
7
|
+
from balsa import get_logger
|
|
8
|
+
|
|
9
|
+
from pyship import __application_name__
|
|
10
|
+
|
|
11
|
+
log = get_logger(__application_name__)
|
|
12
|
+
|
|
13
|
+
# C# source template for the launcher stub.
|
|
14
|
+
# Placeholders: {app_name}
|
|
15
|
+
# The stub finds the latest CLIP directory and runs the standalone Python launcher script.
|
|
16
|
+
CS_LAUNCHER_TEMPLATE = r"""
|
|
17
|
+
using System;
|
|
18
|
+
using System.Diagnostics;
|
|
19
|
+
using System.IO;
|
|
20
|
+
using System.Linq;
|
|
21
|
+
using System.Reflection;
|
|
22
|
+
|
|
23
|
+
class Program
|
|
24
|
+
{{
|
|
25
|
+
static int Main(string[] args)
|
|
26
|
+
{{
|
|
27
|
+
string appName = "{app_name}";
|
|
28
|
+
string exePath = Assembly.GetEntryAssembly().Location;
|
|
29
|
+
string launcherDir = Path.GetDirectoryName(exePath);
|
|
30
|
+
string appDir = Directory.GetParent(launcherDir).FullName;
|
|
31
|
+
|
|
32
|
+
// Find CLIP directories matching appName_version pattern
|
|
33
|
+
string searchPattern = appName + "_*";
|
|
34
|
+
string[] clipDirs;
|
|
35
|
+
try
|
|
36
|
+
{{
|
|
37
|
+
clipDirs = Directory.GetDirectories(appDir, searchPattern);
|
|
38
|
+
}}
|
|
39
|
+
catch (Exception)
|
|
40
|
+
{{
|
|
41
|
+
clipDirs = new string[0];
|
|
42
|
+
}}
|
|
43
|
+
|
|
44
|
+
// Filter to directories that contain Scripts\python.exe
|
|
45
|
+
var validClips = clipDirs
|
|
46
|
+
.Where(d => File.Exists(Path.Combine(d, "Scripts", "python.exe")))
|
|
47
|
+
.ToArray();
|
|
48
|
+
|
|
49
|
+
if (validClips.Length == 0)
|
|
50
|
+
{{
|
|
51
|
+
string msg = "No Python environment found for " + appName + " in " + appDir;
|
|
52
|
+
{error_handler}
|
|
53
|
+
return 1;
|
|
54
|
+
}}
|
|
55
|
+
|
|
56
|
+
// Sort by version (directory name after appName_) and pick the latest
|
|
57
|
+
Array.Sort(validClips, (a, b) =>
|
|
58
|
+
{{
|
|
59
|
+
string verA = Path.GetFileName(a).Substring(appName.Length + 1);
|
|
60
|
+
string verB = Path.GetFileName(b).Substring(appName.Length + 1);
|
|
61
|
+
return CompareVersions(verA, verB);
|
|
62
|
+
}});
|
|
63
|
+
|
|
64
|
+
string latestClip = validClips[validClips.Length - 1];
|
|
65
|
+
string pythonExe = Path.Combine(latestClip, "Scripts", "python.exe");
|
|
66
|
+
string launcherScript = Path.Combine(launcherDir, appName + "_launcher.py");
|
|
67
|
+
|
|
68
|
+
if (!File.Exists(launcherScript))
|
|
69
|
+
{{
|
|
70
|
+
string msg = "Launcher script not found: " + launcherScript;
|
|
71
|
+
{error_handler}
|
|
72
|
+
return 1;
|
|
73
|
+
}}
|
|
74
|
+
|
|
75
|
+
// Build arguments: launcher script path + forwarded args
|
|
76
|
+
string arguments = "\"" + launcherScript + "\" --app-dir \"" + appDir + "\"";
|
|
77
|
+
foreach (string arg in args)
|
|
78
|
+
{{
|
|
79
|
+
arguments += " \"" + arg + "\"";
|
|
80
|
+
}}
|
|
81
|
+
|
|
82
|
+
ProcessStartInfo psi = new ProcessStartInfo();
|
|
83
|
+
psi.FileName = pythonExe;
|
|
84
|
+
psi.Arguments = arguments;
|
|
85
|
+
psi.UseShellExecute = false;
|
|
86
|
+
psi.WorkingDirectory = launcherDir;
|
|
87
|
+
|
|
88
|
+
try
|
|
89
|
+
{{
|
|
90
|
+
Process process = Process.Start(psi);
|
|
91
|
+
process.WaitForExit();
|
|
92
|
+
return process.ExitCode;
|
|
93
|
+
}}
|
|
94
|
+
catch (Exception ex)
|
|
95
|
+
{{
|
|
96
|
+
string msg = "Failed to start: " + ex.Message;
|
|
97
|
+
{error_handler}
|
|
98
|
+
return 1;
|
|
99
|
+
}}
|
|
100
|
+
}}
|
|
101
|
+
|
|
102
|
+
static int CompareVersions(string a, string b)
|
|
103
|
+
{{
|
|
104
|
+
string[] partsA = a.Split('.');
|
|
105
|
+
string[] partsB = b.Split('.');
|
|
106
|
+
int maxLen = Math.Max(partsA.Length, partsB.Length);
|
|
107
|
+
for (int i = 0; i < maxLen; i++)
|
|
108
|
+
{{
|
|
109
|
+
int numA = 0, numB = 0;
|
|
110
|
+
if (i < partsA.Length) int.TryParse(partsA[i], out numA);
|
|
111
|
+
if (i < partsB.Length) int.TryParse(partsB[i], out numB);
|
|
112
|
+
if (numA != numB) return numA.CompareTo(numB);
|
|
113
|
+
}}
|
|
114
|
+
return 0;
|
|
115
|
+
}}
|
|
116
|
+
}}
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
CLI_ERROR_HANDLER = "Console.Error.WriteLine(msg);"
|
|
120
|
+
GUI_ERROR_HANDLER = "System.Windows.Forms.MessageBox.Show(msg, appName, System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error);"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@typechecked
|
|
124
|
+
def _find_csc_exe() -> Union[Path, None]:
|
|
125
|
+
"""
|
|
126
|
+
Locate the C# compiler (csc.exe) from .NET Framework.
|
|
127
|
+
:return: path to csc.exe or None if not found
|
|
128
|
+
"""
|
|
129
|
+
candidates = [
|
|
130
|
+
Path(r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"),
|
|
131
|
+
Path(r"C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe"),
|
|
132
|
+
]
|
|
133
|
+
for candidate in candidates:
|
|
134
|
+
if candidate.exists():
|
|
135
|
+
return candidate
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@typechecked
|
|
140
|
+
def compile_launcher_stub(app_name: str, icon_path: Union[Path, None], is_gui: bool, output_path: Path) -> Path:
|
|
141
|
+
"""
|
|
142
|
+
Compile the C# launcher stub into an .exe using csc.exe.
|
|
143
|
+
:param app_name: target application name
|
|
144
|
+
:param icon_path: path to .ico file (or None for no icon)
|
|
145
|
+
:param is_gui: True for GUI app (winexe target), False for CLI (exe target)
|
|
146
|
+
:param output_path: directory where the .exe will be written
|
|
147
|
+
:return: path to the compiled .exe
|
|
148
|
+
"""
|
|
149
|
+
csc_exe = _find_csc_exe()
|
|
150
|
+
if csc_exe is None:
|
|
151
|
+
raise FileNotFoundError("Could not find csc.exe (.NET Framework C# compiler). Ensure .NET Framework 4.x is installed.")
|
|
152
|
+
|
|
153
|
+
error_handler = GUI_ERROR_HANDLER if is_gui else CLI_ERROR_HANDLER
|
|
154
|
+
|
|
155
|
+
cs_source = CS_LAUNCHER_TEMPLATE.format(
|
|
156
|
+
app_name=app_name,
|
|
157
|
+
error_handler=error_handler,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
exe_path = Path(output_path, f"{app_name}.exe")
|
|
162
|
+
|
|
163
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".cs", delete=False, dir=str(output_path)) as cs_file:
|
|
164
|
+
cs_file.write(cs_source)
|
|
165
|
+
cs_file_path = cs_file.name
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
target = "/target:winexe" if is_gui else "/target:exe"
|
|
169
|
+
cmd = [
|
|
170
|
+
str(csc_exe),
|
|
171
|
+
target,
|
|
172
|
+
"/optimize+",
|
|
173
|
+
"/nologo",
|
|
174
|
+
f"/out:{exe_path}",
|
|
175
|
+
"/reference:System.Core.dll", # required for LINQ
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
if icon_path is not None and icon_path.exists():
|
|
179
|
+
cmd.append(f"/win32icon:{icon_path}")
|
|
180
|
+
|
|
181
|
+
if is_gui:
|
|
182
|
+
cmd.append("/reference:System.Windows.Forms.dll")
|
|
183
|
+
|
|
184
|
+
cmd.append(cs_file_path)
|
|
185
|
+
|
|
186
|
+
log.info(f"compiling launcher stub: {cmd}")
|
|
187
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
188
|
+
if result.returncode != 0:
|
|
189
|
+
log.error(f"csc.exe compilation failed:\nstdout: {result.stdout}\nstderr: {result.stderr}")
|
|
190
|
+
raise RuntimeError(f"C# compilation failed: {result.stderr}")
|
|
191
|
+
log.info(f"launcher stub compiled: {exe_path}")
|
|
192
|
+
finally:
|
|
193
|
+
try:
|
|
194
|
+
Path(cs_file_path).unlink()
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
return exe_path
|
pyship/nsis.py
CHANGED
|
@@ -7,9 +7,11 @@ import shutil
|
|
|
7
7
|
from typeguard import typechecked
|
|
8
8
|
from balsa import get_logger
|
|
9
9
|
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
10
12
|
from pyshipupdate import mkdirs, get_target_os
|
|
11
13
|
from pyship import __application_name__, AppInfo, subprocess_run, pyship_print, get_icon, PyshipLicenseFileDoesNotExist
|
|
12
|
-
|
|
14
|
+
from pyship.constants import is_ci
|
|
13
15
|
|
|
14
16
|
log = get_logger(__application_name__)
|
|
15
17
|
|
|
@@ -29,13 +31,13 @@ def get_folder_size(folder_path: Path) -> int:
|
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
@typechecked
|
|
32
|
-
def run_nsis(target_app_info: AppInfo, target_app_version: VersionInfo, app_dir: Path) -> Path:
|
|
34
|
+
def run_nsis(target_app_info: AppInfo, target_app_version: VersionInfo, app_dir: Path) -> Union[Path, None]:
|
|
33
35
|
"""
|
|
34
36
|
run nsis
|
|
35
37
|
:param target_app_info: target app info
|
|
36
38
|
:param target_app_version: target app version
|
|
37
39
|
:param app_dir: application dir
|
|
38
|
-
:return: path to installer exe, or None if
|
|
40
|
+
:return: path to installer exe, or None if NSIS not available (e.g. in CI)
|
|
39
41
|
"""
|
|
40
42
|
|
|
41
43
|
# basic format is from:
|
|
@@ -97,6 +99,7 @@ def run_nsis(target_app_info: AppInfo, target_app_version: VersionInfo, app_dir:
|
|
|
97
99
|
nsis_lines.append('Name "${COMPANYNAME} - ${APPNAME}"')
|
|
98
100
|
nsis_lines.append(f"Icon {icon_path}")
|
|
99
101
|
nsis_lines.append(f'outFile "{installer_exe_path}"')
|
|
102
|
+
nsis_lines.append("SetCompressor LZMA")
|
|
100
103
|
nsis_lines.append("")
|
|
101
104
|
nsis_lines.append("!include LogicLib.nsh")
|
|
102
105
|
|
|
@@ -214,8 +217,12 @@ def run_nsis(target_app_info: AppInfo, target_app_version: VersionInfo, app_dir:
|
|
|
214
217
|
if os.path.exists(make_nsis_path):
|
|
215
218
|
subprocess_run([make_nsis_path, nsis_file_path], target_app_info.project_dir)
|
|
216
219
|
else:
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
if is_ci():
|
|
221
|
+
log.warning(f"{make_nsis_path} not found - skipping installer creation (running in CI)")
|
|
222
|
+
return None
|
|
223
|
+
else:
|
|
224
|
+
log.fatal(f"{make_nsis_path} not found - see http://nsis.sourceforge.net to get NSIS (Nullsoft Scriptable Install System)")
|
|
225
|
+
raise FileNotFoundError(make_nsis_path)
|
|
219
226
|
|
|
220
227
|
else:
|
|
221
228
|
log.error(f"{license_file_name} file does not exist at {target_app_info.project_dir}")
|
pyship/pyship.py
CHANGED
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
from typing import Union
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import platformdirs
|
|
6
6
|
from attr import attrs
|
|
7
7
|
from typeguard import typechecked
|
|
8
8
|
from awsimple import S3Access
|
|
@@ -25,7 +25,7 @@ class PyShip:
|
|
|
25
25
|
project_dir: Path = Path() # target app project dir, e.g. the "home" directory of the project. If not set, current working directory is used.
|
|
26
26
|
dist_dir: Path = Path(DEFAULT_DIST_DIR_NAME) # many packaging tools (e.g filt, etc.) use "dist" as the package destination directory
|
|
27
27
|
find_links: list = list() # extra dirs for pip to use for packages not yet on PyPI (e.g. under local development)
|
|
28
|
-
cache_dir: Path = Path(
|
|
28
|
+
cache_dir: Path = Path(platformdirs.user_cache_dir(pyship_application_name, pyship_author)) # used to cache things like the embedded Python zip (to keep us off the python.org servers)
|
|
29
29
|
|
|
30
30
|
# cloud credentials, locations, etc.
|
|
31
31
|
cloud_bucket: Union[str, None] = None # e.g. AWS S3 bucket
|
|
@@ -36,16 +36,16 @@ class PyShip:
|
|
|
36
36
|
upload: bool = True # set to False in order to tell pyship to not attempt to perform file upload to the cloud (e.g. installer, clip files to AWS S3)
|
|
37
37
|
|
|
38
38
|
@typechecked
|
|
39
|
-
def ship(self) -> Path:
|
|
39
|
+
def ship(self) -> Union[Path, None]:
|
|
40
40
|
"""
|
|
41
41
|
Perform all the steps to ship the app, including creating the installer.
|
|
42
|
-
:return: the path to the created installer or None if
|
|
42
|
+
:return: the path to the created installer, or None if installer could not be created (e.g. NSIS not available in CI)
|
|
43
43
|
"""
|
|
44
44
|
|
|
45
45
|
start_time = datetime.now()
|
|
46
46
|
pyship_print(f"{pyship_application_name} starting (pyship={str(pyship_version)},pyshipupdate={str(pyshipupdate_version)})")
|
|
47
47
|
|
|
48
|
-
target_app_info = get_app_info(self.project_dir, self.dist_dir)
|
|
48
|
+
target_app_info = get_app_info(self.project_dir, self.dist_dir, self.cache_dir)
|
|
49
49
|
|
|
50
50
|
if self.project_dir is None:
|
|
51
51
|
assert isinstance(self.project_dir, Path)
|
|
@@ -61,13 +61,13 @@ class PyShip:
|
|
|
61
61
|
|
|
62
62
|
create_pyship_launcher(target_app_info, app_dir) # create the OS specific launcher executable
|
|
63
63
|
|
|
64
|
-
clip_dir = create_clip(target_app_info, app_dir,
|
|
64
|
+
clip_dir = create_clip(target_app_info, app_dir, Path(self.project_dir, self.dist_dir), self.cache_dir, self.find_links)
|
|
65
65
|
|
|
66
66
|
clip_file_path = create_clip_file(clip_dir) # create clip file
|
|
67
67
|
assert isinstance(target_app_info.version, VersionInfo)
|
|
68
|
-
installer_exe_path = run_nsis(target_app_info, target_app_info.version, app_dir) # create installer
|
|
68
|
+
installer_exe_path = run_nsis(target_app_info, target_app_info.version, app_dir) # create installer (may be None in CI)
|
|
69
69
|
|
|
70
|
-
if self.upload:
|
|
70
|
+
if self.upload and installer_exe_path is not None:
|
|
71
71
|
if self.cloud_profile is None and self.cloud_id is None:
|
|
72
72
|
pyship_print("no cloud access provided - will not attempt upload")
|
|
73
73
|
else:
|
pyship/pyship_custom_print.py
CHANGED
pyship/pyship_get_icon.py
CHANGED
|
@@ -10,7 +10,7 @@ import pyship
|
|
|
10
10
|
log = get_logger(__application_name__)
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
@typechecked
|
|
13
|
+
@typechecked
|
|
14
14
|
def get_icon(target_app_info: AppInfo, ui_print: Callable) -> Path:
|
|
15
15
|
"""
|
|
16
16
|
find either the target project's icon or the provided icon from pyship
|
|
@@ -48,4 +48,6 @@ def get_icon(target_app_info: AppInfo, ui_print: Callable) -> Path:
|
|
|
48
48
|
log.info(s)
|
|
49
49
|
ui_print(s)
|
|
50
50
|
|
|
51
|
+
assert icon_path is not None
|
|
52
|
+
|
|
51
53
|
return icon_path
|