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.
@@ -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))
@@ -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
 
@@ -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 could not be created
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
- log.fatal(f"{make_nsis_path} not found - see http://nsis.sourceforge.net to get NSIS (Nullsoft Scriptable Install System)")
218
- raise FileNotFoundError(make_nsis_path)
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 appdirs
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(appdirs.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)
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 it could not be created
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, True, Path(self.project_dir, self.dist_dir), self.cache_dir, self.find_links)
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:
@@ -16,7 +16,6 @@ window.withdraw() # no main window
16
16
  def pyship_print(s: str, is_gui: bool = False):
17
17
  log.info(s)
18
18
  if is_gui:
19
- global window
20
19
  label = Label(window, text=s) # adds to the window each time
21
20
  label.grid()
22
21
  label.update()
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