pyappify 1.0.4__tar.gz → 1.0.5__tar.gz
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.
- {pyappify-1.0.4 → pyappify-1.0.5}/PKG-INFO +1 -1
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify/__init__.py +171 -79
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify.egg-info/PKG-INFO +1 -1
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify.egg-info/SOURCES.txt +1 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/pyproject.toml +1 -1
- pyappify-1.0.5/tests/TestProcessControls.py +77 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify/main.py +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify.egg-info/dependency_links.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify.egg-info/entry_points.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify.egg-info/requires.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/pyappify.egg-info/top_level.txt +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/setup.cfg +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/tests/TestUpdateEnv.py +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/tests/TestUpgrade.py +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/tests/TestUpgradeOnline.py +0 -0
- {pyappify-1.0.4 → pyappify-1.0.5}/tests/TestVersion.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
# pyappify/__init__.py
|
|
1
|
+
# pyappify/__init__.py
|
|
2
2
|
import os
|
|
3
3
|
import signal
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
7
|
import shutil
|
|
8
|
+
import subprocess
|
|
8
9
|
import urllib.request
|
|
9
10
|
import zipfile
|
|
10
11
|
import threading
|
|
@@ -16,20 +17,20 @@ update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
|
|
|
16
17
|
app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
|
|
17
18
|
pyappify_version = os.environ.get("PYAPPIFY_VERSION")
|
|
18
19
|
pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
|
|
19
|
-
|
|
20
|
+
|
|
20
21
|
pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
|
|
21
22
|
logger = None
|
|
22
23
|
_console_logger = None
|
|
23
|
-
|
|
24
|
-
try:
|
|
25
|
-
pid = int(os.environ.get("PYAPPIFY_PID"))
|
|
26
|
-
except (ValueError, TypeError):
|
|
27
|
-
pid = None
|
|
28
|
-
|
|
29
|
-
import sys
|
|
30
|
-
|
|
31
|
-
try:
|
|
32
|
-
import ctypes
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
pid = int(os.environ.get("PYAPPIFY_PID"))
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
pid = None
|
|
29
|
+
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import ctypes
|
|
33
34
|
except ImportError:
|
|
34
35
|
ctypes = None
|
|
35
36
|
|
|
@@ -50,40 +51,63 @@ def _get_logger():
|
|
|
50
51
|
return _console_logger
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def _find_visible_window_by_pid(process_pid):
|
|
55
|
+
if not ctypes or sys.platform != "win32":
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
found_hwnd = []
|
|
59
|
+
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
|
60
|
+
|
|
61
|
+
def enum_windows_callback(hwnd, lParam):
|
|
62
|
+
owner_pid = ctypes.c_ulong()
|
|
63
|
+
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
|
|
64
|
+
if owner_pid.value == process_pid and ctypes.windll.user32.IsWindowVisible(hwnd):
|
|
65
|
+
found_hwnd.append(hwnd)
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
|
|
70
|
+
|
|
71
|
+
return found_hwnd[0] if found_hwnd else None
|
|
72
|
+
|
|
73
|
+
|
|
53
74
|
def minimize_window_by_pid(pid):
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return True
|
|
73
|
-
|
|
74
|
-
return False
|
|
75
|
-
|
|
76
|
-
def kill_pyappify():
|
|
75
|
+
hwnd = _find_visible_window_by_pid(pid)
|
|
76
|
+
if hwnd:
|
|
77
|
+
ctypes.windll.user32.ShowWindow(hwnd, 6)
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def bring_window_to_front_by_pid(pid):
|
|
84
|
+
hwnd = _find_visible_window_by_pid(pid)
|
|
85
|
+
if hwnd:
|
|
86
|
+
ctypes.windll.user32.ShowWindow(hwnd, 9)
|
|
87
|
+
return bool(ctypes.windll.user32.SetForegroundWindow(hwnd))
|
|
88
|
+
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def kill_pyappify(timeout=30):
|
|
77
93
|
if pid:
|
|
78
94
|
log = _get_logger()
|
|
79
95
|
log.info(f"Attempting to terminate process with PID: {pid}")
|
|
80
96
|
try:
|
|
81
97
|
os.kill(pid, signal.SIGTERM)
|
|
82
|
-
if not _wait_for_process_exit(pid):
|
|
98
|
+
if not _wait_for_process_exit(pid, timeout):
|
|
83
99
|
log.warning(f"Timed out waiting for process with PID {pid} to exit.")
|
|
100
|
+
return False
|
|
101
|
+
log.info(f'_wait_for_process_exit success {pid}')
|
|
102
|
+
return True
|
|
84
103
|
except Exception as e:
|
|
85
104
|
log.error(f"Failed to terminate process with PID {pid}: {e}")
|
|
86
|
-
|
|
105
|
+
return False
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def kill_pyappify_exe(timeout=30):
|
|
110
|
+
return kill_pyappify(timeout)
|
|
87
111
|
|
|
88
112
|
|
|
89
113
|
def _wait_for_process_exit(process_pid, timeout=30):
|
|
@@ -126,6 +150,74 @@ def _wait_for_process_exit(process_pid, timeout=30):
|
|
|
126
150
|
return False
|
|
127
151
|
|
|
128
152
|
|
|
153
|
+
def _is_process_running(process_pid):
|
|
154
|
+
if not process_pid:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
if sys.platform == "win32" and ctypes:
|
|
158
|
+
synchronize = 0x00100000
|
|
159
|
+
wait_timeout = 0x00000102
|
|
160
|
+
ctypes.windll.kernel32.OpenProcess.argtypes = (
|
|
161
|
+
ctypes.c_uint,
|
|
162
|
+
ctypes.c_bool,
|
|
163
|
+
ctypes.c_ulong,
|
|
164
|
+
)
|
|
165
|
+
ctypes.windll.kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
166
|
+
ctypes.windll.kernel32.WaitForSingleObject.argtypes = (
|
|
167
|
+
ctypes.c_void_p,
|
|
168
|
+
ctypes.c_uint,
|
|
169
|
+
)
|
|
170
|
+
ctypes.windll.kernel32.WaitForSingleObject.restype = ctypes.c_uint
|
|
171
|
+
ctypes.windll.kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
|
|
172
|
+
ctypes.windll.kernel32.CloseHandle.restype = ctypes.c_bool
|
|
173
|
+
handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_pid)
|
|
174
|
+
if not handle:
|
|
175
|
+
return False
|
|
176
|
+
try:
|
|
177
|
+
return ctypes.windll.kernel32.WaitForSingleObject(handle, 0) == wait_timeout
|
|
178
|
+
finally:
|
|
179
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
os.kill(process_pid, 0)
|
|
183
|
+
except OSError:
|
|
184
|
+
return False
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def show_pyappify(args=None, cwd=None, env=None):
|
|
189
|
+
global pid
|
|
190
|
+
|
|
191
|
+
log = _get_logger()
|
|
192
|
+
if _is_process_running(pid):
|
|
193
|
+
log.info(f"PyAppify is already running with PID: {pid}")
|
|
194
|
+
bring_window_to_front_by_pid(pid)
|
|
195
|
+
return pid
|
|
196
|
+
|
|
197
|
+
if not pyappify_executable:
|
|
198
|
+
log.error("PYAPPIFY_EXECUTABLE is not configured.")
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
command = [pyappify_executable]
|
|
202
|
+
if args:
|
|
203
|
+
if isinstance(args, str):
|
|
204
|
+
command.append(args)
|
|
205
|
+
else:
|
|
206
|
+
command.extend(args)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
process = subprocess.Popen(
|
|
210
|
+
command,
|
|
211
|
+
cwd=cwd or os.path.dirname(pyappify_executable) or None,
|
|
212
|
+
env=env,
|
|
213
|
+
)
|
|
214
|
+
pid = process.pid
|
|
215
|
+
return pid
|
|
216
|
+
except Exception as e:
|
|
217
|
+
log.error(f"Failed to start PyAppify executable {pyappify_executable}: {e}")
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
129
221
|
def _replace_executable(source_path, target_path, timeout=30):
|
|
130
222
|
deadline = time.monotonic() + timeout
|
|
131
223
|
last_error = None
|
|
@@ -160,7 +252,7 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
160
252
|
def _do_upgrade():
|
|
161
253
|
tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
|
|
162
254
|
try:
|
|
163
|
-
os.makedirs(tmp_dir, exist_ok=True)
|
|
255
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
164
256
|
downloaded_zip_path = None
|
|
165
257
|
for url in executable_zip_urls:
|
|
166
258
|
try:
|
|
@@ -172,10 +264,10 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
172
264
|
if stop_event and stop_event.is_set():
|
|
173
265
|
log.info("pyappify Upgrade download cancelled by stop event.")
|
|
174
266
|
return
|
|
175
|
-
chunk = response.read(8192)
|
|
176
|
-
if not chunk:
|
|
177
|
-
break
|
|
178
|
-
out_file.write(chunk)
|
|
267
|
+
chunk = response.read(8192)
|
|
268
|
+
if not chunk:
|
|
269
|
+
break
|
|
270
|
+
out_file.write(chunk)
|
|
179
271
|
downloaded_zip_path = local_zip_path
|
|
180
272
|
log.info(
|
|
181
273
|
f"pyappify download success {url}")
|
|
@@ -187,26 +279,26 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
187
279
|
if not downloaded_zip_path:
|
|
188
280
|
log.error("pyappify Failed to download upgrade.")
|
|
189
281
|
return
|
|
190
|
-
|
|
191
|
-
with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
|
|
192
|
-
zip_ref.extractall(tmp_dir)
|
|
193
|
-
|
|
194
|
-
new_executable_name = os.path.basename(pyappify_executable)
|
|
195
|
-
found_executable_path = None
|
|
196
|
-
for root, _, files in os.walk(tmp_dir):
|
|
197
|
-
if new_executable_name in files:
|
|
198
|
-
found_executable_path = os.path.join(root, new_executable_name)
|
|
199
|
-
break
|
|
200
|
-
|
|
282
|
+
|
|
283
|
+
with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
|
|
284
|
+
zip_ref.extractall(tmp_dir)
|
|
285
|
+
|
|
286
|
+
new_executable_name = os.path.basename(pyappify_executable)
|
|
287
|
+
found_executable_path = None
|
|
288
|
+
for root, _, files in os.walk(tmp_dir):
|
|
289
|
+
if new_executable_name in files:
|
|
290
|
+
found_executable_path = os.path.join(root, new_executable_name)
|
|
291
|
+
break
|
|
292
|
+
|
|
201
293
|
if not found_executable_path:
|
|
202
294
|
log.error("pyappify Executable not found in zip.")
|
|
203
295
|
return
|
|
204
|
-
|
|
205
|
-
sha256_hash = hashlib.sha256()
|
|
206
|
-
with open(found_executable_path, "rb") as f:
|
|
207
|
-
for byte_block in iter(lambda: f.read(4096), b""):
|
|
208
|
-
sha256_hash.update(byte_block)
|
|
209
|
-
|
|
296
|
+
|
|
297
|
+
sha256_hash = hashlib.sha256()
|
|
298
|
+
with open(found_executable_path, "rb") as f:
|
|
299
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
300
|
+
sha256_hash.update(byte_block)
|
|
301
|
+
|
|
210
302
|
if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
|
|
211
303
|
log.error("pyappify SHA256 checksum mismatch.")
|
|
212
304
|
return
|
|
@@ -216,10 +308,10 @@ def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None)
|
|
|
216
308
|
log.info(f"pyappify Upgrade success")
|
|
217
309
|
except Exception as e:
|
|
218
310
|
log.error(f"pyappify Upgrade failed: {e}")
|
|
219
|
-
finally:
|
|
220
|
-
if os.path.exists(tmp_dir):
|
|
221
|
-
shutil.rmtree(tmp_dir)
|
|
222
|
-
|
|
311
|
+
finally:
|
|
312
|
+
if os.path.exists(tmp_dir):
|
|
313
|
+
shutil.rmtree(tmp_dir)
|
|
314
|
+
|
|
223
315
|
thread = threading.Thread(target=_do_upgrade)
|
|
224
316
|
thread.daemon = True
|
|
225
317
|
thread.start()
|
|
@@ -260,20 +352,20 @@ def get_update_note():
|
|
|
260
352
|
def is_greater_version(version1, version2):
|
|
261
353
|
"""
|
|
262
354
|
Compares two semantic version strings.
|
|
263
|
-
|
|
264
|
-
Args:
|
|
265
|
-
version1 (str): The first version string.
|
|
266
|
-
version2 (str): The second version string.
|
|
267
|
-
|
|
268
|
-
Returns:
|
|
269
|
-
bool: True if version1 is strictly greater than version2,
|
|
270
|
-
False otherwise or if parsing fails.
|
|
271
|
-
"""
|
|
272
|
-
try:
|
|
273
|
-
version1 = version1.lstrip('v')
|
|
274
|
-
version2 = version2.lstrip('v')
|
|
275
|
-
v1_parts = [int(p) for p in version1.split('.')]
|
|
276
|
-
v2_parts = [int(p) for p in version2.split('.')]
|
|
277
|
-
return v1_parts > v2_parts
|
|
278
|
-
except (ValueError, AttributeError):
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
version1 (str): The first version string.
|
|
358
|
+
version2 (str): The second version string.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
bool: True if version1 is strictly greater than version2,
|
|
362
|
+
False otherwise or if parsing fails.
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
version1 = version1.lstrip('v')
|
|
366
|
+
version2 = version2.lstrip('v')
|
|
367
|
+
v1_parts = [int(p) for p in version1.split('.')]
|
|
368
|
+
v2_parts = [int(p) for p in version2.split('.')]
|
|
369
|
+
return v1_parts > v2_parts
|
|
370
|
+
except (ValueError, AttributeError):
|
|
279
371
|
return False
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
import pyappify
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestProcessControls(unittest.TestCase):
|
|
10
|
+
def setUp(self):
|
|
11
|
+
self.old_pid = pyappify.pid
|
|
12
|
+
self.old_executable = pyappify.pyappify_executable
|
|
13
|
+
self.old_logger = pyappify.logger
|
|
14
|
+
pyappify.logger = mock.Mock()
|
|
15
|
+
|
|
16
|
+
def tearDown(self):
|
|
17
|
+
pyappify.pid = self.old_pid
|
|
18
|
+
pyappify.pyappify_executable = self.old_executable
|
|
19
|
+
pyappify.logger = self.old_logger
|
|
20
|
+
|
|
21
|
+
def test_kill_pyappify_exe_terminates_configured_pid(self):
|
|
22
|
+
pyappify.pid = 1234
|
|
23
|
+
|
|
24
|
+
with mock.patch.object(pyappify.os, "kill") as kill, mock.patch.object(
|
|
25
|
+
pyappify, "_wait_for_process_exit", return_value=True
|
|
26
|
+
) as wait:
|
|
27
|
+
self.assertTrue(pyappify.kill_pyappify_exe(timeout=5))
|
|
28
|
+
|
|
29
|
+
kill.assert_called_once_with(1234, signal.SIGTERM)
|
|
30
|
+
wait.assert_called_once_with(1234, 5)
|
|
31
|
+
|
|
32
|
+
def test_show_pyappify_brings_existing_window_to_front(self):
|
|
33
|
+
pyappify.pid = 1234
|
|
34
|
+
|
|
35
|
+
with mock.patch.object(
|
|
36
|
+
pyappify, "_is_process_running", return_value=True
|
|
37
|
+
) as is_running, mock.patch.object(
|
|
38
|
+
pyappify, "bring_window_to_front_by_pid", return_value=True
|
|
39
|
+
) as bring_to_front, mock.patch.object(pyappify.subprocess, "Popen") as popen:
|
|
40
|
+
self.assertEqual(1234, pyappify.show_pyappify())
|
|
41
|
+
|
|
42
|
+
is_running.assert_called_once_with(1234)
|
|
43
|
+
bring_to_front.assert_called_once_with(1234)
|
|
44
|
+
popen.assert_not_called()
|
|
45
|
+
|
|
46
|
+
def test_show_pyappify_starts_executable_when_not_running(self):
|
|
47
|
+
executable = os.path.abspath("pyappify.exe")
|
|
48
|
+
pyappify.pid = 1234
|
|
49
|
+
pyappify.pyappify_executable = executable
|
|
50
|
+
process = mock.Mock()
|
|
51
|
+
process.pid = 5678
|
|
52
|
+
|
|
53
|
+
with mock.patch.object(
|
|
54
|
+
pyappify, "_is_process_running", return_value=False
|
|
55
|
+
), mock.patch.object(pyappify.subprocess, "Popen", return_value=process) as popen:
|
|
56
|
+
self.assertEqual(
|
|
57
|
+
5678,
|
|
58
|
+
pyappify.show_pyappify(args=["--profile", "dev"]),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
popen.assert_called_once_with(
|
|
62
|
+
[executable, "--profile", "dev"],
|
|
63
|
+
cwd=os.path.dirname(executable),
|
|
64
|
+
env=None,
|
|
65
|
+
)
|
|
66
|
+
self.assertEqual(5678, pyappify.pid)
|
|
67
|
+
|
|
68
|
+
def test_show_pyappify_returns_none_without_executable(self):
|
|
69
|
+
pyappify.pid = None
|
|
70
|
+
pyappify.pyappify_executable = None
|
|
71
|
+
|
|
72
|
+
with mock.patch.object(pyappify, "_is_process_running", return_value=False):
|
|
73
|
+
self.assertIsNone(pyappify.show_pyappify())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|